From 184b7f235e9510715a4f483d8303209476012daf Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 16 Oct 2026 17:30:31 +0800 Subject: [PATCH 1/9] feat(v2): land the dashscope 2.0 SDK stack alongside the 1.x API - add dashscope/v2 (client, protocols, resources, transport, types) and tests/v2; share one process-wide SDK session id via common/_sdk_session.py across the 1.x and 2.0 stacks - forward base_address/workspace through oss_utils certificate fetch so v2 clients fetch OSS upload certificates from their own endpoint - keep dashscope/acli and its unit tests at the online version - migrate RL data models to pydantic ConfigDict and restore the lazy Assistants imports so a plain "import dashscope" stays deprecation-warning free --- .gitignore | 5 + .pre-commit-config.yaml | 88 +- MANIFEST.in | 1 + README.md | 10 +- README_zh.md | 6 +- dashscope/__init__.py | 8 + dashscope/common/_sdk_session.py | 43 + dashscope/common/utils.py | 7 +- dashscope/embeddings/text_embedding.py | 1 + .../reinforcement/common/constants.py | 2 +- .../component/data/base_data_model.py | 5 +- .../component/data/group_reward_input.py | 5 +- .../component/data/group_reward_output.py | 5 +- .../component/data/reward_input.py | 5 +- .../component/data/reward_output.py | 5 +- .../component/data/rollout_input.py | 5 +- .../component/data/rollout_output.py | 5 +- dashscope/rerank/text_rerank.py | 1 + dashscope/utils/oss_utils.py | 7 + dashscope/v2/__init__.py | 40 + dashscope/v2/_config.py | 734 +++++++ dashscope/v2/_error_registry.py | 650 ++++++ dashscope/v2/_logging.py | 138 ++ dashscope/v2/_quickstart.py | 38 + dashscope/v2/client.py | 453 ++++ dashscope/v2/errors.py | 438 ++++ dashscope/v2/protocols/__init__.py | 49 + dashscope/v2/protocols/anthropic.py | 699 ++++++ dashscope/v2/protocols/base.py | 127 ++ dashscope/v2/protocols/native.py | 176 ++ dashscope/v2/protocols/openai.py | 265 +++ dashscope/v2/resources/__init__.py | 3 + dashscope/v2/resources/_agentstudio.py | 397 ++++ dashscope/v2/resources/_jobs.py | 625 ++++++ dashscope/v2/resources/_oss.py | 230 ++ dashscope/v2/resources/_sse.py | 78 + dashscope/v2/resources/apps.py | 572 +++++ dashscope/v2/resources/audio/__init__.py | 101 + dashscope/v2/resources/audio/_callbacks.py | 70 + dashscope/v2/resources/audio/asr.py | 628 ++++++ dashscope/v2/resources/audio/customization.py | 581 +++++ dashscope/v2/resources/audio/http_tts.py | 323 +++ dashscope/v2/resources/audio/phrases.py | 248 +++ dashscope/v2/resources/audio/realtime.py | 802 +++++++ dashscope/v2/resources/audio/translation.py | 255 +++ dashscope/v2/resources/audio/tts.py | 427 ++++ dashscope/v2/resources/chat/__init__.py | 39 + dashscope/v2/resources/chat/completions.py | 996 +++++++++ dashscope/v2/resources/decision.py | 287 +++ dashscope/v2/resources/deployments.py | 455 ++++ dashscope/v2/resources/embeddings.py | 429 ++++ dashscope/v2/resources/files.py | 386 ++++ dashscope/v2/resources/fine_tunes.py | 691 ++++++ dashscope/v2/resources/images.py | 812 +++++++ dashscope/v2/resources/models.py | 228 ++ dashscope/v2/resources/multimodal/__init__.py | 31 + dashscope/v2/resources/multimodal/chat.py | 333 +++ .../v2/resources/multimodal/embeddings.py | 244 +++ .../v2/resources/reinforcement_learning.py | 897 ++++++++ dashscope/v2/resources/rerank.py | 168 ++ dashscope/v2/resources/tingwu.py | 340 +++ dashscope/v2/resources/tokenization.py | 142 ++ dashscope/v2/resources/videos.py | 591 ++++++ dashscope/v2/transport/__init__.py | 11 + dashscope/v2/transport/_ident.py | 91 + dashscope/v2/transport/_retry.py | 54 + dashscope/v2/transport/http.py | 461 ++++ dashscope/v2/transport/sse.py | 156 ++ dashscope/v2/transport/ws.py | 344 +++ dashscope/v2/transport/ws_connection.py | 434 ++++ dashscope/v2/types/__init__.py | 49 + dashscope/v2/types/common.py | 143 ++ dashscope/v2/types/completions.py | 170 ++ dashscope/v2/types/messages.py | 241 +++ dashscope/version.py | 2 +- dev.md | 44 +- requirements.txt | 4 +- samples/agentstudio_deployments.py | 34 - samples/test_agentstudio.py | 66 - samples/test_aio_generation.py | 577 ----- samples/test_aio_image_synthesis.py | 117 - samples/test_aio_multimodal_conversation.py | 304 --- samples/test_aio_video_synthesis.py | 92 - samples/test_application.py | 28 - samples/test_assistant_api.py | 75 - samples/test_assistant_api_file.py | 16 - samples/test_assistant_api_simple.py | 84 - samples/test_cv_models.py | 37 - samples/test_generation.py | 417 ---- samples/test_image_generation.py | 105 - samples/test_image_synthesis.py | 52 - samples/test_multimodal_conversation.py | 267 --- samples/test_multimodal_embedding.py | 54 - samples/test_qwen_asr.py | 34 - samples/test_qwen_tts.py | 47 - samples/test_text_rerank.py | 34 - samples/test_tingwu_realtime.py | 183 -- samples/test_tingwu_usages.py | 16 - samples/test_video_synthesis.py | 136 -- setup.py | 34 + specs/chat.yaml | 314 +++ tests/unit/test_async_custom_session.py | 8 +- tests/unit/test_cli_agent_route.py | 16 +- tests/unit/test_maas_region.py | 6 +- .../unit/test_recognition_explicit_params.py | 98 +- .../protocols/test_anthropic_conformance.py | 214 ++ .../protocols/test_integration_endpoints.py | 109 + tests/v2/protocols/test_openai_conformance.py | 150 ++ tests/v2/smoke/test_examples.py | 61 + tests/v2/smoke/test_full_surface.py | 693 ++++++ tests/v2/test_agentstudio_errors.py | 368 ++++ tests/v2/test_apps.py | 460 ++++ tests/v2/test_audio.py | 691 ++++++ tests/v2/test_audio_connection.py | 271 +++ tests/v2/test_audio_management.py | 476 +++++ tests/v2/test_audio_omni_session.py | 1881 +++++++++++++++++ tests/v2/test_audio_translation.py | 366 ++++ tests/v2/test_audio_tts_http.py | 328 +++ tests/v2/test_audio_tts_realtime.py | 499 +++++ tests/v2/test_audio_tts_session.py | 495 +++++ tests/v2/test_audio_ws_integration.py | 1235 +++++++++++ tests/v2/test_base_url_routing.py | 240 +++ tests/v2/test_chat.py | 1512 +++++++++++++ tests/v2/test_config.py | 615 ++++++ tests/v2/test_decision.py | 558 +++++ tests/v2/test_error_registry.py | 296 +++ tests/v2/test_errors.py | 551 +++++ tests/v2/test_files_models.py | 523 +++++ tests/v2/test_finetune_deployments.py | 795 +++++++ tests/v2/test_http_pool.py | 134 ++ tests/v2/test_images_videos.py | 697 ++++++ tests/v2/test_jobs.py | 505 +++++ tests/v2/test_logging.py | 181 ++ tests/v2/test_multimodal.py | 591 ++++++ tests/v2/test_package_root.py | 114 + tests/v2/test_protocols.py | 1713 +++++++++++++++ tests/v2/test_rl.py | 805 +++++++ tests/v2/test_settings.py | 225 ++ tests/v2/test_sse.py | 399 ++++ tests/v2/test_sse_comprehensive_verify.py | 437 ++++ tests/v2/test_tingwu.py | 356 ++++ tests/v2/test_tools_resources.py | 605 ++++++ tests/v2/test_transport.py | 585 +++++ tests/v2/test_types_messages.py | 358 ++++ tests/v2/test_ws_connection.py | 318 +++ 145 files changed, 40398 insertions(+), 2897 deletions(-) create mode 100644 dashscope/common/_sdk_session.py create mode 100644 dashscope/v2/__init__.py create mode 100644 dashscope/v2/_config.py create mode 100644 dashscope/v2/_error_registry.py create mode 100644 dashscope/v2/_logging.py create mode 100644 dashscope/v2/_quickstart.py create mode 100644 dashscope/v2/client.py create mode 100644 dashscope/v2/errors.py create mode 100644 dashscope/v2/protocols/__init__.py create mode 100644 dashscope/v2/protocols/anthropic.py create mode 100644 dashscope/v2/protocols/base.py create mode 100644 dashscope/v2/protocols/native.py create mode 100644 dashscope/v2/protocols/openai.py create mode 100644 dashscope/v2/resources/__init__.py create mode 100644 dashscope/v2/resources/_agentstudio.py create mode 100644 dashscope/v2/resources/_jobs.py create mode 100644 dashscope/v2/resources/_oss.py create mode 100644 dashscope/v2/resources/_sse.py create mode 100644 dashscope/v2/resources/apps.py create mode 100644 dashscope/v2/resources/audio/__init__.py create mode 100644 dashscope/v2/resources/audio/_callbacks.py create mode 100644 dashscope/v2/resources/audio/asr.py create mode 100644 dashscope/v2/resources/audio/customization.py create mode 100644 dashscope/v2/resources/audio/http_tts.py create mode 100644 dashscope/v2/resources/audio/phrases.py create mode 100644 dashscope/v2/resources/audio/realtime.py create mode 100644 dashscope/v2/resources/audio/translation.py create mode 100644 dashscope/v2/resources/audio/tts.py create mode 100644 dashscope/v2/resources/chat/__init__.py create mode 100644 dashscope/v2/resources/chat/completions.py create mode 100644 dashscope/v2/resources/decision.py create mode 100644 dashscope/v2/resources/deployments.py create mode 100644 dashscope/v2/resources/embeddings.py create mode 100644 dashscope/v2/resources/files.py create mode 100644 dashscope/v2/resources/fine_tunes.py create mode 100644 dashscope/v2/resources/images.py create mode 100644 dashscope/v2/resources/models.py create mode 100644 dashscope/v2/resources/multimodal/__init__.py create mode 100644 dashscope/v2/resources/multimodal/chat.py create mode 100644 dashscope/v2/resources/multimodal/embeddings.py create mode 100644 dashscope/v2/resources/reinforcement_learning.py create mode 100644 dashscope/v2/resources/rerank.py create mode 100644 dashscope/v2/resources/tingwu.py create mode 100644 dashscope/v2/resources/tokenization.py create mode 100644 dashscope/v2/resources/videos.py create mode 100644 dashscope/v2/transport/__init__.py create mode 100644 dashscope/v2/transport/_ident.py create mode 100644 dashscope/v2/transport/_retry.py create mode 100644 dashscope/v2/transport/http.py create mode 100644 dashscope/v2/transport/sse.py create mode 100644 dashscope/v2/transport/ws.py create mode 100644 dashscope/v2/transport/ws_connection.py create mode 100644 dashscope/v2/types/__init__.py create mode 100644 dashscope/v2/types/common.py create mode 100644 dashscope/v2/types/completions.py create mode 100644 dashscope/v2/types/messages.py delete mode 100644 samples/agentstudio_deployments.py delete mode 100644 samples/test_agentstudio.py delete mode 100644 samples/test_aio_generation.py delete mode 100644 samples/test_aio_image_synthesis.py delete mode 100644 samples/test_aio_multimodal_conversation.py delete mode 100644 samples/test_aio_video_synthesis.py delete mode 100644 samples/test_application.py delete mode 100644 samples/test_assistant_api.py delete mode 100644 samples/test_assistant_api_file.py delete mode 100644 samples/test_assistant_api_simple.py delete mode 100644 samples/test_cv_models.py delete mode 100644 samples/test_generation.py delete mode 100644 samples/test_image_generation.py delete mode 100644 samples/test_image_synthesis.py delete mode 100644 samples/test_multimodal_conversation.py delete mode 100644 samples/test_multimodal_embedding.py delete mode 100644 samples/test_qwen_asr.py delete mode 100644 samples/test_qwen_tts.py delete mode 100644 samples/test_text_rerank.py delete mode 100644 samples/test_tingwu_realtime.py delete mode 100644 samples/test_tingwu_usages.py delete mode 100644 samples/test_video_synthesis.py create mode 100644 specs/chat.yaml create mode 100644 tests/v2/protocols/test_anthropic_conformance.py create mode 100644 tests/v2/protocols/test_integration_endpoints.py create mode 100644 tests/v2/protocols/test_openai_conformance.py create mode 100644 tests/v2/smoke/test_examples.py create mode 100644 tests/v2/smoke/test_full_surface.py create mode 100644 tests/v2/test_agentstudio_errors.py create mode 100644 tests/v2/test_apps.py create mode 100644 tests/v2/test_audio.py create mode 100644 tests/v2/test_audio_connection.py create mode 100644 tests/v2/test_audio_management.py create mode 100644 tests/v2/test_audio_omni_session.py create mode 100644 tests/v2/test_audio_translation.py create mode 100644 tests/v2/test_audio_tts_http.py create mode 100644 tests/v2/test_audio_tts_realtime.py create mode 100644 tests/v2/test_audio_tts_session.py create mode 100644 tests/v2/test_audio_ws_integration.py create mode 100644 tests/v2/test_base_url_routing.py create mode 100644 tests/v2/test_chat.py create mode 100644 tests/v2/test_config.py create mode 100644 tests/v2/test_decision.py create mode 100644 tests/v2/test_error_registry.py create mode 100644 tests/v2/test_errors.py create mode 100644 tests/v2/test_files_models.py create mode 100644 tests/v2/test_finetune_deployments.py create mode 100644 tests/v2/test_http_pool.py create mode 100644 tests/v2/test_images_videos.py create mode 100644 tests/v2/test_jobs.py create mode 100644 tests/v2/test_logging.py create mode 100644 tests/v2/test_multimodal.py create mode 100644 tests/v2/test_package_root.py create mode 100644 tests/v2/test_protocols.py create mode 100644 tests/v2/test_rl.py create mode 100644 tests/v2/test_settings.py create mode 100644 tests/v2/test_sse.py create mode 100644 tests/v2/test_sse_comprehensive_verify.py create mode 100644 tests/v2/test_tingwu.py create mode 100644 tests/v2/test_tools_resources.py create mode 100644 tests/v2/test_transport.py create mode 100644 tests/v2/test_types_messages.py create mode 100644 tests/v2/test_ws_connection.py diff --git a/.gitignore b/.gitignore index 891dd3da..c8a620a1 100644 --- a/.gitignore +++ b/.gitignore @@ -139,5 +139,10 @@ cython_debug/ /.acli/ +.qoder/ + +# Local design/analysis docs — kept out of the public repo +/md/ + # Local PyPI investigation tooling; the CI guard lives in tests/unit/. /scripts/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 12cf2858..7c40475d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,35 +3,34 @@ repos: rev: v4.3.0 hooks: - id: check-ast - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: sort-simple-yaml - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: check-yaml exclude: | (?x)^( meta.yaml | tests/legacy/ - | samples/ )$ - id: check-xml - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: check-toml - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: check-docstring-first - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: check-json - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: fix-encoding-pragma - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: detect-private-key - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - id: trailing-whitespace - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - repo: https://github.com/asottile/add-trailing-comma rev: v3.1.0 hooks: - id: add-trailing-comma - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: @@ -42,7 +41,6 @@ repos: | grpc\.py$ | ^docs | ^tests/legacy/ - | ^samples/ | \.html$ | \.pyi$ ) @@ -63,13 +61,13 @@ repos: hooks: - id: black args: [ --line-length=79 ] - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - repo: https://github.com/PyCQA/flake8 rev: 6.1.0 hooks: - id: flake8 args: [ "--extend-ignore=E203"] - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ - repo: https://github.com/pylint-dev/pylint rev: v3.0.2 hooks: @@ -77,8 +75,7 @@ repos: exclude: (?x)( ^docs - | ^tests/legacy/ - | ^samples/ + | ^tests/ | pb2\.py$ | grpc\.py$ | \.demo$ @@ -119,19 +116,72 @@ repos: --disable=C0115, # Disable missing class docstring for quick dev --disable=C0116, # Disable missing function or method docstring for quick dev ] + # Tests get their own entry (pylint 3.0.2 discovers rcfiles from the + # invocation CWD, not per linted-file directory, so a tests/pylintrc + # would never be found): source-oriented checks that fight idiomatic + # pytest are disabled here only, keeping dashscope/ on the strict set. + # W0621 fixture shadowing, W0613 mock handlers ignoring the request, + # W0212 tests reaching into _private members by design. + - id: pylint + name: pylint (tests) + files: ^tests/ + exclude: ^tests/legacy/ + args: [ + --disable=W0511, + --disable=W0718, + --disable=W0122, + --disable=C0103, + --disable=R0913, + --disable=E0401, + --disable=E1101, + --disable=C0415, + --disable=W0603, + --disable=R1705, + --disable=R0914, + --disable=E0601, + --disable=W0602, + --disable=W0604, + --disable=R0801, + --disable=R0902, + --disable=R0903, + --disable=C0123, + --disable=W0231, + --disable=W1113, + --disable=W0221, + --disable=R0401, + --disable=W0632, + --disable=W0123, + --disable=C3001, + --disable=W0201, + --disable=C0302, + --disable=W1203, + --disable=C2801, + --disable=C0114, # Disable missing module docstring for quick dev + --disable=C0115, # Disable missing class docstring for quick dev + --disable=C0116, # Disable missing function or method docstring for quick dev + --disable=W0621, + --disable=W0613, + --disable=W0212, + # ``assert x == []`` reads better than ``assert not x`` in pytest + # failure output, so C1803's simplification is unwanted here. + --disable=C1803, + # Long sequential arrange/act scripts are normal in integration + # tests; splitting them for R0915 hurts readability. + --disable=R0915, + ] - repo: https://github.com/pre-commit/mirrors-eslint rev: v7.32.0 hooks: - id: eslint files: \.(js|jsx)$ - exclude: '(.*js_third_party.*|^tests/legacy/|^samples/)' + exclude: '(.*js_third_party.*|^tests/legacy/)' args: [ '--fix' ] - repo: https://github.com/thibaudcolas/pre-commit-stylelint rev: v14.4.0 hooks: - id: stylelint files: \.(css)$ - exclude: '(.*css_third_party.*|^tests/legacy/|^samples/)' + exclude: '(.*css_third_party.*|^tests/legacy/)' args: [ '--fix' ] - repo: https://github.com/pre-commit/mirrors-prettier rev: 'v3.0.0' @@ -139,4 +189,4 @@ repos: - id: prettier additional_dependencies: [ 'prettier@3.0.0' ] files: \.(tsx?)$ - exclude: ^(tests/legacy|samples)/ + exclude: ^tests/legacy/ diff --git a/MANIFEST.in b/MANIFEST.in index f9237af5..5ddd1f49 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,4 +4,5 @@ include dashscope/__init__.pyi include NOTICE include LICENSE.certifi recursive-include dashscope/acli/examples *.md *.toml *.py +recursive-include examples *.md *.py include requirements.txt diff --git a/README.md b/README.md index 12649cd9..d5f07ea7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ from http import HTTPStatus from dashscope import Generation responses = Generation.call( - model="qwen-plus", + model="qwen3.7-plus", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Who are you?"}, @@ -160,7 +160,7 @@ from dashscope.aigc.chat_completion import Completions dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") response = Completions.create( - model="qwen-max", + model="qwen3.8-max", messages=[{"role": "user", "content": "Hello"}], api_key="YOUR-DASHSCOPE-API-KEY", stream=False, # set True to get a generator of ChatCompletionChunk @@ -168,7 +168,7 @@ response = Completions.create( print(response) ``` -A complete runnable example is available in [`samples/set_region_example.py`](samples/set_region_example.py). +A complete runnable example is available in [`examples/legacy_1x/set_region_example.py`](examples/legacy_1x/set_region_example.py). ## AI Assistant: DashScope SDK Expert @@ -184,9 +184,9 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built |----------|-------------------|-----------| | Text Generation | qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash | `Generation` | | Multi-Modal Understanding | qwen3.5-omni-plus, qwen3.7-plus (vision) | `MultiModalConversation` | -| Text Embedding | text-embedding-v4, text-embedding-v3 | `TextEmbedding` | +| Text Embedding | qwen3.7-text-embedding, text-embedding-v4 | `TextEmbedding` | | Multi-Modal Embedding | tongyi-embedding-vision-plus, qwen3-vl-embedding | `MultiModalEmbedding` | -| Text ReRank | qwen3-rerank, gte-rerank-v2 | `TextReRank` | +| Text ReRank | qwen3.7-text-rerank, qwen3-rerank | `TextReRank` | | Image Generation | wan2.7-image-pro, qwen-image-2.0-pro | `ImageSynthesis` | | Video Generation | wan2.7-t2v, wan2.7-i2v, happyhorse-1.0-t2v/i2v | `VideoSynthesis` | | Speech Synthesis (TTS) | cosyvoice-v3.5-plus, cosyvoice-v1 | `SpeechSynthesizer`, `HttpSpeechSynthesizer` | diff --git a/README_zh.md b/README_zh.md index aadf223a..2af23d07 100644 --- a/README_zh.md +++ b/README_zh.md @@ -35,7 +35,7 @@ from http import HTTPStatus from dashscope import Generation responses = Generation.call( - model="qwen-plus", + model="qwen3.7-plus", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Who are you?"}, @@ -159,7 +159,7 @@ from dashscope.aigc.chat_completion import Completions dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") response = Completions.create( - model="qwen-max", + model="qwen3.8-max", messages=[{"role": "user", "content": "你好"}], api_key="YOUR-DASHSCOPE-API-KEY", stream=False, # 设为 True 则返回 ChatCompletionChunk 生成器 @@ -167,7 +167,7 @@ response = Completions.create( print(response) ``` -完整可运行示例见 [`samples/set_region_example.py`](samples/set_region_example.py)。 +完整可运行示例见 [`examples/legacy_1x/set_region_example.py`](examples/legacy_1x/set_region_example.py)。 ## AI 助手:DashScope SDK Expert diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 7cd5f596..99375a16 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -68,6 +68,8 @@ list_tokenizers, ) +# dashscope 2.0 public surface (implementation lives in dashscope/v2/). +from dashscope.v2 import AsyncClient, Client, WsConnection, chat, settings # cn-beijing defaults to the legacy dashscope.aliyuncs.com endpoints; # its MaaS URLs are only reachable via an explicit set_region() call. @@ -160,6 +162,7 @@ def __dir__(): "api_key", "api_key_file_path", "save_api_key", + "settings", "close_shared_aio_session", "close_shared_sync_session", "AioGeneration", @@ -212,6 +215,11 @@ def __dir__(): "MessageFile", "AssistantFile", "VideoSynthesis", + # dashscope 2.0 + "AsyncClient", + "Client", + "WsConnection", + "chat", "set_region", ] diff --git a/dashscope/common/_sdk_session.py b/dashscope/common/_sdk_session.py new file mode 100644 index 00000000..c5fdea8e --- /dev/null +++ b/dashscope/common/_sdk_session.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Single process-wide SDK session id. + +The ``x-dashscope-sdk-session-id`` header lets server-side access logs +group every request one SDK session makes. A process can emit requests +through both stacks at once -- the 1.x API classes and the 2.0 transport +-- and the 2.0 client even delegates some calls (rl, agentstudio) +straight into 1.x. If each stack minted its own id, one logical session +would split across several ids and the grouping breaks. + +This leaf module is the one source of truth both import. It depends only +on the stdlib so it can be pulled in from anywhere without dragging +``dashscope/__init__`` (and its v2 surface) into a cycle. + +The embedded ``acli`` deliberately keeps its own id: it is a one-way +sync copy of the agenticCLI repo (design doc §3.2), so wiring it to this +module would be silently reverted by the next sync and cannot be +upstreamed as written. +""" + +from __future__ import annotations + +import threading +import uuid + +_session_id = None +_lock = threading.Lock() + + +def sdk_session_id() -> str: + """Return this process's session id, generating it on first use. + + Double-checked locking: concurrent callers must not each mint their + own id and break the one-id-per-process invariant log grouping + relies on. + """ + global _session_id # pylint: disable=global-statement + if _session_id is None: + with _lock: + if _session_id is None: + _session_id = uuid.uuid4().hex + return _session_id diff --git a/dashscope/common/utils.py b/dashscope/common/utils.py index 8c2d79fb..bb9229ba 100644 --- a/dashscope/common/utils.py +++ b/dashscope/common/utils.py @@ -7,7 +7,6 @@ import platform import queue import threading -import uuid from dataclasses import dataclass from http import HTTPStatus from typing import Dict @@ -17,12 +16,16 @@ import requests from dashscope.api_entities.dashscope_response import DashScopeAPIResponse +from dashscope.common._sdk_session import sdk_session_id from dashscope.common.api_key import get_default_api_key from dashscope.common.constants import SSE_CONTENT_TYPE from dashscope.common.logging import logger from dashscope.version import __version__ -_SDK_SESSION_ID = uuid.uuid4().hex +# Shared with the 2.0 transport so one process reports one +# x-dashscope-sdk-session-id across both stacks (see _sdk_session; +# acli deliberately keeps its own, it is a one-way sync copy). +_SDK_SESSION_ID = sdk_session_id() def is_validate_fine_tune_file(file_path): diff --git a/dashscope/embeddings/text_embedding.py b/dashscope/embeddings/text_embedding.py index 264045a6..b388bad2 100644 --- a/dashscope/embeddings/text_embedding.py +++ b/dashscope/embeddings/text_embedding.py @@ -17,6 +17,7 @@ class Models: text_embedding_v2 = "text-embedding-v2" text_embedding_v3 = "text-embedding-v3" text_embedding_v4 = "text-embedding-v4" + qwen3_7_text_embedding = "qwen3.7-text-embedding" @classmethod def call( # type: ignore[override] # pylint: disable=arguments-renamed diff --git a/dashscope/finetune/reinforcement/common/constants.py b/dashscope/finetune/reinforcement/common/constants.py index 47c1eba5..e87de62e 100644 --- a/dashscope/finetune/reinforcement/common/constants.py +++ b/dashscope/finetune/reinforcement/common/constants.py @@ -35,7 +35,7 @@ def get_int_env(env_var: str, default: int) -> int: # -------------------------- # Logging Configuration # -------------------------- -LOGGER_NAME = os.environ.get("LOGGER_NAME", "dashscope") +LOGGER_NAME = os.environ.get("LOGGER_NAME", "dashscope.finetune.reinforcement") LOG_LEVEL = os.environ.get("LOG_LEVEL", "info").upper() LOGGER_FILTER_FIELDS = { "trigger_token", diff --git a/dashscope/finetune/reinforcement/component/data/base_data_model.py b/dashscope/finetune/reinforcement/component/data/base_data_model.py index 319d7d0f..c1970c53 100644 --- a/dashscope/finetune/reinforcement/component/data/base_data_model.py +++ b/dashscope/finetune/reinforcement/component/data/base_data_model.py @@ -9,7 +9,7 @@ import uuid from enum import Enum from typing import Any, Dict, List, Optional, Union -from pydantic import BaseModel, Field, SecretStr +from pydantic import BaseModel, ConfigDict, Field, SecretStr from dashscope.finetune.reinforcement.common.model_types import ( FunctionType as FuncType, @@ -70,8 +70,7 @@ class BaseDataModel(BaseModel): func_type: FuncType - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") # ========================================================================== # diff --git a/dashscope/finetune/reinforcement/component/data/group_reward_input.py b/dashscope/finetune/reinforcement/component/data/group_reward_input.py index 3f3f469d..acd897f6 100644 --- a/dashscope/finetune/reinforcement/component/data/group_reward_input.py +++ b/dashscope/finetune/reinforcement/component/data/group_reward_input.py @@ -7,7 +7,7 @@ """ from typing import Any, List, Optional -from pydantic import Field +from pydantic import ConfigDict, Field from dashscope.finetune.reinforcement.common.model_types import ( FunctionType as FuncType, @@ -49,5 +49,4 @@ class GroupRewardInput(BaseDataModel): "calculation or automated evaluation.", ) - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") diff --git a/dashscope/finetune/reinforcement/component/data/group_reward_output.py b/dashscope/finetune/reinforcement/component/data/group_reward_output.py index cbb06299..07208c84 100644 --- a/dashscope/finetune/reinforcement/component/data/group_reward_output.py +++ b/dashscope/finetune/reinforcement/component/data/group_reward_output.py @@ -6,7 +6,7 @@ """ from typing import List, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from dashscope.finetune.reinforcement.component.data.base_data_model import ( TaskStatus, @@ -53,5 +53,4 @@ class GroupRewardOutput(BaseModel): description="Error details if the reward computation failed.", ) - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") diff --git a/dashscope/finetune/reinforcement/component/data/reward_input.py b/dashscope/finetune/reinforcement/component/data/reward_input.py index 0cd01a6a..f21bd71e 100644 --- a/dashscope/finetune/reinforcement/component/data/reward_input.py +++ b/dashscope/finetune/reinforcement/component/data/reward_input.py @@ -7,7 +7,7 @@ """ from typing import Any, Optional -from pydantic import Field +from pydantic import ConfigDict, Field from dashscope.finetune.reinforcement.common.model_types import ( FunctionType as FuncType, @@ -49,5 +49,4 @@ class RewardInput(BaseDataModel): description="The agent output for the reward calculation.", ) - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") diff --git a/dashscope/finetune/reinforcement/component/data/reward_output.py b/dashscope/finetune/reinforcement/component/data/reward_output.py index 5ff5ab9b..93c3061c 100644 --- a/dashscope/finetune/reinforcement/component/data/reward_output.py +++ b/dashscope/finetune/reinforcement/component/data/reward_output.py @@ -6,7 +6,7 @@ """ from typing import Any, Dict, Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from dashscope.finetune.reinforcement.component.data.base_data_model import ( TaskStatus, @@ -67,5 +67,4 @@ def normalize_error_code(cls, v: Any) -> Any: """Normalize int error codes to str (protocol stays string).""" return _normalize_error_code(v) - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") diff --git a/dashscope/finetune/reinforcement/component/data/rollout_input.py b/dashscope/finetune/reinforcement/component/data/rollout_input.py index f845cba0..ea7bfa1a 100644 --- a/dashscope/finetune/reinforcement/component/data/rollout_input.py +++ b/dashscope/finetune/reinforcement/component/data/rollout_input.py @@ -7,7 +7,7 @@ """ from typing import Any, Dict, List, Optional -from pydantic import Field +from pydantic import ConfigDict, Field from dashscope.finetune.reinforcement.common.model_types import ( FunctionType as FuncType, @@ -74,5 +74,4 @@ class RolloutInput(BaseDataModel): description="The metadata for the request.", ) - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") diff --git a/dashscope/finetune/reinforcement/component/data/rollout_output.py b/dashscope/finetune/reinforcement/component/data/rollout_output.py index c50b4ba9..1b923558 100644 --- a/dashscope/finetune/reinforcement/component/data/rollout_output.py +++ b/dashscope/finetune/reinforcement/component/data/rollout_output.py @@ -7,7 +7,7 @@ """ from typing import Any, Optional -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from dashscope.finetune.reinforcement.component.data.base_data_model import ( TaskStatus, @@ -52,5 +52,4 @@ def normalize_error_code(cls, v: Any) -> Any: """Normalize int error codes to str (protocol stays string).""" return _normalize_error_code(v) - class Config: - extra = "allow" + model_config = ConfigDict(extra="allow") diff --git a/dashscope/rerank/text_rerank.py b/dashscope/rerank/text_rerank.py index 3a4b31e1..960a8357 100644 --- a/dashscope/rerank/text_rerank.py +++ b/dashscope/rerank/text_rerank.py @@ -53,6 +53,7 @@ class Models: gte_rerank_v2 = "gte-rerank-v2" qwen3_rerank = "qwen3-rerank" qwen3_vl_rerank = "qwen3-vl-rerank" + qwen3_7_text_rerank = "qwen3.7-text-rerank" @classmethod def call( # type: ignore[override] # pylint: disable=arguments-renamed diff --git a/dashscope/utils/oss_utils.py b/dashscope/utils/oss_utils.py index 6b5e476d..95187dff 100644 --- a/dashscope/utils/oss_utils.py +++ b/dashscope/utils/oss_utils.py @@ -176,6 +176,7 @@ def check_and_upload_local( content: str, api_key: str, upload_certificate: dict = None, + **kwargs, ): """Check the content is local file path, upload and return the url @@ -185,6 +186,10 @@ def check_and_upload_local( api_key (_type_): The api key. upload_certificate (dict, optional): Reusable upload certificate. Defaults to None. + **kwargs: Forwarded to the certificate fetch when a new upload + certificate is needed, e.g. ``base_address`` (endpoint the + ``getPolicy`` request is sent to) and ``workspace``. Callers + that omit them keep the default endpoint resolution. Raises: UploadFileException: Upload failed. @@ -204,6 +209,7 @@ def check_and_upload_local( file_path=file_path, api_key=api_key, upload_certificate=upload_certificate, + **kwargs, ) if file_url is None: raise UploadFileException( @@ -221,6 +227,7 @@ def check_and_upload_local( file_path=content, api_key=api_key, upload_certificate=upload_certificate, + **kwargs, ) if file_url is None: raise UploadFileException( diff --git a/dashscope/v2/__init__.py b/dashscope/v2/__init__.py new file mode 100644 index 00000000..7217e39b --- /dev/null +++ b/dashscope/v2/__init__.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 SDK implementation package (internal). + +Public API is re-exported at the package root (dashscope.Client etc.); +``dashscope.v2`` is only the internal implementation location. +""" + +from dashscope.v2 import errors +from dashscope.v2._config import Config, Settings, settings +from dashscope.v2._quickstart import chat +from dashscope.v2.client import AsyncClient, Client +from dashscope.v2.errors import DashScopeError, ErrorCode +from dashscope.v2.transport.ws_connection import WsConnection, WsTask + +# Offline helpers delegated to the 1.x implementation, re-exported so the +# 2.0 namespace is self-contained (same objects the package root already +# exposes): the local tiktoken wrapper and the key-file writer. +from dashscope.common.api_key import save_api_key # noqa: E402 +from dashscope.tokenizers import ( # noqa: E402 + get_tokenizer, + list_tokenizers, +) + +__all__ = [ + "AsyncClient", + "Client", + "Config", + "DashScopeError", + "ErrorCode", + "Settings", + "WsConnection", + "WsTask", + "chat", + "errors", + "get_tokenizer", + "list_tokenizers", + "save_api_key", + "settings", +] diff --git a/dashscope/v2/_config.py b/dashscope/v2/_config.py new file mode 100644 index 00000000..ad8bc0be --- /dev/null +++ b/dashscope/v2/_config.py @@ -0,0 +1,734 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Configuration resolution for the dashscope 2.0 SDK. + +Priority: explicit parameters > environment variables +> ~/.dashscope/config.toml. For ``api_key`` only there is one more, +lowest tier: the 1.x key file (``DASHSCOPE_API_KEY_FILE_PATH`` or +``~/.dashscope/api_key``) written by ``save_api_key``, so 1.x users who +stored their key that way keep working. + +When no base_url is configured at any level, every default endpoint is +built from the workspace-scoped MaaS gateway +(``https://{workspace}.{region}.maas.aliyuncs.com``), which serves each +protocol under its own path. ``region`` (default ``cn-beijing``) selects +one of the six regional gateways, so international users pass +``region=...`` instead of hand-building a ``base_url``. When no +``workspace`` is available either, resolution falls back to the legacy +shared gateway +(``https://dashscope.aliyuncs.com``, the 1.x endpoint -- still supported +server-side, though the workspace-scoped gateway is recommended) and +emits a ``UserWarning`` nudging users to configure a workspace, so an +api_key-only quickstart keeps working. The protocol-dependent default +endpoint (``openai`` / ``anthropic`` compatible endpoints) applies only +to ``protocol_base_url``, which the protocol-adapted chat family +(``chat.completions`` / ``multimodal.chat``) sends its requests to; every +other resource always talks to ``base_url``. Setting an explicit +``base_url`` remains an escape hatch for custom/private gateways and does +not require a ``workspace``. +""" + +from __future__ import annotations + +import os +import threading +import warnings +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional, Union + +try: + import tomllib +except ModuleNotFoundError: # Python < 3.11 + import tomli as tomllib # type: ignore[no-redef] + +# Workspace-scoped MaaS gateway: the recommended endpoint host, used for +# every default route when a workspace is configured. Without a workspace +# (and no explicit base_url), resolution falls back to the legacy shared +# gateway below. +WORKSPACE_GATEWAY_TEMPLATE = "https://{workspace}.{region}.maas.aliyuncs.com" + +# The MaaS gateway is deployed in six regions; ``region`` picks which one +# the default endpoints point at. cn-beijing is the default so existing +# configurations keep their URLs unchanged. +DEFAULT_REGION = "cn-beijing" +VALID_REGIONS = ( + "cn-beijing", + "ap-southeast-1", + "cn-hongkong", + "us-east-1", + "eu-central-1", + "ap-northeast-1", +) + +# Legacy shared gateways (the 1.x endpoints), per region. Still supported +# server-side with no announced retirement; v2 uses them only as the +# no-workspace fallback so an api_key-only quickstart keeps working, and +# warns the user toward the workspace-scoped gateway. The wire-protocol +# paths are identical on both gateways; only the host differs. Regions +# without a dedicated shared gateway use the international one. +LEGACY_GATEWAY_HOSTS = { + "cn-beijing": "https://dashscope.aliyuncs.com", + "ap-southeast-1": "https://dashscope-intl.aliyuncs.com", + "cn-hongkong": "https://cn-hongkong.dashscope.aliyuncs.com", + "us-east-1": "https://dashscope-intl.aliyuncs.com", + "eu-central-1": "https://dashscope-intl.aliyuncs.com", + "ap-northeast-1": "https://dashscope-intl.aliyuncs.com", +} +DEFAULT_PROTOCOL = "native" +DEFAULT_TIMEOUT = 300.0 +DEFAULT_MAX_RETRIES = 2 +VALID_PROTOCOLS = ("native", "openai", "anthropic") + +# HTTP keep-alive pool knobs (matching the httpx defaults). One Client +# holds one pool shared by every resource call on it, so connections +# stay warm across requests -- the 1.x SDK built a fresh Session per +# call instead. +DEFAULT_HTTP_MAX_CONNECTIONS = 100 +DEFAULT_HTTP_MAX_KEEPALIVE_CONNECTIONS = 20 +DEFAULT_HTTP_KEEPALIVE_EXPIRY = 5.0 + +# Each wire protocol lives at its own URL path under the gateway host. +PROTOCOL_DEFAULT_PATHS = { + "native": "/api/v1", + "openai": "/compatible-mode/v1", + "anthropic": "/apps/anthropic", +} + +# The MaaS gateway serves the websocket endpoints under the same host as +# HTTP (only the scheme differs); the legacy shared gateway below is the +# no-workspace fallback. +WORKSPACE_WS_GATEWAY_TEMPLATE = "wss://{workspace}.{region}.maas.aliyuncs.com" +LEGACY_WS_GATEWAY_HOSTS = { + "cn-beijing": "wss://dashscope.aliyuncs.com", + "ap-southeast-1": "wss://dashscope-intl.aliyuncs.com", + "cn-hongkong": "wss://cn-hongkong.dashscope.aliyuncs.com", + "us-east-1": "wss://dashscope-intl.aliyuncs.com", + "eu-central-1": "wss://dashscope-intl.aliyuncs.com", + "ap-northeast-1": "wss://dashscope-intl.aliyuncs.com", +} +WS_INFERENCE_PATH = "/api-ws/v1/inference" +WS_REALTIME_PATH = "/api-ws/v1/realtime" + +_LEGACY_GATEWAY_FALLBACK_MSG = ( + "no workspace configured: falling back to the legacy shared gateway " + "({}). For better performance and stability, configure a workspace " + "to use the recommended workspace-scoped gateway: set the " + "DASHSCOPE_WORKSPACE environment variable, pass workspace=..., or " + "add 'workspace' to your config.toml." +) + +_API_KEY_ENV = "DASHSCOPE_API_KEY" +_BASE_URL_ENV = "DASHSCOPE_BASE_URL" +# 1.x env var name, honored as fallback so existing setups keep working +_LEGACY_BASE_URL_ENV = "DASHSCOPE_HTTP_BASE_URL" +_WORKSPACE_ENV = "DASHSCOPE_WORKSPACE" +_REGION_ENV = "DASHSCOPE_REGION" +_WS_BASE_URL_ENV = "DASHSCOPE_WEBSOCKET_BASE_URL" +_PROTOCOL_ENV = "DASHSCOPE_PROTOCOL" +_TIMEOUT_ENV = "DASHSCOPE_TIMEOUT" +_MAX_RETRIES_ENV = "DASHSCOPE_MAX_RETRIES" +_HTTP_MAX_CONNECTIONS_ENV = "DASHSCOPE_HTTP_MAX_CONNECTIONS" +_HTTP_MAX_KEEPALIVE_CONNECTIONS_ENV = ( + "DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS" +) +_HTTP_KEEPALIVE_EXPIRY_ENV = "DASHSCOPE_HTTP_KEEPALIVE_EXPIRY" +_LOG_ENV = "DASHSCOPE_LOG" +_CONFIG_PATH_ENV = "DASHSCOPE_CONFIG" +_DEFAULT_CONFIG_PATH = "~/.dashscope/config.toml" +# 1.x key-file auth (dashscope.save_api_key): env-specified path first, +# then the default file -- the lowest api_key tier. +_API_KEY_FILE_PATH_ENV = "DASHSCOPE_API_KEY_FILE_PATH" +_DEFAULT_API_KEY_FILE_PATH = "~/.dashscope/api_key" + +_FILE_KEYS = frozenset( + { + "api_key", + "base_url", + "workspace", + "region", + "protocol", + "timeout", + "max_retries", + "http_max_connections", + "http_max_keepalive_connections", + "http_keepalive_expiry", + }, +) + + +def _env_float(name: str) -> Optional[float]: + raw = os.environ.get(name) + if not raw: + return None + try: + return float(raw) + except ValueError: + raise ValueError( + f"invalid {name}={raw!r}: expected a number", + ) from None + + +def _env_int(name: str) -> Optional[int]: + raw = os.environ.get(name) + if not raw: + return None + try: + return int(raw) + except ValueError: + raise ValueError( + f"invalid {name}={raw!r}: expected an integer", + ) from None + + +def _legacy_fallback(host: str) -> str: + """Return the legacy gateway host, nudging toward a workspace. + + A single warn site keeps the warning once-per-process under the + default warnings filter. + """ + warnings.warn( + _LEGACY_GATEWAY_FALLBACK_MSG.format(host), + UserWarning, + stacklevel=2, + ) + return host + + +def _validate_region(region: str) -> str: + if region not in VALID_REGIONS: + raise ValueError( + f"invalid region {region!r}, expected one of {VALID_REGIONS}", + ) + return region + + +def _protocol_default_base_url( + protocol: str, + workspace: Optional[str], + region: Optional[str] = None, +) -> str: + region = _validate_region(region or DEFAULT_REGION) + if workspace: + host = WORKSPACE_GATEWAY_TEMPLATE.format( + workspace=workspace, + region=region, + ) + else: + host = _legacy_fallback(LEGACY_GATEWAY_HOSTS[region]) + return host + PROTOCOL_DEFAULT_PATHS[protocol] + + +def compatible_mode_base_url( + workspace: Optional[str], + region: Optional[str] = None, + configured_base_url: Optional[str] = None, +) -> str: + """Endpoint root for services mounted under the compatible-mode path. + + The System One decision model (``/systemone``) is served under + ``/compatible-mode/v1`` on every gateway, regardless of the client's + chat ``protocol`` setting, so its base can come from neither + ``Config.base_url`` (native ``/api/v1``) nor + ``Config.protocol_base_url`` (which follows the configured chat + protocol). An explicit base_url stays the service root -- the same + rule ``protocol_base_url`` applies; otherwise the default gateway + host (workspace-scoped, or the legacy shared fallback) carries the + fixed compatible-mode path. + """ + if configured_base_url is not None: + return configured_base_url + return _protocol_default_base_url("openai", workspace, region) + + +def workspace_ws_url( + workspace: Optional[str], + path: str, + region: Optional[str] = None, +) -> str: + """Build the default websocket endpoint for ``path``. + + The MaaS gateway serves websocket under the same host as HTTP, so the + URL is ``wss://{workspace}.{region}.maas.aliyuncs.com{path}``. + Without a workspace it falls back to the region's legacy shared + gateway (``wss://dashscope.aliyuncs.com`` for cn-beijing) with a + warning. + """ + region = _validate_region(region or DEFAULT_REGION) + if workspace: + host = WORKSPACE_WS_GATEWAY_TEMPLATE.format( + workspace=workspace, + region=region, + ) + else: + host = _legacy_fallback(LEGACY_WS_GATEWAY_HOSTS[region]) + return host + path + + +@dataclass(frozen=True) +class Config: + api_key: Optional[str] + base_url: str + workspace: Optional[str] + protocol: str + timeout: float + max_retries: int + # Where the protocol-adapted chat family (chat/multimodal) sends + # requests: the configured base_url if any, else the protocol's + # default endpoint. + protocol_base_url: str + # The configured base_url (any level) before defaults kicked in; + # None means every resolved URL is a default. Lets with_options() + # re-resolve per-protocol defaults for a derived protocol. + configured_base_url: Optional[str] = None + # The region the default gateways were built for. Informational only + # when an explicit base_url overrode them. + region: str = DEFAULT_REGION + # HTTP connection-pool sizes for the client's httpx client. + http_max_connections: int = DEFAULT_HTTP_MAX_CONNECTIONS + http_max_keepalive_connections: int = ( + DEFAULT_HTTP_MAX_KEEPALIVE_CONNECTIONS + ) + http_keepalive_expiry: float = DEFAULT_HTTP_KEEPALIVE_EXPIRY + + +class Settings: + """Global mutable settings facade (design doc M3 3.11). + + Single runtime entry point: values assigned here override environment + variables and ``config.toml`` for every Client created afterwards. + Unset properties fall back lazily to the environment (read at access + time, not import time). + """ + + def __init__(self) -> None: + self._explicit: Dict[str, Any] = {} + self._lock = threading.Lock() + + def get_explicit(self, name: str) -> Any: + """Explicitly assigned value, or None if unset.""" + with self._lock: + return self._explicit.get(name) + + def clear(self) -> None: + """Forget every explicit assignment (back to env / defaults). + + If ``log_level`` had been assigned, the logger level is reset to + the ``DASHSCOPE_LOG`` env level (or WARNING) so it does not leak. + """ + with self._lock: + had_log_level = "log_level" in self._explicit + self._explicit.clear() + if had_log_level: + # pylint: disable=import-outside-toplevel + from dashscope.v2._logging import reset_log_level + + reset_log_level() + + @property + def api_key(self) -> Optional[str]: + return self._explicit.get("api_key") or os.environ.get(_API_KEY_ENV) + + @api_key.setter + def api_key(self, value: Optional[str]) -> None: + with self._lock: + self._explicit["api_key"] = value + + @property + def base_url(self) -> str: + return ( + self._explicit.get("base_url") + or os.environ.get(_BASE_URL_ENV) + or os.environ.get(_LEGACY_BASE_URL_ENV) + or _protocol_default_base_url( + "native", + self.workspace, + self.region, + ) + ) + + @base_url.setter + def base_url(self, value: Optional[str]) -> None: + with self._lock: + self._explicit["base_url"] = value + + @property + def websocket_base_url(self) -> Optional[str]: + return self._explicit.get( + "websocket_base_url", + ) or os.environ.get(_WS_BASE_URL_ENV) + + @websocket_base_url.setter + def websocket_base_url(self, value: Optional[str]) -> None: + with self._lock: + self._explicit["websocket_base_url"] = value + + @property + def workspace(self) -> Optional[str]: + return self._explicit.get("workspace") or os.environ.get( + _WORKSPACE_ENV, + ) + + @workspace.setter + def workspace(self, value: Optional[str]) -> None: + with self._lock: + self._explicit["workspace"] = value + + @property + def region(self) -> str: + return ( + self._explicit.get("region") + or os.environ.get(_REGION_ENV) + or DEFAULT_REGION + ) + + @region.setter + def region(self, value: str) -> None: + _validate_region(value) + with self._lock: + self._explicit["region"] = value + + @property + def protocol(self) -> str: + return ( + self._explicit.get("protocol") + or os.environ.get(_PROTOCOL_ENV) + or DEFAULT_PROTOCOL + ) + + @protocol.setter + def protocol(self, value: str) -> None: + if value not in VALID_PROTOCOLS: + raise ValueError( + f"invalid protocol {value!r}, " + f"expected one of {VALID_PROTOCOLS}", + ) + with self._lock: + self._explicit["protocol"] = value + + @property + def timeout(self) -> float: + value = self._explicit.get("timeout") + if value is None: + value = _env_float(_TIMEOUT_ENV) + return DEFAULT_TIMEOUT if value is None else value + + @timeout.setter + def timeout(self, value: float) -> None: + value = float(value) + if value <= 0: + raise ValueError(f"timeout must be positive, got {value}") + with self._lock: + self._explicit["timeout"] = value + + @property + def max_retries(self) -> int: + value = self._explicit.get("max_retries") + if value is None: + value = _env_int(_MAX_RETRIES_ENV) + return DEFAULT_MAX_RETRIES if value is None else value + + @max_retries.setter + def max_retries(self, value: int) -> None: + value = int(value) + if value < 0: + raise ValueError(f"max_retries must be >= 0, got {value}") + with self._lock: + self._explicit["max_retries"] = value + + @property + def http_max_connections(self) -> int: + value = self._explicit.get("http_max_connections") + if value is None: + value = _env_int(_HTTP_MAX_CONNECTIONS_ENV) + return DEFAULT_HTTP_MAX_CONNECTIONS if value is None else value + + @http_max_connections.setter + def http_max_connections(self, value: int) -> None: + value = int(value) + if value < 1: + raise ValueError( + f"http_max_connections must be >= 1, got {value}", + ) + with self._lock: + self._explicit["http_max_connections"] = value + + @property + def http_max_keepalive_connections(self) -> int: + value = self._explicit.get("http_max_keepalive_connections") + if value is None: + value = _env_int(_HTTP_MAX_KEEPALIVE_CONNECTIONS_ENV) + return ( + DEFAULT_HTTP_MAX_KEEPALIVE_CONNECTIONS if value is None else value + ) + + @http_max_keepalive_connections.setter + def http_max_keepalive_connections(self, value: int) -> None: + value = int(value) + if value < 0: + raise ValueError( + f"http_max_keepalive_connections must be >= 0, got {value}", + ) + with self._lock: + self._explicit["http_max_keepalive_connections"] = value + + @property + def http_keepalive_expiry(self) -> float: + value = self._explicit.get("http_keepalive_expiry") + if value is None: + value = _env_float(_HTTP_KEEPALIVE_EXPIRY_ENV) + return DEFAULT_HTTP_KEEPALIVE_EXPIRY if value is None else value + + @http_keepalive_expiry.setter + def http_keepalive_expiry(self, value: float) -> None: + value = float(value) + if value <= 0: + raise ValueError( + f"http_keepalive_expiry must be positive, got {value}", + ) + with self._lock: + self._explicit["http_keepalive_expiry"] = value + + @property + def log_level(self) -> Optional[str]: + return self._explicit.get("log_level") or os.environ.get(_LOG_ENV) + + @log_level.setter + def log_level(self, value: str) -> None: + # pylint: disable=import-outside-toplevel + from dashscope.v2._logging import set_log_level + + set_log_level(value) + with self._lock: + self._explicit["log_level"] = value + + +settings = Settings() + + +def default_config_path() -> Path: + return Path( + os.environ.get(_CONFIG_PATH_ENV, _DEFAULT_CONFIG_PATH), + ).expanduser() + + +def _load_config_file(path: Path) -> Dict[str, Any]: + if not path.is_file(): + return {} + try: + with path.open("rb") as f: + data = tomllib.load(f) + except tomllib.TOMLDecodeError as e: + raise ValueError(f"invalid TOML in config file {path}: {e}") from e + if not isinstance(data, dict): + return {} + return {k: v for k, v in data.items() if k in _FILE_KEYS} + + +def _api_key_from_file() -> Optional[str]: + """Lowest api_key tier: the 1.x key file. + + Same order as ``dashscope.common.api_key.get_default_api_key`` -- + ``DASHSCOPE_API_KEY_FILE_PATH`` first, then the default + ``~/.dashscope/api_key`` written by ``save_api_key``. A missing or + unreadable file resolves to None rather than raising, since this is + only a fallback tier. + """ + path = os.environ.get(_API_KEY_FILE_PATH_ENV) or _DEFAULT_API_KEY_FILE_PATH + try: + with open(os.path.expanduser(path), "rt", encoding="utf-8") as f: + return f.read().strip() or None + except OSError: + return None + + +def _pick( + explicit: Any, + settings_value: Any, + env_value: Any, + file_value: Any, + default: Any, +) -> Any: + if explicit is not None: + return explicit + if settings_value is not None: + return settings_value + # Missing env vars parse to None and empty strings count as unset, + # but a real 0 (e.g. keepalive=0, max_retries=0) is a value. + if env_value is not None and env_value != "": + return env_value + if file_value is not None: + return file_value + return default + + +def resolve_config( + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + protocol: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + config_path: Union[str, Path, None] = None, +) -> Config: + path = ( + Path(config_path).expanduser() + if config_path + else default_config_path() + ) + file_cfg = _load_config_file(path) + + resolved_protocol = str( + _pick( + protocol, + settings.get_explicit("protocol"), + os.environ.get(_PROTOCOL_ENV), + file_cfg.get("protocol"), + DEFAULT_PROTOCOL, + ), + ) + if resolved_protocol not in VALID_PROTOCOLS: + raise ValueError( + f"invalid protocol {resolved_protocol!r}, " + f"expected one of {VALID_PROTOCOLS}", + ) + + resolved_timeout = float( + _pick( + timeout, + settings.get_explicit("timeout"), + _env_float(_TIMEOUT_ENV), + file_cfg.get("timeout"), + DEFAULT_TIMEOUT, + ), + ) + if resolved_timeout <= 0: + raise ValueError(f"timeout must be positive, got {resolved_timeout}") + + resolved_max_retries = int( + _pick( + max_retries, + settings.get_explicit("max_retries"), + _env_int(_MAX_RETRIES_ENV), + file_cfg.get("max_retries"), + DEFAULT_MAX_RETRIES, + ), + ) + if resolved_max_retries < 0: + raise ValueError( + f"max_retries must be >= 0, got {resolved_max_retries}", + ) + + resolved_http_max_connections = int( + _pick( + None, + settings.get_explicit("http_max_connections"), + _env_int(_HTTP_MAX_CONNECTIONS_ENV), + file_cfg.get("http_max_connections"), + DEFAULT_HTTP_MAX_CONNECTIONS, + ), + ) + if resolved_http_max_connections < 1: + raise ValueError( + "http_max_connections must be >= 1, " + f"got {resolved_http_max_connections}", + ) + + resolved_http_max_keepalive = int( + _pick( + None, + settings.get_explicit("http_max_keepalive_connections"), + _env_int(_HTTP_MAX_KEEPALIVE_CONNECTIONS_ENV), + file_cfg.get("http_max_keepalive_connections"), + DEFAULT_HTTP_MAX_KEEPALIVE_CONNECTIONS, + ), + ) + if resolved_http_max_keepalive < 0: + raise ValueError( + "http_max_keepalive_connections must be >= 0, " + f"got {resolved_http_max_keepalive}", + ) + + resolved_http_keepalive_expiry = float( + _pick( + None, + settings.get_explicit("http_keepalive_expiry"), + _env_float(_HTTP_KEEPALIVE_EXPIRY_ENV), + file_cfg.get("http_keepalive_expiry"), + DEFAULT_HTTP_KEEPALIVE_EXPIRY, + ), + ) + if resolved_http_keepalive_expiry <= 0: + raise ValueError( + "http_keepalive_expiry must be positive, " + f"got {resolved_http_keepalive_expiry}", + ) + + resolved_workspace = _pick( + workspace, + settings.get_explicit("workspace"), + os.environ.get(_WORKSPACE_ENV), + file_cfg.get("workspace"), + None, + ) + + resolved_region = _validate_region( + str( + _pick( + region, + settings.get_explicit("region"), + os.environ.get(_REGION_ENV), + file_cfg.get("region"), + DEFAULT_REGION, + ), + ), + ) + + configured_base_url = _pick( + base_url, + settings.get_explicit("base_url"), + os.environ.get(_BASE_URL_ENV) or os.environ.get(_LEGACY_BASE_URL_ENV), + file_cfg.get("base_url"), + None, + ) + # An explicit base_url is the escape hatch for custom/private gateways. + # Otherwise defaults come from the workspace-scoped MaaS gateway, + # falling back to the legacy shared gateway (with a warning) when no + # workspace is configured (_protocol_default_base_url). + if configured_base_url is not None: + resolved_base_url = configured_base_url + resolved_protocol_base_url = configured_base_url + else: + resolved_base_url = _protocol_default_base_url( + "native", + resolved_workspace, + resolved_region, + ) + resolved_protocol_base_url = _protocol_default_base_url( + resolved_protocol, + resolved_workspace, + resolved_region, + ) + + return Config( + api_key=_pick( + api_key, + settings.get_explicit("api_key"), + os.environ.get(_API_KEY_ENV), + file_cfg.get("api_key"), + _api_key_from_file(), + ), + base_url=resolved_base_url, + workspace=resolved_workspace, + protocol=resolved_protocol, + timeout=resolved_timeout, + max_retries=resolved_max_retries, + protocol_base_url=resolved_protocol_base_url, + configured_base_url=configured_base_url, + region=resolved_region, + http_max_connections=resolved_http_max_connections, + http_max_keepalive_connections=resolved_http_max_keepalive, + http_keepalive_expiry=resolved_http_keepalive_expiry, + ) diff --git a/dashscope/v2/_error_registry.py b/dashscope/v2/_error_registry.py new file mode 100644 index 00000000..9c2675fa --- /dev/null +++ b/dashscope/v2/_error_registry.py @@ -0,0 +1,650 @@ +# -*- coding: utf-8 -*- +"""Auto-generated by tools/generate.py -- DO NOT EDIT.""" +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +__version__ = "1.0.6" +__commit__ = "56b7208" +__snapshot__ = False + + +@dataclass(frozen=True) +class PublicError: + key: str + status_code: int + error_code: str + error_msg: str + anthropic_error_code: str + + def format_msg( + self, + substitutions: Optional[Dict[str, str]] = None, + ) -> str: + msg = self.error_msg + if substitutions: + for k, v in substitutions.items(): + msg = msg.replace("{" + k + "}", v) + return msg + + +@dataclass(frozen=True) +class SdkErrorDef: + name: str + message: str + solution: str + allow_retry: bool = False + vars: List[str] = field(default_factory=list) + + def format_message( + self, + variables: Optional[Dict[str, str]] = None, + ) -> str: + msg = self.message + if variables: + for k, v in variables.items(): + msg = msg.replace("{" + k + "}", v) + return msg + + +# -- Public Errors -------------------------------------------------- + +INVALID_REQUEST = PublicError( + key="invalid_request", + status_code=400, + error_code="BadRequestError", + error_msg=( + "The request is invalid. Please check the request and try " "again." + ), + anthropic_error_code="invalid_request_error", +) + +MISSING_PARAMETER = PublicError( + key="missing_parameter", + status_code=400, + error_code="BadRequestError", + error_msg="Missing required parameter: {parameter}.", + anthropic_error_code="invalid_request_error", +) + +CONTENT_POLICY_VIOLATION = PublicError( + key="content_policy_violation", + status_code=400, + error_code="BadRequestError", + error_msg="The request was rejected by content policy.", + anthropic_error_code="invalid_request_error", +) + +INVALID_URL = PublicError( + key="invalid_url", + status_code=400, + error_code="BadRequestError", + error_msg="The provided URL is invalid or cannot be accessed.", + anthropic_error_code="invalid_request_error", +) + +INVALID_FILE = PublicError( + key="invalid_file", + status_code=400, + error_code="BadRequestError", + error_msg="The provided file is invalid.", + anthropic_error_code="invalid_request_error", +) + +AUTH_FAILED = PublicError( + key="auth_failed", + status_code=401, + error_code="AuthenticationError", + error_msg=( + "Authentication failed. Please provide valid " + "authentication credentials." + ), + anthropic_error_code="authentication_error", +) + +INVALID_API_KEY = PublicError( + key="invalid_api_key", + status_code=401, + error_code="AuthenticationError", + error_msg="Incorrect API key provided.", + anthropic_error_code="authentication_error", +) + +PERMISSION_DENIED = PublicError( + key="permission_denied", + status_code=403, + error_code="PermissionDeniedError", + error_msg="You do not have permission to access this resource.", + anthropic_error_code="permission_error", +) + +RESOURCE_NOT_FOUND = PublicError( + key="resource_not_found", + status_code=404, + error_code="NotFoundError", + error_msg="The requested resource was not found: {resource}.", + anthropic_error_code="not_found_error", +) + +REQUEST_TOO_LARGE = PublicError( + key="request_too_large", + status_code=413, + error_code="RequestTooLargeError", + error_msg="The request exceeds the maximum allowed size of {limit}.", + anthropic_error_code="request_too_large", +) + +RATE_LIMIT_EXCEEDED = PublicError( + key="rate_limit_exceeded", + status_code=429, + error_code="RateLimitError", + error_msg="Rate limit exceeded. Please slow down your requests.", + anthropic_error_code="rate_limit_error", +) + +CONCURRENCY_LIMIT_EXCEEDED = PublicError( + key="concurrency_limit_exceeded", + status_code=429, + error_code="RateLimitError", + error_msg=( + "Too many concurrent requests. Please reduce concurrency " + "and try again." + ), + anthropic_error_code="rate_limit_error", +) + +INSUFFICIENT_QUOTA = PublicError( + key="insufficient_quota", + status_code=429, + error_code="RateLimitError", + error_msg=( + "You exceeded your current quota. Please check your plan " + "and billing details." + ), + anthropic_error_code="billing_error", +) + +INTERNAL_ERROR = PublicError( + key="internal_error", + status_code=500, + error_code="InternalServerError", + error_msg=( + "The server encountered an internal error. Please try again " "later." + ), + anthropic_error_code="api_error", +) + +SERVICE_UNAVAILABLE = PublicError( + key="service_unavailable", + status_code=503, + error_code="ServiceUnavailableError", + error_msg=( + "The service is temporarily unavailable. Please try again " "later." + ), + anthropic_error_code="overloaded_error", +) + +REQUEST_TIMEOUT = PublicError( + key="request_timeout", + status_code=504, + error_code="GatewayTimeoutError", + error_msg="The request timed out. Please try again later.", + anthropic_error_code="timeout_error", +) + + +# -- SDK Errors ------------------------------------------------- + +SDK_INVALID_API_KEY = SdkErrorDef( + name="sdk.InvalidApiKey", + message="Invalid API key configuration for client.", + solution=( + "Set the DASHSCOPE_API_KEY environment variable or pass a " + "valid api_key to AgenticRL(). You can obtain an API key at " + "https://modelstudio.console.alibabacloud.com/cn-beijing" + "?tab=api." + ), + allow_retry=False, + vars=[], +) + +SDK_AGENTIC_RL_FUNCTION_REGISTRATION_FAILED = SdkErrorDef( + name="sdk.agentic_rl.FunctionRegistrationFailed", + message="Function component registration failed: {inner_code}.", + solution=( + "Check the inner error code for details. Ensure the function " + "code package is valid and the deployment service is " + "reachable. Retry the register_functions() call." + ), + allow_retry=True, + vars=["inner_code"], +) + +SDK_AGENTIC_RL_DATASETS_UPLOAD_FAILED = SdkErrorDef( + name="sdk.agentic_rl.DatasetsUploadFailed", + message="Datasets upload failed: {inner_code}.", + solution=( + "Verify your dataset files exist and are readable. Check OSS " + "credentials and network connectivity. Retry the " + "upload_datasets() call." + ), + allow_retry=True, + vars=["inner_code"], +) + +SDK_AGENTIC_RL_DUPLICATE_FUNCTION_NAMES = SdkErrorDef( + name="sdk.agentic_rl.DuplicateFunctionNames", + message="Duplicate function names detected: {names}.", + solution=( + "Each registered function must have a unique name. Review " + "your RolloutFunctionComponent and " + "RewardFunctionComponent definitions and remove any " + "duplicate names before calling submit_job()." + ), + allow_retry=False, + vars=["names"], +) + +SDK_AGENTIC_RL_JOB_SUBMISSION_FAILED = SdkErrorDef( + name="sdk.agentic_rl.JobSubmissionFailed", + message="Job submission failed: {inner_code}.", + solution=( + "Check the inner error code for details. Ensure the model " + "name is valid and your account has sufficient quota. Retry " + "submit_job() after fixing the underlying issue." + ), + allow_retry=True, + vars=["inner_code"], +) + +SDK_AGENTIC_RL_WORKFLOW_FAILED = SdkErrorDef( + name="sdk.agentic_rl.WorkflowFailed", + message="RL tuning workflow failed: {inner_code}.", + solution=( + "This error wraps a failure in the run() workflow. Inspect " + "the inner error code to identify which step failed " + "(registration, upload, or submission), fix that issue, " + "and retry run()." + ), + allow_retry=True, + vars=["inner_code"], +) + +SDK_AGENTIC_RL_UNSUPPORTED_FUNCTION_TYPE = SdkErrorDef( + name="sdk.agentic_rl.UnsupportedFunctionType", + message="Unsupported function type: {functype}.", + solution=( + "Only ROLLOUT, REWARD, and GROUP_REWARD function types are " + "supported. Pass a valid FunctionType enum value to " + "test_functions()." + ), + allow_retry=False, + vars=["functype"], +) + +SDK_AGENTIC_RL_FUNCTION_TEST_FAILED = SdkErrorDef( + name="sdk.agentic_rl.FunctionTestFailed", + message="Function test failed: {inner_code}.", + solution=( + "Check the inner error code and the function logs for " + "details. Verify that the function code runs correctly with " + "the provided input_data. Fix the function and retry " + "test_functions()." + ), + allow_retry=True, + vars=["inner_code"], +) + +SDK_AGENTIC_RL_FUNCTION_TEST_TIMEOUT = SdkErrorDef( + name="sdk.agentic_rl.FunctionTestTimeout", + message="Function test timed out after {timeout} seconds.", + solution=( + "The function did not return within the timeout period. " + "Profile your function for slow operations (e.g. large " + "model inference, network calls) and optimize it. Consider " + "increasing the timeout if the function legitimately takes " + "longer." + ), + allow_retry=True, + vars=["timeout"], +) + +SDK_AGENTIC_RL_INPUT_ERROR = SdkErrorDef( + name="sdk.agentic_rl.InputError", + message="Invalid input data detected during validation.", + solution=( + "Check the input data passed to the function. Ensure all " + "required fields are present and have valid types. Refer to " + "the RolloutInput / RewardInput / GroupRewardInput schema " + "documentation." + ), + allow_retry=False, + vars=[], +) + +SDK_AGENTIC_RL_OUTPUT_ERROR = SdkErrorDef( + name="sdk.agentic_rl.OutputError", + message="Service response failed output validation checks.", + solution=( + "The function output did not pass validation. Ensure your " + "function returns a valid RolloutOutput / RewardOutput / " + "GroupRewardOutput object with all required fields " + "populated." + ), + allow_retry=False, + vars=[], +) + +SDK_AGENTIC_RL_BASE_CONNECTION_ERROR = SdkErrorDef( + name="sdk.agentic_rl.BaseConnectionError", + message="Connection-related error occurred.", + solution=( + "Check your network connectivity and ensure the target " + "service endpoint is reachable. Verify firewall rules and " + "proxy settings." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_OSS_CONNECTION_ERROR = SdkErrorDef( + name="sdk.agentic_rl.OSSConnectionError", + message="Connecting to OSS storage service failed.", + solution=( + "Verify that the OSS endpoint URL is correct and the OSS " + "service is accessible. Check your API key permissions for " + "OSS access. Ensure you are in a region that supports OSS " + "connectivity." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_OSS_UPLOAD_ERROR = SdkErrorDef( + name="sdk.agentic_rl.OSSUploadError", + message="File upload operation to OSS failed.", + solution=( + "Check that the file exists, is readable, and does not " + "exceed the upload size limit. Verify your OSS bucket " + "permissions allow write operations." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_DEPLOYMENT_ERROR = SdkErrorDef( + name="sdk.agentic_rl.DeploymentError", + message="Deployment-related error occurred.", + solution=( + "Check the deployment service status and logs. Ensure the " + "function code package and dependencies are valid. Retry " + "after the deployment service recovers." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_REGISTRATION_ERROR = SdkErrorDef( + name="sdk.agentic_rl.RegistrationError", + message="Function registration failed in the deployment system.", + solution=( + "The function could not be registered in the deployment " + "system. Verify the function name is unique and the code " + "package is valid. Check the inner error for " + "deployment-specific details." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_FUNCTION_LOAD_ERROR = SdkErrorDef( + name="sdk.agentic_rl.FunctionLoadError", + message="Loading a registered function into runtime failed.", + solution=( + "The function could not be loaded into the runtime after " + "registration. Check the error logs for import errors or " + "missing dependencies in the function layer." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_INSTANCE_WARMUP_ERROR = SdkErrorDef( + name="sdk.agentic_rl.InstanceWarmupError", + message="Function instance health check failed after deployment.", + solution=( + "The function instance health check failed after deployment. " + "Check the instance logs for startup errors. Ensure the " + "function initializes correctly and the health endpoint " + "responds within the timeout." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_INSTANCE_QUERY_ERROR = SdkErrorDef( + name="sdk.agentic_rl.InstanceQueryError", + message="Querying function instance status failed.", + solution=( + "Could not query the function instance status. The instance " + "may not be ready yet. Wait and retry, or check the " + "deployment logs for errors." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_FUNCTION_LAYER_ERROR = SdkErrorDef( + name="sdk.agentic_rl.FunctionLayerError", + message="Creating a layer of function failed.", + solution=( + "Failed to create the function dependency layer. Check that " + "the requirements.txt or setup.py is valid and all " + "dependencies are installable. Review the layer build logs " + "for details." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_DATASETS_ERROR = SdkErrorDef( + name="sdk.agentic_rl.DatasetsError", + message="Update datasets failed in the deployment system.", + solution=( + "Failed to update datasets in the deployment system. Verify " + "the dataset IDs are valid and the dataset files are " + "accessible. Retry after fixing the underlying issue." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_VALIDATION_ERROR = SdkErrorDef( + name="sdk.agentic_rl.ValidationError", + message="Data validation failed.", + solution=( + "One or more data fields failed validation. Check the error " + "message for the specific field and fix the value according " + "to the schema documentation." + ), + allow_retry=False, + vars=[], +) + +SDK_AGENTIC_RL_CONFIGURATION_ERROR = SdkErrorDef( + name="sdk.agentic_rl.ConfigurationError", + message="Invalid system configuration detected.", + solution=( + "Check your YAML configuration file or initialization " + "parameters. Ensure all required fields (model, functions, " + "datasets) are correctly specified. Refer to the AgenticRL " + "configuration documentation." + ), + allow_retry=False, + vars=[], +) + +SDK_AGENTIC_RL_BASE_PERMISSION_ERROR = SdkErrorDef( + name="sdk.agentic_rl.BasePermissionError", + message="Operation lacks required permissions.", + solution=( + "Your API key does not have the required permissions. " + "Ensure your account has access to AgenticRL, OSS, and " + "Function Compute services. Contact your administrator to " + "grant the necessary permissions." + ), + allow_retry=False, + vars=[], +) + +SDK_AGENTIC_RL_IO_ERROR_WITH_CODE = SdkErrorDef( + name="sdk.agentic_rl.IOErrorWithCode", + message="General I/O operation failure.", + solution=( + "A file or network I/O operation failed. Check file paths, " + "permissions, and network connectivity. Review the error " + "message for the specific operation that failed." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_ERROR = SdkErrorDef( + name="sdk.agentic_rl.Error", + message="Base client error occurred.", + solution=( + "An unexpected AgenticRL error occurred. Check the error " + "message and logs for details. If the issue persists, " + "submit a bug report at " + "https://github.com/dashscope/dashscope-sdk-python/issues." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_RUNTIME_ERROR_WITH_CODE = SdkErrorDef( + name="sdk.agentic_rl.RuntimeErrorWithCode", + message="Runtime error occurred in client.", + solution=( + "An unexpected runtime error occurred. Check the error " + "message and traceback for details. If the issue persists, " + "submit a bug report at " + "https://github.com/dashscope/dashscope-sdk-python/issues." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTIC_RL_VALUE_ERROR_WITH_CODE = SdkErrorDef( + name="sdk.agentic_rl.ValueErrorWithCode", + message="Invalid value encountered in client.", + solution=( + "An invalid value was encountered. Check the function " + "parameters and input data. Ensure all values conform to " + "the expected types and ranges." + ), + allow_retry=False, + vars=[], +) + +SDK_AGENTSTUDIO_API_CONNECTION_ERROR = SdkErrorDef( + name="sdk.agentstudio.APIConnectionError", + message="Failed to connect to the AgentStudio service.", + solution=( + "Check your network connectivity and ensure the " + "AgentStudio endpoint (base_url / region) is reachable. " + "Verify firewall and proxy settings." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTSTUDIO_API_TIMEOUT_ERROR = SdkErrorDef( + name="sdk.agentstudio.APITimeoutError", + message="The request to the AgentStudio service timed out.", + solution=( + "The request did not complete within the timeout period. " + "Increase the client timeout or retry the request." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTSTUDIO_STREAM_ERROR = SdkErrorDef( + name="sdk.agentstudio.StreamError", + message="The SSE stream encountered a fatal protocol error.", + solution=( + "Retry the streaming request. If the error persists, " + "report it at " + "https://github.com/dashscope/dashscope-sdk-python/issues." + ), + allow_retry=True, + vars=[], +) + +SDK_AGENTSTUDIO_STREAM_CLOSED_ERROR = SdkErrorDef( + name="sdk.agentstudio.StreamClosedError", + message="Attempted I/O on an already-closed stream.", + solution=( + "Do not read from a stream after it has been closed. " + "Consume the stream within its context manager or before " + "calling close()." + ), + allow_retry=False, + vars=[], +) + + +# -- Catalogs ------------------------------------------------------ +PUBLIC_ERRORS = [ + INVALID_REQUEST, + MISSING_PARAMETER, + CONTENT_POLICY_VIOLATION, + INVALID_URL, + INVALID_FILE, + AUTH_FAILED, + INVALID_API_KEY, + PERMISSION_DENIED, + RESOURCE_NOT_FOUND, + REQUEST_TOO_LARGE, + RATE_LIMIT_EXCEEDED, + CONCURRENCY_LIMIT_EXCEEDED, + INSUFFICIENT_QUOTA, + INTERNAL_ERROR, + SERVICE_UNAVAILABLE, + REQUEST_TIMEOUT, +] +SDK_ERRORS = [ + SDK_INVALID_API_KEY, + SDK_AGENTIC_RL_FUNCTION_REGISTRATION_FAILED, + SDK_AGENTIC_RL_DATASETS_UPLOAD_FAILED, + SDK_AGENTIC_RL_DUPLICATE_FUNCTION_NAMES, + SDK_AGENTIC_RL_JOB_SUBMISSION_FAILED, + SDK_AGENTIC_RL_WORKFLOW_FAILED, + SDK_AGENTIC_RL_UNSUPPORTED_FUNCTION_TYPE, + SDK_AGENTIC_RL_FUNCTION_TEST_FAILED, + SDK_AGENTIC_RL_FUNCTION_TEST_TIMEOUT, + SDK_AGENTIC_RL_INPUT_ERROR, + SDK_AGENTIC_RL_OUTPUT_ERROR, + SDK_AGENTIC_RL_BASE_CONNECTION_ERROR, + SDK_AGENTIC_RL_OSS_CONNECTION_ERROR, + SDK_AGENTIC_RL_OSS_UPLOAD_ERROR, + SDK_AGENTIC_RL_DEPLOYMENT_ERROR, + SDK_AGENTIC_RL_REGISTRATION_ERROR, + SDK_AGENTIC_RL_FUNCTION_LOAD_ERROR, + SDK_AGENTIC_RL_INSTANCE_WARMUP_ERROR, + SDK_AGENTIC_RL_INSTANCE_QUERY_ERROR, + SDK_AGENTIC_RL_FUNCTION_LAYER_ERROR, + SDK_AGENTIC_RL_DATASETS_ERROR, + SDK_AGENTIC_RL_VALIDATION_ERROR, + SDK_AGENTIC_RL_CONFIGURATION_ERROR, + SDK_AGENTIC_RL_BASE_PERMISSION_ERROR, + SDK_AGENTIC_RL_IO_ERROR_WITH_CODE, + SDK_AGENTIC_RL_ERROR, + SDK_AGENTIC_RL_RUNTIME_ERROR_WITH_CODE, + SDK_AGENTIC_RL_VALUE_ERROR_WITH_CODE, + SDK_AGENTSTUDIO_API_CONNECTION_ERROR, + SDK_AGENTSTUDIO_API_TIMEOUT_ERROR, + SDK_AGENTSTUDIO_STREAM_ERROR, + SDK_AGENTSTUDIO_STREAM_CLOSED_ERROR, +] diff --git a/dashscope/v2/_logging.py b/dashscope/v2/_logging.py new file mode 100644 index 00000000..bfb39d54 --- /dev/null +++ b/dashscope/v2/_logging.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Logging setup for the dashscope 2.0 SDK. + +Enable with ``DASHSCOPE_LOG=debug|info|warning|error``. Requests, +response status, request_id and retry decisions are logged; sensitive +headers (Authorization etc.) and bearer tokens are redacted. +""" + +from __future__ import annotations + +import logging +import os +import re +from typing import Any, Mapping, Optional + +LOG_ENV = "DASHSCOPE_LOG" +ROOT_NAME = "dashscope.v2" + +_LEVELS = { + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, +} + +SENSITIVE_HEADER_KEYS = frozenset( + { + "authorization", + "api-key", + "x-api-key", + "cookie", + "proxy-authorization", + "x-dashscope-encryptionkey", + }, +) + +_BEARER_RE = re.compile(r"(?i)(bearer\s+)[^\s'\"]+") +_API_KEY_RE = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b") + +REDACTED = "[REDACTED]" + + +def redact_headers( + headers: Optional[Mapping[str, Any]], +) -> Optional[dict]: + if headers is None: + return None + return { + k: REDACTED if k.lower() in SENSITIVE_HEADER_KEYS else v + for k, v in headers.items() + } + + +def redact_text(text: str) -> str: + text = _BEARER_RE.sub(r"\1" + REDACTED, text) + return _API_KEY_RE.sub(REDACTED, text) + + +class SensitiveHeadersFilter(logging.Filter): + """Redacts sensitive headers and tokens from log records.""" + + def filter(self, record: logging.LogRecord) -> bool: + headers = getattr(record, "headers", None) + if headers is not None: + record.headers = redact_headers(headers) + if isinstance(record.msg, str): + record.msg = redact_text(record.msg) + if isinstance(record.args, tuple): + record.args = tuple( + redact_text(a) if isinstance(a, str) else a + for a in record.args + ) + elif isinstance(record.args, dict): + record.args = { + k: redact_text(v) if isinstance(v, str) else v + for k, v in record.args.items() + } + return True + + +_SENSITIVE_FILTER = SensitiveHeadersFilter() + +_configured = False + + +def _configure(level: int) -> None: + logger = logging.getLogger(ROOT_NAME) + logger.setLevel(level) + if not logger.handlers: + handler = logging.StreamHandler() + handler.addFilter(_SENSITIVE_FILTER) + handler.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)s %(name)s %(message)s", + ), + ) + logger.addHandler(handler) + + +def set_log_level(level_name: str) -> None: + """Apply a log level to the v2 logger immediately.""" + level = _LEVELS.get(str(level_name).strip().lower()) + if level is None: + raise ValueError( + f"invalid log level {level_name!r}, " + f"expected one of {sorted(_LEVELS)}", + ) + _configure(level) + + +def reset_log_level() -> None: + """Restore the v2 logger level to the env-configured level or WARNING.""" + level_name = os.environ.get(LOG_ENV, "").strip().lower() + level = _LEVELS.get(level_name, logging.WARNING) + logging.getLogger(ROOT_NAME).setLevel(level) + + +def _ensure_configured() -> None: + global _configured + if _configured: + return + _configured = True + level_name = os.environ.get(LOG_ENV, "").strip().lower() + level = _LEVELS.get(level_name) + if level is None: + return + _configure(level) + + +def get_logger(name: str) -> logging.Logger: + _ensure_configured() + logger = logging.getLogger(f"{ROOT_NAME}.{name}") + # Logger-level filters don't see records propagated from children, so + # the filter must sit on each emitting logger (and on the handler). + if _SENSITIVE_FILTER not in logger.filters: + logger.addFilter(_SENSITIVE_FILTER) + return logger diff --git a/dashscope/v2/_quickstart.py b/dashscope/v2/_quickstart.py new file mode 100644 index 00000000..78f46647 --- /dev/null +++ b/dashscope/v2/_quickstart.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Module-level quickstart entry points backed by a lazy singleton Client.""" + +from __future__ import annotations + +import threading +from typing import Any, Optional + +from dashscope.v2.client import Client +from dashscope.v2.types.completions import ChatCompletion + +__all__ = ["chat"] + +_lock = threading.Lock() +_client: Optional[Client] = None + + +def _get_client() -> Client: + global _client + if _client is None: + with _lock: + if _client is None: + _client = Client() + return _client + + +def chat(model: str, prompt: str, **kwargs: Any) -> ChatCompletion: + """One-call chat entry: ``dashscope.chat("qwen-plus", "Tell me a joke")``. + + Extra keyword arguments are forwarded to + ``client.chat.completions.create``. + """ + return _get_client().chat.completions.create( + model=model, + messages=[prompt], + **kwargs, + ) diff --git a/dashscope/v2/client.py b/dashscope/v2/client.py new file mode 100644 index 00000000..5da60f60 --- /dev/null +++ b/dashscope/v2/client.py @@ -0,0 +1,453 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 Client shells. + +Resources and transport are attached in subsequent M1 tasks; +this module only holds construction and configuration. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Mapping, Optional, Union + +import httpx + +from dashscope.v2._config import Config, resolve_config +from dashscope.v2.protocols import ChatProtocol, get_protocol +from dashscope.v2.resources.apps import Apps, AsyncApps +from dashscope.v2.resources.audio import AsyncAudio, Audio +from dashscope.v2.resources.chat import AsyncChat, Chat +from dashscope.v2.resources.decision import AsyncDecision, Decision +from dashscope.v2.resources.deployments import AsyncDeployments, Deployments +from dashscope.v2.resources.embeddings import AsyncEmbeddings, Embeddings +from dashscope.v2.resources.files import AsyncFiles, Files +from dashscope.v2.resources.fine_tunes import AsyncFineTunes, FineTunes +from dashscope.v2.resources.images import AsyncImages, Images +from dashscope.v2.resources.models import AsyncModels, Models +from dashscope.v2.resources.multimodal import AsyncMultimodal, Multimodal +from dashscope.v2.resources.rerank import AsyncRerank, Rerank +from dashscope.v2.resources.reinforcement_learning import AsyncRl, Rl +from dashscope.v2.resources.tingwu import AsyncTingWu, TingWu +from dashscope.v2.resources.tokenization import AsyncTokenization, Tokenization +from dashscope.v2.resources.videos import AsyncVideos, Videos +from dashscope.v2.transport import ( + AsyncTransport, + SyncTransport, + default_user_agent, +) +from dashscope.v2.transport.ws import Connector +from dashscope.v2.transport.ws_connection import WsConnection + + +def _http_limits(config: Config) -> httpx.Limits: + """Connection-pool sizes for the client's own httpx client.""" + return httpx.Limits( + max_connections=config.http_max_connections, + max_keepalive_connections=config.http_max_keepalive_connections, + keepalive_expiry=config.http_keepalive_expiry, + ) + + +class Client: + """Synchronous dashscope 2.0 client. + + Usage:: + + import dashscope + + client = dashscope.Client(api_key="sk-xxx") + resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + protocol: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + config_path: Union[str, Path, None] = None, + http_client: Optional[httpx.Client] = None, + raise_on_error: bool = True, + protocol_options: Optional[Dict[str, Any]] = None, + ) -> None: + self._config = resolve_config( + api_key=api_key, + base_url=base_url, + workspace=workspace, + region=region, + protocol=protocol, + timeout=timeout, + max_retries=max_retries, + config_path=config_path, + ) + self._raise_on_error = raise_on_error + self._protocol_options = protocol_options + self._protocol = get_protocol( + self._config.protocol, + protocol_options=protocol_options, + ) + self._transport = SyncTransport( + base_url=self._config.base_url, + api_key=self._config.api_key, + workspace=self._config.workspace, + user_agent=default_user_agent(), + timeout=self._config.timeout, + max_retries=self._config.max_retries, + http_client=http_client, + limits=None + if http_client is not None + else _http_limits(self._config), + ) + self.chat = Chat(self) + self.multimodal = Multimodal(self) + self.embeddings = Embeddings(self) + self.rerank = Rerank(self) + self.decision = Decision(self) + self.tokenization = Tokenization(self) + self.files = Files(self) + self.models = Models(self) + self.images = Images(self) + self.videos = Videos(self) + self.audio = Audio(self) + self.fine_tunes = FineTunes(self) + self.deployments = Deployments(self) + self.rl = Rl(self) + self.apps = Apps(self) + self.tingwu = TingWu(self) + self._agentstudio = None + + @property + def reinforcement_learning(self) -> "Rl": + """Long-form alias for ``client.rl``.""" + return self.rl + + @property + def agentstudio(self): + """Lazily mounted 1.x AgentStudio sub-SDK (design doc M3 3.8). + + The client is wrapped in a transparent proxy (design doc M3 3.12) + that translates 1.x AgentStudio errors into the ``dashscope.v2`` + exception family, so ``except dashscope.v2.errors.DashScopeError`` + covers them. The proxy spoofs ``__class__`` and forwards + attributes, so ``isinstance(client.agentstudio, AgentStudioClient)`` + and ``.transport`` access keep working; no 1.x code is touched. + + AgentStudio rides the shared 1.x transport, so its requests keep + the unmarked 1.x module tag (``agentstudio``), not the v2 ``2`` + marker -- intentional, since this is the 1.x code path. + """ + if self._agentstudio is None: + # pylint: disable=import-outside-toplevel + from dashscope.agentstudio import Client as AgentStudioClient + from dashscope.v2.resources._agentstudio import wrap_agentstudio + + # agentstudio resolves its own base_url from workspace + + # region (the v2 base_url does not propagate: the agentstudio + # path differs), so forward both. + self._agentstudio = wrap_agentstudio( + AgentStudioClient( + api_key=self._config.api_key, + workspace=self._config.workspace, + region=self._config.region, + ), + ) + return self._agentstudio + + @property + def config(self) -> Config: + return self._config + + @property + def protocol(self) -> ChatProtocol: + return self._protocol + + @property + def transport(self) -> SyncTransport: + return self._transport + + @property + def raise_on_error(self) -> bool: + return self._raise_on_error + + def with_options( + self, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + protocol: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + raise_on_error: Optional[bool] = None, + ) -> "Client": + """Derive a client with the given options overridden. + + Unmentioned options are inherited. When connection-level options + (``api_key``/``base_url``/``workspace``/``region``/``timeout``) + are left unchanged the derived client shares this one's + connection pool and must not outlive it; otherwise it builds its + own pool. + """ + config = self._config + share_pool = ( + api_key is None + and base_url is None + and workspace is None + and region is None + and timeout is None + ) + return type(self)( + api_key=config.api_key if api_key is None else api_key, + base_url=( + config.configured_base_url if base_url is None else base_url + ), + workspace=(config.workspace if workspace is None else workspace), + region=config.region if region is None else region, + protocol=config.protocol if protocol is None else protocol, + timeout=config.timeout if timeout is None else timeout, + max_retries=( + config.max_retries if max_retries is None else max_retries + ), + http_client=(self._transport.http_client if share_pool else None), + raise_on_error=( + self._raise_on_error + if raise_on_error is None + else raise_on_error + ), + protocol_options=self._protocol_options, + ) + + def close(self) -> None: + self._transport.close() + if self._agentstudio is not None: + self._agentstudio.close() + self._agentstudio = None + + def ws_connection( + self, + *, + url: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + module: Optional[str] = None, + ) -> WsConnection: + """Open a shared duplex websocket carrying multiple tasks. + + Credentials, region and timeout come from this client's config. + Pass the connection as ``connection=`` to the realtime audio + sessions (``audio.asr.recognize`` / ``audio.tts.connect`` / + ``audio.translation.recognize`` / ``tingwu.realtime``) to run + tasks concurrently or back-to-back on one socket, saving the + handshake per task -- the 1.x Java-side connection-reuse pattern + for latency-sensitive audio traffic. The caller owns the + connection and closes it via ``WsConnection.close``. + """ + config = self._config + return WsConnection( + url=url, + api_key=config.api_key, + workspace=config.workspace, + region=config.region, + headers=headers, + connector=connector, + timeout=config.timeout if timeout is None else timeout, + module=module, + ) + + def __enter__(self) -> "Client": + return self + + def __exit__(self, *exc: Any) -> None: + self.close() + + +class AsyncClient: + """Asynchronous dashscope 2.0 client. + + Usage:: + + import dashscope + + client = dashscope.AsyncClient(api_key="sk-xxx") + resp = await client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + """ + + def __init__( + self, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + protocol: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + config_path: Union[str, Path, None] = None, + http_client: Optional[httpx.AsyncClient] = None, + raise_on_error: bool = True, + protocol_options: Optional[Dict[str, Any]] = None, + ) -> None: + self._config = resolve_config( + api_key=api_key, + base_url=base_url, + workspace=workspace, + region=region, + protocol=protocol, + timeout=timeout, + max_retries=max_retries, + config_path=config_path, + ) + self._raise_on_error = raise_on_error + self._protocol_options = protocol_options + self._protocol = get_protocol( + self._config.protocol, + protocol_options=protocol_options, + ) + self._transport = AsyncTransport( + base_url=self._config.base_url, + api_key=self._config.api_key, + workspace=self._config.workspace, + user_agent=default_user_agent(), + timeout=self._config.timeout, + max_retries=self._config.max_retries, + http_client=http_client, + limits=None + if http_client is not None + else _http_limits(self._config), + ) + self.chat = AsyncChat(self) + self.multimodal = AsyncMultimodal(self) + self.embeddings = AsyncEmbeddings(self) + self.rerank = AsyncRerank(self) + self.decision = AsyncDecision(self) + self.tokenization = AsyncTokenization(self) + self.files = AsyncFiles(self) + self.models = AsyncModels(self) + self.images = AsyncImages(self) + self.videos = AsyncVideos(self) + self.audio = AsyncAudio(self) + self.fine_tunes = AsyncFineTunes(self) + self.deployments = AsyncDeployments(self) + self.rl = AsyncRl(self) + self.apps = AsyncApps(self) + self.tingwu = AsyncTingWu(self) + self._agentstudio = None + + @property + def reinforcement_learning(self) -> "AsyncRl": + """Long-form alias for ``client.rl``.""" + return self.rl + + @property + def agentstudio(self): + """Lazily mounted 1.x AgentStudio sub-SDK (design doc M3 3.8). + + See :attr:`Client.agentstudio`: the returned client is wrapped in + a transparent proxy that aligns 1.x AgentStudio errors with the + ``dashscope.v2`` exception family (design doc M3 3.12). + """ + if self._agentstudio is None: + # pylint: disable=import-outside-toplevel + from dashscope.agentstudio import AsyncClient as AsClient + from dashscope.v2.resources._agentstudio import wrap_agentstudio + + # See Client.agentstudio: agentstudio builds its own base_url + # from workspace + region, so forward both. + self._agentstudio = wrap_agentstudio( + AsClient( + api_key=self._config.api_key, + workspace=self._config.workspace, + region=self._config.region, + ), + ) + return self._agentstudio + + @property + def config(self) -> Config: + return self._config + + @property + def protocol(self) -> ChatProtocol: + return self._protocol + + @property + def transport(self) -> AsyncTransport: + return self._transport + + @property + def raise_on_error(self) -> bool: + return self._raise_on_error + + def with_options( + self, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + protocol: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + raise_on_error: Optional[bool] = None, + ) -> "AsyncClient": + """Derive a client with the given options overridden. + + Unmentioned options are inherited. When connection-level options + (``api_key``/``base_url``/``workspace``/``region``/``timeout``) + are left unchanged the derived client shares this one's + connection pool and must not outlive it; otherwise it builds its + own pool. + """ + config = self._config + share_pool = ( + api_key is None + and base_url is None + and workspace is None + and region is None + and timeout is None + ) + return type(self)( + api_key=config.api_key if api_key is None else api_key, + base_url=( + config.configured_base_url if base_url is None else base_url + ), + workspace=(config.workspace if workspace is None else workspace), + region=config.region if region is None else region, + protocol=config.protocol if protocol is None else protocol, + timeout=config.timeout if timeout is None else timeout, + max_retries=( + config.max_retries if max_retries is None else max_retries + ), + http_client=(self._transport.http_client if share_pool else None), + raise_on_error=( + self._raise_on_error + if raise_on_error is None + else raise_on_error + ), + protocol_options=self._protocol_options, + ) + + async def aclose(self) -> None: + await self._transport.aclose() + if self._agentstudio is not None: + await self._agentstudio.aclose() + self._agentstudio = None + + async def __aenter__(self) -> "AsyncClient": + return self + + async def __aexit__(self, *exc: Any) -> None: + await self.aclose() diff --git a/dashscope/v2/errors.py b/dashscope/v2/errors.py new file mode 100644 index 00000000..a84cba88 --- /dev/null +++ b/dashscope/v2/errors.py @@ -0,0 +1,438 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Exception hierarchy and error codes for the dashscope 2.0 SDK. + +Class names and HTTP statuses are read out of the generated error +catalog (``dashscope/v2/_error_registry.py``) instead of being spelled +here: every ``PUBLIC_ERRORS.error_code`` names exactly one exception +class, and that class takes its ``status_code`` from the same entry. + +The catalog carries no gateway wire codes, so ``_WIRE_TO_PUBLIC`` below +is the SDK's own crosswalk from wire code to catalog entry. Class +selection stays catalog-driven: the crosswalk only picks *which* +``PublicError`` applies, never which exception class. + +Two-layer error model: besides non-2xx HTTP statuses, the DashScope +gateway may return HTTP 200 with a business error in the body +(``code``/``message`` keys); both layers map into this hierarchy. + +A third source has no response at all: failures the SDK reports on its +own side. Those come from the catalog's ``SDK_ERRORS`` table as +:class:`SdkError`, and the v2 mount layers over 1.x subsystems +translate with :func:`error_from_sdk_exception`. +""" + +from __future__ import annotations + +import json +from collections import Counter +from enum import Enum +from typing import Any, Dict, Mapping, Optional, Type + +import httpx + +from dashscope.v2 import _error_registry as _registry +from dashscope.v2._error_registry import PublicError, SdkErrorDef + + +class ErrorCode(str, Enum): + """Server-side error codes returned in ``response.code``.""" + + INVALID_API_KEY = "InvalidApiKey" + ACCESS_DENIED = "AccessDenied" + ACCESS_DENIED_UNPURCHASED = "AccessDenied.Unpurchased" + INVALID_PARAMETER = "InvalidParameter" + MODEL_NOT_EXIST = "Model.NotExist" + DATA_INSPECTION_FAILED = "DataInspectionFailed" + THROTTLING = "Throttling" + THROTTLING_RATE_QUOTA = "Throttling.RateQuota" + THROTTLING_ALLOCATION_QUOTA = "Throttling.AllocationQuota" + INTERNAL_ERROR = "InternalError" + SERVICE_UNAVAILABLE = "ServiceUnavailable" + GATEWAY_TIMEOUT = "GatewayTimeout" + ARREARAGE = "Arrearage" + + +class DashScopeError(Exception): + """Base class for all dashscope 2.0 SDK errors.""" + + def __init__( + self, + message: str, + *, + code: Optional[str] = None, + status_code: Optional[int] = None, + request_id: Optional[str] = None, + body: Any = None, + headers: Optional[Mapping[str, str]] = None, + ) -> None: + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.request_id = request_id + self.body = body + self.headers = headers or {} + + def __str__(self) -> str: + parts = [f"<{type(self).__name__}> {self.message}"] + if self.code: + parts.append(f"code={self.code}") + if self.status_code is not None: + parts.append(f"status_code={self.status_code}") + if self.request_id: + parts.append(f"request_id={self.request_id}") + return " | ".join(parts) + + +class APIConnectionError(DashScopeError): + """Network-level failure (connection refused, DNS, TLS...).""" + + +class APITimeoutError(APIConnectionError): + """Request timed out.""" + + +class APIStatusError(DashScopeError): + """A failure the gateway declared, on either layer of the model. + + Both a non-2xx HTTP status and an HTTP 200 whose body carries a + business ``code`` land here; the instance's ``status_code`` says + which one it was. + """ + + status_code: Optional[int] = None + + +def _status_by_class_name() -> Dict[str, int]: + out: Dict[str, int] = {} + for entry in _registry.PUBLIC_ERRORS: + out.setdefault(entry.error_code, entry.status_code) + return out + + +_CATALOG_STATUS = _status_by_class_name() + + +class BadRequestError(APIStatusError): + status_code = _CATALOG_STATUS["BadRequestError"] + + +class AuthenticationError(APIStatusError): + status_code = _CATALOG_STATUS["AuthenticationError"] + + +class PermissionDeniedError(APIStatusError): + status_code = _CATALOG_STATUS["PermissionDeniedError"] + + +class NotFoundError(APIStatusError): + status_code = _CATALOG_STATUS["NotFoundError"] + + +class RequestTooLargeError(APIStatusError): + status_code = _CATALOG_STATUS["RequestTooLargeError"] + + +class RateLimitError(APIStatusError): + status_code = _CATALOG_STATUS["RateLimitError"] + + +class InternalServerError(APIStatusError): + status_code = _CATALOG_STATUS["InternalServerError"] + + +class ServiceUnavailableError(APIStatusError): + status_code = _CATALOG_STATUS["ServiceUnavailableError"] + + +class GatewayTimeoutError(APIStatusError): + status_code = _CATALOG_STATUS["GatewayTimeoutError"] + + +class ContentFilterError(BadRequestError): + """Input or output blocked by content moderation. + + The catalog entry behind it (``content_policy_violation``) names + ``BadRequestError``; V2 refines that into a dedicated class so + moderation blocks are catchable on their own. Subclassing is what + keeps the refinement catalog-legal, an invariant the tests pin for + every row of the crosswalk. + """ + + +class SdkError(DashScopeError): + """A failure the SDK reported itself, with no HTTP response behind it. + + ``code`` is the catalog name (``sdk.agentic_rl.OSSUploadError``) and + ``solution`` the fix guidance shipped alongside it. ``status_code`` + stays ``None``: the catalog gives SDK-side errors no public + projection, so inventing a status would add meaning the standard + does not carry. + """ + + def __init__( + self, + message: str, + *, + code: Optional[str] = None, + solution: Optional[str] = None, + allow_retry: bool = False, + request_id: Optional[str] = None, + body: Any = None, + headers: Optional[Mapping[str, str]] = None, + ) -> None: + super().__init__( + message, + code=code, + request_id=request_id, + body=body, + headers=headers, + ) + self.solution = solution + self.allow_retry = allow_retry + + +_CATALOG_CLASSES: Dict[str, Type[APIStatusError]] = { + cls.__name__: cls + for cls in ( + BadRequestError, + AuthenticationError, + PermissionDeniedError, + NotFoundError, + RequestTooLargeError, + RateLimitError, + InternalServerError, + ServiceUnavailableError, + GatewayTimeoutError, + ) +} + +_STATUS_MAP: Dict[int, Type[APIStatusError]] = { + entry.status_code: _CATALOG_CLASSES[entry.error_code] + for entry in _registry.PUBLIC_ERRORS +} + +_STATUS_COUNTS = Counter( + entry.status_code for entry in _registry.PUBLIC_ERRORS +) + +# Only the statuses a single entry claims. 400 / 401 / 429 carry siblings +# with different ``error_msg`` text, so a status alone cannot pick one +# without stating a reason the gateway never gave. +_STATUS_ENTRY: Dict[int, PublicError] = { + entry.status_code: entry + for entry in _registry.PUBLIC_ERRORS + if _STATUS_COUNTS[entry.status_code] == 1 +} + +_PUBLIC_BY_KEY: Dict[str, PublicError] = { + entry.key: entry for entry in _registry.PUBLIC_ERRORS +} + + +def _entry(key: str) -> PublicError: + return _PUBLIC_BY_KEY[key] + + +_WIRE_TO_PUBLIC: Dict[str, PublicError] = { + ErrorCode.INVALID_API_KEY.value: _entry("invalid_api_key"), + ErrorCode.ACCESS_DENIED.value: _entry("permission_denied"), + ErrorCode.ACCESS_DENIED_UNPURCHASED.value: _entry("permission_denied"), + ErrorCode.INVALID_PARAMETER.value: _entry("invalid_request"), + ErrorCode.MODEL_NOT_EXIST.value: _entry("resource_not_found"), + ErrorCode.DATA_INSPECTION_FAILED.value: _entry("content_policy_violation"), + ErrorCode.THROTTLING.value: _entry("rate_limit_exceeded"), + ErrorCode.THROTTLING_RATE_QUOTA.value: _entry("rate_limit_exceeded"), + ErrorCode.THROTTLING_ALLOCATION_QUOTA.value: _entry("rate_limit_exceeded"), + ErrorCode.INTERNAL_ERROR.value: _entry("internal_error"), + ErrorCode.SERVICE_UNAVAILABLE.value: _entry("service_unavailable"), + ErrorCode.GATEWAY_TIMEOUT.value: _entry("request_timeout"), + # Arrearage is a refused request, not an exhausted quota: the + # catalog's insufficient_quota entry is 429 and therefore retryable. + ErrorCode.ARREARAGE.value: _entry("permission_denied"), +} + +# Wire codes V2 resolves to a subclass of the class the entry names. +_REFINED_CLASS: Dict[str, Type[DashScopeError]] = { + ErrorCode.DATA_INSPECTION_FAILED.value: ContentFilterError, +} + +_CODE_MAP: Dict[str, Type[DashScopeError]] = { + wire: _REFINED_CLASS.get(wire) or _CATALOG_CLASSES[entry.error_code] + for wire, entry in _WIRE_TO_PUBLIC.items() +} + +_SDK_ERRORS: Dict[str, SdkErrorDef] = { + entry.name: entry for entry in _registry.SDK_ERRORS +} + + +def _extract( + body: Any, +) -> Dict[str, Any]: + if isinstance(body, dict): + return body + return {} + + +def _request_id( + body: Dict[str, Any], + headers: Mapping[str, str], +) -> Optional[str]: + return body.get("request_id") or headers.get("x-request-id") + + +def _catalog_message( + code: Optional[str], + status_code: Optional[int] = None, +) -> str: + """Catalog text for a failure that arrived without a message. + + Placeholders stay verbatim: nothing here knows the ``{parameter}`` / + ``{resource}`` / ``{limit}`` values, and stripping them would hide + the field the catalog declares. An entry that cannot be identified + yields no text rather than a sibling's guess. + """ + entry = _WIRE_TO_PUBLIC.get(code) if code is not None else None + if entry is None and status_code is not None: + entry = _STATUS_ENTRY.get(status_code) + return entry.error_msg if entry is not None else "" + + +def error_from_status( + status_code: int, + *, + message: str, + body: Any = None, + headers: Optional[Mapping[str, str]] = None, +) -> DashScopeError: + data = _extract(body) + code = data.get("code") + message = data.get("message") or message + error = data.get("error") + if isinstance(error, dict): + code = code or error.get("code") or error.get("type") + if not data.get("message"): + message = error.get("message") or message + cls = _CODE_MAP.get(code) or _STATUS_MAP.get(status_code) or APIStatusError + return cls( + message or _catalog_message(code, status_code), + code=code, + status_code=status_code, + request_id=_request_id(data, headers or {}), + body=body, + headers=headers, + ) + + +def error_from_business_code( + *, + message: str, + code: Optional[str] = None, + body: Any = None, + headers: Optional[Mapping[str, str]] = None, +) -> DashScopeError: + """Build an error for the HTTP-200-but-business-failure layer.""" + data = _extract(body) + code = code or data.get("code") + cls = _CODE_MAP.get(code, DashScopeError) + return cls( + message or _catalog_message(code), + code=code, + status_code=200, + request_id=_request_id(data, headers or {}), + body=body, + headers=headers, + ) + + +def error_from_sdk_exception( + exc: BaseException, + *, + family: str, +) -> SdkError: + """Translate a 1.x SDK-side exception into the v2 error family. + + ``family`` is the catalog namespace the subsystem owns, e.g. + ``sdk.agentic_rl``. Lookup is two-tier and needs no hand-maintained + table: ``.`` when the catalog carries that exact + class, otherwise the family's ``.Error`` fallback. Message + text and the 1.x numeric ``error_code`` are deliberately never + sniffed -- both are 1.x internals. + """ + name = f"{family}.{type(exc).__name__}" + entry = _SDK_ERRORS.get(name) + if entry is None: + entry = _SDK_ERRORS[f"{family}.Error"] + return SdkError( + str(exc), + code=entry.name, + solution=entry.solution, + allow_retry=entry.allow_retry, + ) + + +def _sse_error_body(text: str) -> Any: + """Decode an SSE error body into its first ``data:`` JSON payload. + + A streaming request the gateway rejects before any output arrives + as a non-2xx response whose body is the error frame itself -- + ``event:error`` plus a ``data:`` JSON line -- instead of a plain + JSON document. 1.x parses the same shape in + ``common.utils._handle_http_failed_response``; like + ``resources/_sse.py``, the frame is detected by line shape rather + than content-type, so a mislabeled header still maps. Bodies with + no decodable frame yield None, leaving the raw-text fallback + intact. + """ + for line in text.splitlines(): + if not line.startswith("data:"): + continue + try: + body = json.loads(line[len("data:") :]) + except json.JSONDecodeError: + continue + if isinstance(body, dict): + return body + return None + + +def _parse_body(response: httpx.Response) -> Any: + try: + return response.json() + except Exception: + return _sse_error_body(response.text) + + +def response_error(response: httpx.Response) -> Optional[DashScopeError]: + """Return the mapped error for a failed response, else None. + + Dual-layer check: HTTP status first, then business code in body. + """ + if response.status_code >= 400: + return error_from_status( + response.status_code, + message=response.text, + body=_parse_body(response), + headers=response.headers, + ) + body = _parse_body(response) + data = _extract(body) + if data.get("code"): + return error_from_business_code( + message=data.get("message") or response.text, + body=body, + headers=response.headers, + ) + return None + + +def check_response( + response: httpx.Response, + *, + raise_on_error: bool = True, +) -> None: + error = response_error(response) + if error is not None and raise_on_error: + raise error diff --git a/dashscope/v2/protocols/__init__.py b/dashscope/v2/protocols/__init__.py new file mode 100644 index 00000000..78178b78 --- /dev/null +++ b/dashscope/v2/protocols/__init__.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Protocol adapters (native / openai / anthropic). + +See design doc §3 for the translation contract. +""" + +from typing import Any, Dict, Optional + +from dashscope.v2._config import VALID_PROTOCOLS +from dashscope.v2.protocols.anthropic import AnthropicProtocol +from dashscope.v2.protocols.base import ChatProtocol, ChatRequest +from dashscope.v2.protocols.native import NativeProtocol +from dashscope.v2.protocols.openai import OpenAIProtocol + +__all__ = [ + "AnthropicProtocol", + "ChatProtocol", + "ChatRequest", + "NativeProtocol", + "OpenAIProtocol", + "get_protocol", +] + +_REGISTRY = { + NativeProtocol.name: NativeProtocol, + OpenAIProtocol.name: OpenAIProtocol, + AnthropicProtocol.name: AnthropicProtocol, +} + + +def get_protocol( + name: str, + protocol_options: Optional[Dict[str, Any]] = None, +) -> ChatProtocol: + """Resolve a protocol adapter by name, passing adapter options.""" + protocol_cls = _REGISTRY.get(name) + if protocol_cls is None: + raise ValueError( + f"unknown protocol {name!r}, expected one of {VALID_PROTOCOLS}", + ) + try: + return protocol_cls( # type: ignore[abstract] + **dict(protocol_options or {}), + ) + except TypeError as exc: + raise ValueError( + f"invalid protocol_options for protocol {name!r}: {exc}", + ) from exc diff --git a/dashscope/v2/protocols/anthropic.py b/dashscope/v2/protocols/anthropic.py new file mode 100644 index 00000000..1b916130 --- /dev/null +++ b/dashscope/v2/protocols/anthropic.py @@ -0,0 +1,699 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Anthropic protocol adapter (design doc §3.1). + +Translates the canonical chat models to ``POST /v1/messages`` requests so +``Client(protocol="anthropic", base_url=...)`` can talk to Anthropic +or Anthropic-compatible endpoints. System messages move into the +``system`` field, tool calls map to ``tool_use``/``tool_result`` +blocks; thinking blocks (enabled by passing the native ``thinking`` +object through ``extra_parameters``) surface as ``reasoning_content`` +in streaming and non-streaming responses alike, with the block +signature preserved as ``reasoning_signature`` and echoed back on +assistant turns so strict endpoints accept the replayed thinking +blocks. Canonical ``tool_choice`` values translate onto the wire's +``tool_choice`` object: ``"auto"``/``"none"`` keep their type, +``"required"`` becomes ``{"type": "any"}``, function dicts become +``{"type": "tool", "name": ...}``, and Anthropic-shaped dicts pass +through unchanged. Canonical parameters with no mapped Anthropic +equivalent (``_UNSUPPORTED_CANONICAL``) raise ``BadRequestError`` +rather than passing through silently — including when smuggled in via +``extra_parameters``; other ``extra_parameters`` still pass through +unless ``strict=True``. + +Streaming tool use accumulates per-stream state; every live stream +gets its own parser via ``make_chunk_parser()``, so concurrent +streams on one client stay isolated. +""" + +from __future__ import annotations + +import json +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from dashscope.v2.errors import BadRequestError, DashScopeError, ErrorCode +from dashscope.v2.protocols.base import ChatProtocol, ChatRequest +from dashscope.v2.types.completions import ( + ChatCompletion, + ChatCompletionChunk, +) + +__all__ = [ + "AnthropicProtocol", +] + +# Anthropic requires max_tokens; used when the caller omits it. +DEFAULT_MAX_TOKENS = 4096 + +# Canonical parameters this adapter does not map onto the Anthropic +# Messages wire (``tool_choice`` does map — see _translate_tool_choice; +# ``enable_thinking``/``thinking_budget`` reach the wire's ``thinking`` +# object only as a pass-through via ``extra_parameters``). Rejected +# whether passed directly or smuggled through ``extra_parameters``, +# because silently dropping them is invisible to the caller (the +# request "works" but the parameter never takes effect). +_UNSUPPORTED_CANONICAL = frozenset( + { + "seed", + "repetition_penalty", + "presence_penalty", + "enable_thinking", + "response_format", + "n", + "logprobs", + "top_logprobs", + "thinking_budget", + "enable_search", + "search_options", + "parallel_tool_calls", + }, +) + +_STOP_REASON_MAP = { + "end_turn": "stop", + "stop_sequence": "stop", + "tool_use": "tool_calls", + "max_tokens": "length", +} + + +# The explicit context-cache marker is an Anthropic block-level field +# (prompt caching), so it maps to the same part-level sibling here. +def _copy_cache_control( + part: Dict[str, Any], + out: Dict[str, Any], +) -> None: + if part.get("cache_control") is not None: + out["cache_control"] = part["cache_control"] + + +def _text_of(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + return "".join( + p.get("text", "") for p in content if p.get("type") == "text" + ) + + +def _translate_part(part: Dict[str, Any]) -> Dict[str, Any]: + part_type = part.get("type") + if part_type == "text": + text: Dict[str, Any] = {"type": "text", "text": part.get("text", "")} + _copy_cache_control(part, text) + return text + if part_type == "image": + # Anthropic has no resolution-control equivalent; dropping the + # hints would silently change token cost, so fail fast. + if ( + part.get("min_pixels") is not None + or part.get("max_pixels") is not None + ): + raise ValueError( + "image pixel hints (min_pixels/max_pixels) are supported " + "only with protocol='native'", + ) + image: Dict[str, Any] = { + "type": "image", + "source": {"type": "url", "url": part["url"]}, + } + _copy_cache_control(part, image) + return image + raise ValueError( + f"content part {part_type!r} has no Anthropic equivalent; " + "use protocol='native' for audio/video parts", + ) + + +def _tool_use_blocks( + tool_calls: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + blocks = [] + for call in tool_calls: + function = call.get("function") or {} + arguments = function.get("arguments") or "" + try: + tool_input = json.loads(arguments) if arguments else {} + except (TypeError, ValueError): + tool_input = {"_raw": arguments} + blocks.append( + { + "type": "tool_use", + "id": call.get("id"), + "name": function.get("name"), + "input": tool_input, + }, + ) + return blocks + + +def _tool_result_block(message: Dict[str, Any]) -> Dict[str, Any]: + return { + "type": "tool_result", + "tool_use_id": message.get("tool_call_id"), + "content": _text_of(message.get("content")), + } + + +def _translate_message(message: Dict[str, Any]) -> Dict[str, Any]: + role = message["role"] + content = message.get("content") + out: Dict[str, Any] = {"role": role} + if role == "assistant": + blocks: List[Dict[str, Any]] = [] + thinking = _thinking_block(message) + if thinking is not None: + blocks.append(thinking) + if message.get("tool_calls") or blocks: + if isinstance(content, list): + # Parts lists keep their non-text parts; collapsing + # them to text here would silently drop images. + blocks.extend(_translate_part(p) for p in content) + elif _text_of(content): + blocks.append({"type": "text", "text": _text_of(content)}) + if message.get("tool_calls"): + blocks.extend(_tool_use_blocks(message["tool_calls"])) + out["content"] = blocks + return out + if isinstance(content, list): + content = [_translate_part(p) for p in content] + out["content"] = content if content is not None else "" + return out + + +def _thinking_block(message: Dict[str, Any]) -> Optional[Dict[str, Any]]: + # Strict Anthropic-compatible endpoints reject thinking blocks + # without a valid signature, and require signed ones replayed + # verbatim on later turns; unsigned reasoning stays dropped. + thinking = message.get("reasoning_content") + signature = message.get("reasoning_signature") + if thinking and signature: + return { + "type": "thinking", + "thinking": thinking, + "signature": signature, + } + return None + + +def _translate_tools( + tools: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + translated = [] + for tool in tools: + function = tool.get("function") or tool + item: Dict[str, Any] = { + "name": function.get("name"), + "input_schema": function.get("parameters") + or {"type": "object", "properties": {}}, + } + if function.get("description"): + item["description"] = function["description"] + translated.append(item) + return translated + + +def _translate_tool_choice( + tool_choice: Union[str, Dict[str, Any]], +) -> Dict[str, Any]: + # Canonical strings and the OpenAI function shape map onto the + # Anthropic object form the wire documents (auto/any/none/tool); + # Anthropic-shaped dicts — including extra keys such as + # ``disable_parallel_tool_use`` — pass through unchanged. Anything + # else raises instead of forwarding: the gateway ignores unknown + # shapes, so a forwarded value would never take effect. + if tool_choice in ("auto", "none"): + return {"type": tool_choice} + if tool_choice == "required": + return {"type": "any"} + if isinstance(tool_choice, dict): + choice_type = tool_choice.get("type") + if choice_type == "function": + name = (tool_choice.get("function") or {}).get("name") + if not name: + raise ValueError( + "tool_choice function dict requires function.name", + ) + return {"type": "tool", "name": name} + if choice_type in ("auto", "any", "none", "tool"): + return dict(tool_choice) + raise ValueError( + f"tool_choice {tool_choice!r} has no Anthropic translation; use " + "'auto'/'required'/'none', {'type': 'function', 'function': " + "{'name': ...}}, or an Anthropic-shaped dict", + ) + + +def _split_messages( + messages: List[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + system_blocks: List[Dict[str, Any]] = [] + wire_messages: List[Dict[str, Any]] = [] + tool_results: List[Dict[str, Any]] = [] + + def _flush_tool_results() -> None: + # Consecutive tool-role messages (parallel tool calls) merge + # into ONE user message so user/assistant alternation holds. + if tool_results: + wire_messages.append( + {"role": "user", "content": list(tool_results)}, + ) + tool_results.clear() + + for message in messages: + role = message["role"] + if role == "system": + content = message.get("content") + if isinstance(content, list): + # Text parts become individual blocks so a cache_control + # marker survives; non-text system content keeps the + # historical text-only extraction. + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + system_blocks.append(_translate_part(part)) + elif isinstance(part, str): + system_blocks.append({"type": "text", "text": part}) + else: + system_blocks.append({"type": "text", "text": ""}) + else: + system_blocks.append( + {"type": "text", "text": _text_of(content)}, + ) + elif role == "tool": + tool_results.append(_tool_result_block(message)) + else: + translated = _translate_message(message) + if role == "user" and tool_results and translated.get("content"): + # Join the pending tool results rather than emitting a + # second consecutive user message: tool_result blocks may + # share a user turn with text as long as they come first, + # and alternation has to hold. + content = translated["content"] + blocks = ( + content + if isinstance(content, list) + else [{"type": "text", "text": content}] + ) + wire_messages.append( + {"role": "user", "content": [*tool_results, *blocks]}, + ) + tool_results.clear() + else: + _flush_tool_results() + wire_messages.append(translated) + _flush_tool_results() + return wire_messages, system_blocks + + +def _reject_unsupported(candidates: Tuple[Tuple[str, Any], ...]) -> None: + # Unmapped canonical parameters raise instead of passing through: + # silently dropping them is invisible to the caller (the gateway + # ignores unknown keys), so the request "works" but the parameter + # never takes effect. + names = sorted(key for key, value in candidates if value is not None) + if not names: + return + hint = "" + if "enable_thinking" in names or "thinking_budget" in names: + # The wire does have a thinking equivalent (thinking.type / + # thinking.budget_tokens); the canonical pair stays unmapped as a + # product-scoping decision, so point at the pass-through instead + # of implying a wire limitation. + hint = ( + "; for thinking control pass the native 'thinking' object " + "via extra_parameters" + ) + raise BadRequestError( + f"parameter(s) {names} are not supported by protocol='anthropic' " + f"(not mapped to an Anthropic Messages API field{hint}); drop " + "them or switch to protocol='native'/'openai'", + code=ErrorCode.INVALID_PARAMETER.value, + status_code=400, + ) + + +def _apply_extra_parameters( + body: Dict[str, Any], + strict: bool, + extra_parameters: Optional[Dict[str, Any]], +) -> None: + if not extra_parameters: + return + if strict: + raise DashScopeError( + "unknown Anthropic parameters in strict mode: " + f"{sorted(extra_parameters)}; drop them or use strict=False", + ) + body.update(extra_parameters) + + +def _canonical_usage(usage: Dict[str, Any]) -> Dict[str, Any]: + """Usage bag with the derived ``total_tokens`` filled in. + + Anthropic already spells the canonical counters ``input_tokens`` / + ``output_tokens``, so only the total is computed here; the rest of the + bag (``cache_creation_input_tokens``, ``cache_read_input_tokens``, + ``service_tier``) reaches ``Usage`` as an extra. That matches the + streaming parser, which accumulates the whole bag across events — + reducing it to three numbers would silently drop billing detail the + caller cannot recover. + """ + canonical = dict(usage) + canonical["total_tokens"] = usage.get("input_tokens", 0) + usage.get( + "output_tokens", + 0, + ) + return canonical + + +class _StreamParser: + """Per-stream parsing state for Anthropic SSE events. + + Tool-use blocks stream as incremental JSON fragments and usage + accumulates across events, so every live stream needs its own + parser instance; sharing one would mix chunks across streams. + """ + + def __init__(self) -> None: + self._tools: Dict[int, Dict[str, Any]] = {} + self._usage: Dict[str, int] = {} + + def parse(self, payload: Dict[str, Any]) -> Optional[ChatCompletionChunk]: + handlers = { + "message_start": self._message_start, + "content_block_start": self._block_start, + "content_block_delta": self._block_delta, + "message_delta": self._message_delta, + } + handler = handlers.get(payload.get("type")) + if handler is None: + # ping / content_block_stop / message_stop / unknown events + # carry nothing user-facing; skip them. message_stop still + # resets per-message tool state. + if payload.get("type") == "message_stop": + self._tools = {} + return None + parsed = handler(payload) + if parsed is None: + 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, + ) + + def _message_start(self, payload: Dict[str, Any]): + message = payload.get("message") or {} + self._tools = {} + 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) + return ( + message.get("id") or payload.get("id"), + message.get("model") or payload.get("model"), + [], + usage, + ) + + def _block_start(self, payload: Dict[str, Any]): + block = payload.get("content_block") or {} + if block.get("type") != "tool_use": + # Text/audio block starts carry no content yet; skip. + return None + choices: List[Dict[str, Any]] = [] + self._tools[payload.get("index", 0)] = { + "id": block.get("id"), + "name": block.get("name"), + "arguments": "", + } + choices.append( + { + "index": 0, + "delta": { + "tool_calls": self._cumulative_tool_calls(), + }, + }, + ) + return payload.get("id"), payload.get("model"), choices, None + + def _block_delta(self, payload: Dict[str, Any]): + delta_payload = payload.get("delta") or {} + delta_type = delta_payload.get("type") + delta: Dict[str, Any] = {} + if delta_type == "text_delta": + delta["content"] = delta_payload.get("text", "") + elif delta_type == "thinking_delta": + delta["reasoning_content"] = delta_payload.get("thinking", "") + elif delta_type == "signature_delta": + delta["reasoning_signature"] = delta_payload.get("signature", "") + elif delta_type == "input_json_delta": + tool = self._tools.get(payload.get("index", 0)) + if tool is not None: + tool["arguments"] += delta_payload.get("partial_json", "") + delta["tool_calls"] = self._cumulative_tool_calls() + if not delta: + # Unknown delta types (e.g. citations_delta) carry nothing. + return None + choices = [{"index": 0, "delta": delta}] + return payload.get("id"), payload.get("model"), choices, None + + 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) + if stop_reason is not None: + choices.append( + { + "index": 0, + "finish_reason": _STOP_REASON_MAP.get( + stop_reason, + stop_reason, + ), + "delta": {}, + }, + ) + if not choices and usage is None: + return None + return payload.get("id"), payload.get("model"), choices, usage + + def _cumulative_tool_calls(self) -> List[Dict[str, Any]]: + return [ + { + "id": tool["id"], + "type": "function", + "function": { + "name": tool["name"], + "arguments": tool["arguments"], + }, + } + for _, tool in sorted(self._tools.items()) + ] + + +class AnthropicProtocol(ChatProtocol): + """Adapter for ``POST /v1/messages`` (Anthropic Messages API).""" + + name = "anthropic" + PATH = "/v1/messages" + API_VERSION = "2023-06-01" + + def __init__(self, *, strict: bool = False) -> None: + super().__init__(strict=strict) + self._default_parser = _StreamParser() + + def make_chunk_parser( + self, + ) -> Callable[[Dict[str, Any]], Optional[ChatCompletionChunk]]: + """Return a fresh parser so concurrent streams stay isolated.""" + return _StreamParser().parse + + def build_chat_request( + self, + *, + model: str, + messages: List[Dict[str, Any]], + stream: bool = 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, + extra_parameters: Optional[Dict[str, Any]] = None, + path: Optional[str] = None, + ) -> ChatRequest: + _reject_unsupported( + ( + ("seed", seed), + ("repetition_penalty", repetition_penalty), + ("presence_penalty", presence_penalty), + ("enable_thinking", enable_thinking), + ("response_format", response_format), + ("n", n), + ("logprobs", logprobs), + ("top_logprobs", top_logprobs), + ("thinking_budget", thinking_budget), + ("enable_search", enable_search), + ("search_options", search_options), + ("parallel_tool_calls", parallel_tool_calls), + ), + ) + wire_messages, system_blocks = _split_messages(messages) + body: Dict[str, Any] = { + "model": model, + "messages": wire_messages, + "max_tokens": max_tokens or DEFAULT_MAX_TOKENS, + } + if system_blocks: + if any("cache_control" in b for b in system_blocks): + # A cache_control marker needs the block-list form of the + # Anthropic ``system`` field; without one keep the joined + # string so the wire stays byte-identical. + body["system"] = system_blocks + else: + body["system"] = "\n\n".join(b["text"] for b in system_blocks) + if stream: + body["stream"] = True + if temperature is not None: + body["temperature"] = temperature + if top_p is not None: + body["top_p"] = top_p + if top_k is not None: + body["top_k"] = top_k + if stop is not None: + body["stop_sequences"] = [stop] if isinstance(stop, str) else stop + if tools: + body["tools"] = _translate_tools(tools) + # Emitted even without tools so the value is never silently + # dropped; a tool_choice without tools gets a clear server-side + # 400 instead. + if tool_choice is not None: + body["tool_choice"] = _translate_tool_choice(tool_choice) + # The hatch below is applied verbatim (body.update), so a + # canonical or OpenAI-shaped tool_choice smuggled through + # extra_parameters must go through the same translator first; + # otherwise a bare "required" reaches the wire and the gateway + # silently ignores it. Native shapes pass through the translator + # unchanged, so this converges both entry points and keeps the + # hatch-wins precedence. Copy first: never mutate the caller's + # dict. + if extra_parameters and "tool_choice" in extra_parameters: + extra_parameters = dict(extra_parameters) + extra_parameters["tool_choice"] = _translate_tool_choice( + extra_parameters["tool_choice"], + ) + # Reject unsupported canonical names even when passed via + # extra_parameters, so they can never slip through silently. + _reject_unsupported( + tuple( + (key, value) + for key, value in (extra_parameters or {}).items() + if key in _UNSUPPORTED_CANONICAL + ), + ) + _apply_extra_parameters(body, self._strict, extra_parameters) + return ChatRequest( + path=path or self.PATH, + body=body, + headers={"anthropic-version": self.API_VERSION}, + ) + + def parse_completion(self, payload: Dict[str, Any]) -> ChatCompletion: + text_parts: List[str] = [] + thinking_parts: List[str] = [] + signature_parts: List[str] = [] + tool_calls: List[Dict[str, Any]] = [] + for block in payload.get("content") or []: + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + elif block.get("type") == "thinking": + # Parity with the streaming thinking_delta path. The + # signature is kept so the turn can be echoed back + # to the API; redacted_thinking carries no readable + # text and stays dropped (verbatim replay of it is + # unsupported by the flat canonical model). + thinking_parts.append(block.get("thinking", "")) + if block.get("signature"): + signature_parts.append(block["signature"]) + elif block.get("type") == "tool_use": + tool_calls.append( + { + "id": block.get("id"), + "type": "function", + "function": { + "name": block.get("name"), + "arguments": json.dumps( + block.get("input") or {}, + ensure_ascii=False, + ), + }, + }, + ) + message: Dict[str, Any] = { + "role": payload.get("role") or "assistant", + "content": "".join(text_parts) or None, + } + reasoning_content = "".join(thinking_parts) + if reasoning_content: + message["reasoning_content"] = reasoning_content + # Flat canonical model: a single thinking block round-trips; + # interleaved-thinking responses (several signed blocks) are + # reduced like their text and cannot be replayed faithfully. + reasoning_signature = "".join(signature_parts) + if reasoning_signature: + message["reasoning_signature"] = reasoning_signature + if tool_calls: + 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, + ) + + def parse_chunk( + self, + payload: Dict[str, Any], + ) -> 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) diff --git a/dashscope/v2/protocols/base.py b/dashscope/v2/protocols/base.py new file mode 100644 index 00000000..5e6a09d6 --- /dev/null +++ b/dashscope/v2/protocols/base.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Protocol adapter interface for the dashscope 2.0 SDK. + +A protocol adapter translates between the SDK's canonical chat models +and one wire format (native / openai / anthropic, see design doc §3). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Dict, + List, + Optional, + Union, +) + +if TYPE_CHECKING: + # Annotation-only: avoid importing resources at runtime, which would + # cycle back through completions.py into this partially-loaded module. + from dashscope.v2.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + ) + +__all__ = [ + "ChatProtocol", + "ChatRequest", +] + + +@dataclass(frozen=True) +class ChatRequest: + """Wire-level description of one chat request.""" + + path: str + body: Dict[str, Any] + headers: Dict[str, str] = field(default_factory=dict) + + +class ChatProtocol(ABC): + """Translates canonical chat calls to one wire protocol.""" + + name: ClassVar[str] + + # Endpoint for messages carrying media parts; None when the adapter + # serves them from its single chat path. + MULTIMODAL_PATH: ClassVar[Optional[str]] = None + + def __init__(self, *, strict: bool = False) -> None: + self._strict = strict + + @property + def strict(self) -> bool: + """Reject (True) or pass through (False) unknown parameters. + + Inert on the native adapter: every known parameter maps into + its ``parameters`` bag, so nothing is ever "unknown". + """ + return self._strict + + @abstractmethod + def build_chat_request( + self, + *, + model: str, + messages: List[Dict[str, Any]], + stream: bool = 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, + extra_parameters: Optional[Dict[str, Any]] = None, + path: Optional[str] = None, + ) -> ChatRequest: + """Build the wire request (path, headers, body) for one call. + + ``path`` overrides the adapter's default endpoint; callers pass + ``MULTIMODAL_PATH`` when the messages carry media parts. + """ + + @abstractmethod + def parse_completion( + self, + payload: Dict[str, Any], + ) -> ChatCompletion: + """Translate a non-streaming response body.""" + + @abstractmethod + def parse_chunk( + self, + payload: Dict[str, Any], + ) -> 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, + ) -> 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. + """ + return self.parse_chunk diff --git a/dashscope/v2/protocols/native.py b/dashscope/v2/protocols/native.py new file mode 100644 index 00000000..59976a33 --- /dev/null +++ b/dashscope/v2/protocols/native.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Native DashScope wire protocol adapter.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union + +from dashscope.v2.protocols.base import ChatProtocol, ChatRequest +from dashscope.v2.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + parse_chunk, + parse_completion, +) + +__all__ = [ + "NativeProtocol", +] + + +# Media control hints travel as part-level siblings on the native wire +# (Qwen-VL/Omni); the native protocol is the only one that carries them. +_IMAGE_HINT_KEYS = ("min_pixels", "max_pixels") +_VIDEO_HINT_KEYS = ("fps", "min_pixels", "max_pixels", "total_pixels") +# Explicit context-cache marker: also a part-level sibling (the cached +# prefix ends at the marked content block), on text and multimodal +# parts alike. +_CACHE_CONTROL_KEYS = ("cache_control",) + + +def _copy_hints(part: Dict[str, Any], out: Dict[str, Any], keys) -> None: + for key in keys: + if part.get(key) is not None: + out[key] = part[key] + + +def _translate_part(part: Dict[str, Any]) -> Dict[str, Any]: + part_type = part.get("type") + if part_type == "text": + text: Dict[str, Any] = {"text": part.get("text", "")} + _copy_hints(part, text, _CACHE_CONTROL_KEYS) + return text + if part_type == "image": + image: Dict[str, Any] = {"image": part["url"]} + _copy_hints(part, image, _IMAGE_HINT_KEYS) + _copy_hints(part, image, _CACHE_CONTROL_KEYS) + return image + if part_type == "audio": + if part.get("url") is not None: + audio: Dict[str, Any] = {"audio": part["url"]} + _copy_hints(part, audio, _CACHE_CONTROL_KEYS) + return audio + # Inline base64 audio travels as a data: URI on the native wire. + data = part.get("data") + if data is None: + raise ValueError("audio part requires either 'url' or 'data'") + audio_format = part.get("format") + if audio_format is None: + raise ValueError( + "audio part 'format' is required with protocol='native' " + "when 'data' carries inline base64 audio", + ) + inline: Dict[str, Any] = { + "audio": f"data:audio/{audio_format};base64,{data}", + } + _copy_hints(part, inline, _CACHE_CONTROL_KEYS) + return inline + if part_type == "video": + if part.get("frames"): + video: Dict[str, Any] = {"video": part["frames"]} + else: + video = {"video": part.get("url")} + _copy_hints(part, video, _VIDEO_HINT_KEYS) + _copy_hints(part, video, _CACHE_CONTROL_KEYS) + return video + return part + + +def _translate_messages( + messages: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + translated = [] + for message in messages: + content = message.get("content") + if isinstance(content, list): + message = dict(message) + message["content"] = [_translate_part(p) for p in content] + translated.append(message) + return translated + + +class NativeProtocol(ChatProtocol): + """DashScope native protocol: /services/... + input/parameters body.""" + + name = "native" + + PATH = "/services/aigc/text-generation/generation" + MULTIMODAL_PATH = "/services/aigc/multimodal-generation/generation" + SSE_HEADERS = {"X-DashScope-SSE": "enable"} + + def build_chat_request( + self, + *, + model: str, + messages: List[Dict[str, Any]], + stream: bool = 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, + extra_parameters: Optional[Dict[str, Any]] = None, + path: Optional[str] = None, + ) -> ChatRequest: + parameters: Dict[str, Any] = {"result_format": "message"} + if stream: + parameters["incremental_output"] = True + for key, value in ( + ("temperature", temperature), + ("top_p", top_p), + ("max_tokens", max_tokens), + ("stop", stop), + ("seed", seed), + ("repetition_penalty", repetition_penalty), + ("presence_penalty", presence_penalty), + ("enable_thinking", enable_thinking), + ("response_format", response_format), + ("top_k", top_k), + ("tool_choice", tool_choice), + ("n", n), + ("logprobs", logprobs), + ("top_logprobs", top_logprobs), + ("thinking_budget", thinking_budget), + ("enable_search", enable_search), + ("search_options", search_options), + ("parallel_tool_calls", parallel_tool_calls), + ("tools", tools), + ): + if value is not None: + parameters[key] = value + if extra_parameters: + parameters.update(extra_parameters) + body = { + "model": model, + "input": {"messages": _translate_messages(messages)}, + "parameters": parameters, + } + headers = dict(self.SSE_HEADERS) if stream else {} + return ChatRequest(path=path or self.PATH, body=body, headers=headers) + + def parse_completion( + self, + payload: Dict[str, Any], + ) -> ChatCompletion: + return parse_completion(payload) + + def parse_chunk( + self, + payload: Dict[str, Any], + ) -> ChatCompletionChunk: + return parse_chunk(payload) diff --git a/dashscope/v2/protocols/openai.py b/dashscope/v2/protocols/openai.py new file mode 100644 index 00000000..4a2f1a68 --- /dev/null +++ b/dashscope/v2/protocols/openai.py @@ -0,0 +1,265 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""OpenAI-compatible protocol adapter (design doc §3.1). + +Translates the canonical chat models to ``/chat/completions`` requests +so ``Client(protocol="openai", base_url=...)`` can talk to any +OpenAI-compatible endpoint (Bailian compatible-mode, third-party +gateways). Unknown parameters pass through to the body unless +``strict=True``. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union + +from dashscope.v2.errors import DashScopeError +from dashscope.v2.protocols.base import ChatProtocol, ChatRequest +from dashscope.v2.types.completions import ( + ChatCompletion, + ChatCompletionChunk, +) + +__all__ = [ + "OpenAIProtocol", +] + + +_IMAGE_HINT_KEYS = ("min_pixels", "max_pixels") +_VIDEO_HINT_KEYS = ("fps", "min_pixels", "max_pixels", "total_pixels") +# Explicit context-cache marker rides as a part-level sibling on the +# compatible-mode wire (documented example: {"type": "text", "text": ..., +# "cache_control": {"type": "ephemeral"}}). +_CACHE_CONTROL_KEYS = ("cache_control",) + + +def _copy_cache_control( + part: Dict[str, Any], + out: Dict[str, Any], +) -> None: + for key in _CACHE_CONTROL_KEYS: + if part.get(key) is not None: + out[key] = part[key] + + +def _image_part(part: Dict[str, Any]) -> Dict[str, Any]: + image_url: Dict[str, Any] = {"url": part["url"]} + if part.get("detail"): + image_url["detail"] = part["detail"] + out: Dict[str, Any] = {"type": "image_url", "image_url": image_url} + # Qwen-VL/Omni resize hints ride as part-level siblings of the + # content item on the compatible-mode wire (documented example: + # {"type": "image_url", "image_url": {...}, "min_pixels": N}). + for key in _IMAGE_HINT_KEYS: + if part.get(key) is not None: + out[key] = part[key] + _copy_cache_control(part, out) + return out + + +def _audio_part(part: Dict[str, Any]) -> Dict[str, Any]: + # Qwen-Omni input_audio: ``data`` carries a public URL or inline + # base64 and ``format`` names the codec; both live inside the + # input_audio object on the compatible-mode wire. + input_audio: Dict[str, Any] = {} + if part.get("data") is not None: + input_audio["data"] = part["data"] + elif part.get("url") is not None: + input_audio["data"] = part["url"] + if not input_audio: + raise ValueError("audio part requires 'url' or 'data'") + if part.get("format") is not None: + input_audio["format"] = part["format"] + out: Dict[str, Any] = {"type": "input_audio", "input_audio": input_audio} + _copy_cache_control(part, out) + return out + + +def _video_part(part: Dict[str, Any]) -> Dict[str, Any]: + # A single file maps to video_url and a frame list to video; the + # fps/pixel hints are part-level siblings on the compatible-mode + # wire ({"type": "video_url", "video_url": {...}, "fps": 2}). + if part.get("frames"): + out: Dict[str, Any] = {"type": "video", "video": part["frames"]} + else: + out = {"type": "video_url", "video_url": {"url": part["url"]}} + for key in _VIDEO_HINT_KEYS: + if part.get(key) is not None: + out[key] = part[key] + _copy_cache_control(part, out) + return out + + +def _translate_part(part: Dict[str, Any]) -> Dict[str, Any]: + part_type = part.get("type") + if part_type == "text": + out: Dict[str, Any] = {"type": "text", "text": part.get("text", "")} + _copy_cache_control(part, out) + return out + if part_type == "image": + return _image_part(part) + if part_type == "audio": + return _audio_part(part) + if part_type == "video": + return _video_part(part) + raise ValueError( + f"content part {part_type!r} has no OpenAI equivalent", + ) + + +def _translate_message(message: Dict[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = {"role": message["role"]} + content = message.get("content") + if isinstance(content, list): + content = [_translate_part(p) for p in content] + out["content"] = content + for key in ("name", "tool_call_id"): + if message.get(key) is not None: + out[key] = message[key] + if message.get("tool_calls"): + out["tool_calls"] = message["tool_calls"] + # Reasoning models require prior turns' reasoning_content + # echoed back; only assistant turns carry it. + if message["role"] == "assistant" and message.get("reasoning_content"): + out["reasoning_content"] = message["reasoning_content"] + return out + + +# Renamed into the canonical counters, so the same number is not also +# readable as an extra under its wire spelling. +_USAGE_RENAMED = ("prompt_tokens", "completion_tokens") + + +def _translate_usage(usage: Optional[Dict[str, Any]]) -> Dict[str, Any]: + usage = usage or {} + # Only the canonical counters are translated; every other field the + # gateway reports (``prompt_tokens_details.cached_tokens``, + # ``completion_tokens_details.reasoning_tokens``, ...) reaches ``Usage`` + # as an extra. ``Usage`` is ``extra="allow"`` and the native adapter + # already hands its usage bag over untouched, so reducing the bag here + # would silently drop billing detail the caller cannot recover. + translated = { + key: value for key, value in usage.items() if key not in _USAGE_RENAMED + } + 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"], + ) + return translated + + +class OpenAIProtocol(ChatProtocol): + """Adapter for ``POST /chat/completions`` (OpenAI-compatible).""" + + name = "openai" + PATH = "/chat/completions" + + def build_chat_request( + self, + *, + model: str, + messages: List[Dict[str, Any]], + stream: bool = 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, + extra_parameters: Optional[Dict[str, Any]] = None, + path: Optional[str] = None, + ) -> ChatRequest: + body: Dict[str, Any] = { + "model": model, + "messages": [_translate_message(m) for m in messages], + } + if stream: + body["stream"] = True + # OpenAI-compatible servers omit usage from streams unless + # asked; extra_parameters can still override this. + body["stream_options"] = {"include_usage": True} + for key, value in ( + ("temperature", temperature), + ("top_p", top_p), + ("max_tokens", max_tokens), + ("stop", stop), + ("seed", seed), + ("presence_penalty", presence_penalty), + ("response_format", response_format), + ("tools", tools), + ("tool_choice", tool_choice), + ("n", n), + ("logprobs", logprobs), + ("top_logprobs", top_logprobs), + ("parallel_tool_calls", parallel_tool_calls), + # DashScope extensions Bailian compatible-mode understands; + # named SDK params, so strict must not reject them. The search + # runs server-side, but the compatible wire returns no search + # sources, so search_info stays None on this protocol (the + # native protocol is where it is parsed). + ("top_k", top_k), + ("thinking_budget", thinking_budget), + ("enable_search", enable_search), + ("search_options", search_options), + ): + if value is not None: + body[key] = value + unknown: Dict[str, Any] = {} + if repetition_penalty is not None: + unknown["repetition_penalty"] = repetition_penalty + if enable_thinking is not None: + unknown["enable_thinking"] = enable_thinking + if extra_parameters: + unknown.update(extra_parameters) + if unknown: + if self._strict: + raise DashScopeError( + "unknown OpenAI parameters in strict mode: " + f"{sorted(unknown)}; drop them or use strict=False", + ) + 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_chunk(self, payload: Dict[str, Any]) -> ChatCompletionChunk: + choices = [] + for raw in payload.get("choices") or []: + raw = dict(raw) + raw.setdefault("delta", {}) + choices.append(raw) + return ChatCompletionChunk( + request_id=payload.get("request_id") or payload.get("id"), + model=payload.get("model"), + choices=choices, + usage=( + _translate_usage(payload["usage"]) + if payload.get("usage") + else None + ), + ) diff --git a/dashscope/v2/resources/__init__.py b/dashscope/v2/resources/__init__.py new file mode 100644 index 00000000..1d776d10 --- /dev/null +++ b/dashscope/v2/resources/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SDK resources (chat, embeddings, files, ...).""" diff --git a/dashscope/v2/resources/_agentstudio.py b/dashscope/v2/resources/_agentstudio.py new file mode 100644 index 00000000..9b7665c4 --- /dev/null +++ b/dashscope/v2/resources/_agentstudio.py @@ -0,0 +1,397 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Transparent v2 error-alignment proxy for the mounted AgentStudio sub-SDK. + +Design doc M3 task 3.12. ``client.agentstudio`` lazily mounts the 1.x +AgentStudio sub-SDK, which keeps its own exception family rooted at +``dashscope.common.error.DashScopeException``. That family shares no +lineage with ``dashscope.v2.errors.DashScopeError``, so a user writing +``except dashscope.v2.errors.DashScopeError`` would miss every failure +the mounted sub-SDK raises. + +This module closes that gap at the *mount boundary only*: it wraps the +constructed AgentStudio client in a transparent proxy that catches +``AgentStudioError`` and re-raises the equivalent v2 error. No 1.x code +is touched, and the standalone ``dashscope.agentstudio.Client`` keeps its +native error contract -- only the v2 mount is translated. + +Two-branch translation (design doc §7): + +* HTTP-layer errors (``APIStatusError`` and its subclasses) carry a real + ``status_code``; they belong to the status map, not ``SDK_ERRORS``, so + they go through :func:`~dashscope.v2.errors.error_from_status`. +* Local / stream errors that have no HTTP response behind them + (``APIConnectionError`` / ``APITimeoutError`` / ``StreamError`` / + ``StreamClosedError``) go through + :func:`~dashscope.v2.errors.error_from_sdk_exception` with family + ``sdk.agentstudio``. The registry carries exactly these four and, by + design, no ``sdk.agentstudio.Error`` family fallback, so any other + non-HTTP class degrades to a v2 ``APIConnectionError`` rather than let + the two-tier lookup raise ``KeyError`` and mask the original failure. + +The proxy is transparent (``__class__`` spoofing plus attribute +forwarding) so ``isinstance(client.agentstudio, AgentStudioClient)``, +``.transport`` access and the context-manager protocol keep working. + +Return values whose errors surface *after* the call are guarded too: +event streams raise on enter/iterate, and cursor pages raise on +auto-pagination and explicit ``get_next()`` (their ``fetch_next`` +closure captures the raw 1.x resource method, bypassing the method +guards), so both are wrapped to keep late failures translated. +""" + +from __future__ import annotations + +import asyncio +import functools +from typing import Any, Dict, Optional + +from dashscope.agentstudio import exceptions as _as_exceptions +from dashscope.agentstudio.pagination import ( + AsyncCursorPage as _AsyncCursorPage, +) +from dashscope.agentstudio.pagination import CursorPage as _CursorPage +from dashscope.v2 import errors as _v2_errors + +_AGENTSTUDIO_FAMILY = "sdk.agentstudio" + +# The only AgentStudio classes registered under ``sdk.agentstudio.*`` in +# the error catalog. Everything else non-HTTP has no entry and no family +# fallback, so it must not reach ``error_from_sdk_exception``. +_SDK_ERROR_CLASSES = frozenset( + { + "APIConnectionError", + "APITimeoutError", + "StreamError", + "StreamClosedError", + }, +) + +# Resource classes (and the typed event streams) live under this package; +# attribute access recurses into them so nested method calls stay guarded. +_RESOURCE_PACKAGE = "dashscope.agentstudio.resources" + +# AgentStudio raises a few HTTP-family errors locally, without a response, +# so they carry no ``status_code`` (the missing-``api_key`` guard in +# ``transport.build_headers`` is the live example). Recover the status the +# class stands for so ``error_from_status`` still picks the right v2 class +# instead of degrading to a 500. Values mirror agentstudio's own +# ``_STATUS_TO_DEFAULT``; only the class's canonical status is kept. +_STATUS_BY_CLASS: Dict[type, int] = { + _as_exceptions.InvalidRequestError: 400, + _as_exceptions.AuthenticationError: 401, + _as_exceptions.PermissionDeniedError: 403, + _as_exceptions.NotFoundError: 404, + _as_exceptions.ConflictError: 409, + _as_exceptions.RateLimitError: 429, + _as_exceptions.OverloadedError: 503, + _as_exceptions.InternalServerError: 500, +} + + +def _effective_status(exc: _as_exceptions.AgentStudioError) -> int: + """The HTTP status to map on, recovering it from the class if absent.""" + + status = getattr(exc, "status_code", None) + if status is not None: + return status + for cls in type(exc).__mro__: + if cls in _STATUS_BY_CLASS: + return _STATUS_BY_CLASS[cls] + return 500 + + +def translate_agentstudio_error( + exc: _as_exceptions.AgentStudioError, +) -> _v2_errors.DashScopeError: + """Map a 1.x AgentStudio exception into the v2 error family.""" + + status = getattr(exc, "status_code", None) + if isinstance(exc, _as_exceptions.APIStatusError) or status is not None: + # HTTP layer: keep the server's real status and let the catalog + # status map pick the class. agentstudio codes are CMA-shaped + # (``rate_limit_error``), not gateway wire codes, so the map keys + # off ``status_code``; the raw body still supplies message/code. + request_id = getattr(exc, "request_id", None) + headers = {"x-request-id": request_id} if request_id else None + return _v2_errors.error_from_status( + _effective_status(exc), + message=getattr(exc, "message", None) or str(exc), + body=getattr(exc, "raw", None), + headers=headers, + ) + + if type(exc).__name__ in _SDK_ERROR_CLASSES: + return _v2_errors.error_from_sdk_exception( + exc, + family=_AGENTSTUDIO_FAMILY, + ) + + # Unregistered non-HTTP class (in practice only the bare base class): + # surface it as a connection error instead of a masking KeyError. + return _v2_errors.APIConnectionError( + getattr(exc, "message", None) or str(exc), + request_id=getattr(exc, "request_id", None), + ) + + +def _is_resource(obj: Any) -> bool: + return type(obj).__module__.startswith(_RESOURCE_PACKAGE) + + +def _is_sync_stream(obj: Any) -> bool: + # CursorPage is iterable but not a context manager, so requiring both + # keeps pagination results out of the stream proxy; they get their + # own guard in ``_wrap_result``. + return hasattr(obj, "__enter__") and hasattr(obj, "__iter__") + + +def _is_async_stream(obj: Any) -> bool: + return hasattr(obj, "__aenter__") and hasattr(obj, "__aiter__") + + +def _wrap_result(result: Any) -> Any: + """Guard a method's return value if its failures surface lazily. + + Streams raise on enter/iterate; cursor pages raise while iterating + (auto-pagination) and on explicit ``get_next()``. Plain values pass + through untouched. + """ + + if isinstance(result, _AsyncCursorPage): + return _AsyncPageProxy(result) + if isinstance(result, _CursorPage): + return _SyncPageProxy(result) + if _is_async_stream(result): + return _AsyncStreamProxy(result) + if _is_sync_stream(result): + return _SyncStreamProxy(result) + return result + + +def _guard_sync(func: Any) -> Any: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + result = func(*args, **kwargs) + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + return _wrap_result(result) + + return wrapper + + +def _guard_async(func: Any) -> Any: + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + result = await func(*args, **kwargs) + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + return _wrap_result(result) + + return wrapper + + +class _SyncStreamProxy: + """Guards a sync event stream whose errors surface on enter/iterate.""" + + def __init__(self, target: Any) -> None: + object.__setattr__(self, "_target", target) + + @property # type: ignore[misc] + def __class__(self) -> type: # noqa: D105 - transparency helper + return type(object.__getattribute__(self, "_target")) + + def __getattr__(self, name: str) -> Any: + return getattr(object.__getattribute__(self, "_target"), name) + + def __enter__(self) -> "_SyncStreamProxy": + target = object.__getattribute__(self, "_target") + try: + target.__enter__() + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + return self + + def __exit__(self, *exc_info: Any) -> Optional[bool]: + target = object.__getattribute__(self, "_target") + return target.__exit__(*exc_info) + + def __iter__(self) -> Any: + target = object.__getattribute__(self, "_target") + try: + for item in target: + yield item + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + +class _AsyncStreamProxy: + """Guards an async event stream whose errors surface on enter/iterate.""" + + def __init__(self, target: Any) -> None: + object.__setattr__(self, "_target", target) + + @property # type: ignore[misc] + def __class__(self) -> type: # noqa: D105 - transparency helper + return type(object.__getattribute__(self, "_target")) + + def __getattr__(self, name: str) -> Any: + return getattr(object.__getattribute__(self, "_target"), name) + + async def __aenter__(self) -> "_AsyncStreamProxy": + target = object.__getattribute__(self, "_target") + try: + await target.__aenter__() + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + return self + + async def __aexit__(self, *exc_info: Any) -> Optional[bool]: + target = object.__getattribute__(self, "_target") + return await target.__aexit__(*exc_info) + + async def __aiter__(self) -> Any: + target = object.__getattribute__(self, "_target") + try: + async for item in target: + yield item + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + +class _SyncPageProxy: + """Guards a cursor page whose fetches surface on iterate/get_next. + + Iterating the raw page auto-paginates through the unguarded + ``fetch_next`` closure, so both dunder iteration and the explicit + ``get_next()`` need the translation here. + """ + + def __init__(self, target: Any) -> None: + object.__setattr__(self, "_target", target) + + @property # type: ignore[misc] + def __class__(self) -> type: # noqa: D105 - transparency helper + return type(object.__getattribute__(self, "_target")) + + def __getattr__(self, name: str) -> Any: + return getattr(object.__getattribute__(self, "_target"), name) + + def __len__(self) -> int: + return len(object.__getattribute__(self, "_target")) + + def get_next(self) -> Any: + target = object.__getattribute__(self, "_target") + try: + return _wrap_result(target.get_next()) + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + def __iter__(self) -> Any: + target = object.__getattribute__(self, "_target") + try: + for item in target: + yield item + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + +class _AsyncPageProxy: + """Async twin of :class:`_SyncPageProxy`.""" + + def __init__(self, target: Any) -> None: + object.__setattr__(self, "_target", target) + + @property # type: ignore[misc] + def __class__(self) -> type: # noqa: D105 - transparency helper + return type(object.__getattribute__(self, "_target")) + + def __getattr__(self, name: str) -> Any: + return getattr(object.__getattribute__(self, "_target"), name) + + def __len__(self) -> int: + return len(object.__getattribute__(self, "_target")) + + async def get_next(self) -> Any: + target = object.__getattribute__(self, "_target") + try: + return _wrap_result(await target.get_next()) + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + def __aiter__(self) -> Any: + return self._aiter() + + async def _aiter(self) -> Any: + target = object.__getattribute__(self, "_target") + try: + async for item in target: + yield item + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + +class AgentStudioProxy: + """Transparent recursive proxy over an AgentStudio client or resource. + + Attribute access returns a guarded callable for methods, a nested + proxy for sub-resources, and the raw value for everything else (so + ``.transport`` and returned model objects pass through untouched). + """ + + def __init__(self, target: Any) -> None: + object.__setattr__(self, "_target", target) + object.__setattr__(self, "_cache", {}) + + @property # type: ignore[misc] + def __class__(self) -> type: # noqa: D105 - transparency helper + return type(object.__getattribute__(self, "_target")) + + def __getattr__(self, name: str) -> Any: + target = object.__getattribute__(self, "_target") + cache = object.__getattribute__(self, "_cache") + if name in cache: + return cache[name] + value = getattr(target, name) + if callable(value): + wrapped = ( + _guard_async(value) + if asyncio.iscoroutinefunction(value) + else _guard_sync(value) + ) + elif _is_resource(value): + wrapped = AgentStudioProxy(value) + else: + wrapped = value + cache[name] = wrapped + return wrapped + + def __setattr__(self, name: str, value: Any) -> None: + setattr(object.__getattribute__(self, "_target"), name, value) + + def __repr__(self) -> str: # pragma: no cover - debug helper + return repr(object.__getattribute__(self, "_target")) + + def __enter__(self) -> "AgentStudioProxy": + object.__getattribute__(self, "_target").__enter__() + return self + + def __exit__(self, *exc_info: Any) -> Optional[bool]: + target = object.__getattribute__(self, "_target") + return target.__exit__(*exc_info) + + async def __aenter__(self) -> "AgentStudioProxy": + await object.__getattribute__(self, "_target").__aenter__() + return self + + async def __aexit__(self, *exc_info: Any) -> Optional[bool]: + target = object.__getattribute__(self, "_target") + return await target.__aexit__(*exc_info) + + +def wrap_agentstudio(client: Any) -> AgentStudioProxy: + """Wrap a constructed AgentStudio client in the v2 translation proxy.""" + + return AgentStudioProxy(client) diff --git a/dashscope/v2/resources/_jobs.py b/dashscope/v2/resources/_jobs.py new file mode 100644 index 00000000..9d7d3ed2 --- /dev/null +++ b/dashscope/v2/resources/_jobs.py @@ -0,0 +1,625 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Unified async-task (Job) infrastructure (design doc M3 3.2). + +Long-running services (images, videos, transcription, ...) share the +``X-DashScope-Async`` pattern: submit returns a ``task_id``, then the +task is fetched from ``/tasks/{task_id}`` until it reaches a terminal +status. ``Jobs`` / ``AsyncJobs`` wrap that pattern with exponential +backoff polling; resources mount them as ``jobs.create/fetch/wait``. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any, Awaitable, Callable, Dict, List, Optional, Union + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from dashscope.v2.errors import ( + DashScopeError, + check_response, + error_from_business_code, +) +from dashscope.v2.types.common import ( + ResponseBase, + Usage, + flatten_envelope, + response_envelope, + with_headers, +) + +__all__ = [ + "AsyncJobs", + "CANCELED", + "FAILED", + "Job", + "JobList", + "JobResult", + "Jobs", + "PENDING", + "RUNNING", + "SUCCEEDED", + "UNKNOWN", +] + +PENDING = "PENDING" +RUNNING = "RUNNING" +SUCCEEDED = "SUCCEEDED" +FAILED = "FAILED" +CANCELED = "CANCELED" +UNKNOWN = "UNKNOWN" + +# UNKNOWN counts as terminal even though it reads like an indeterminate +# state: the gateway answers ``GET /tasks/{task_id}`` with HTTP 200 and +# ``task_status: UNKNOWN`` for a task that does not exist (or is not +# visible to this api key/workspace), so it never resolves. Treating it as +# non-terminal would make ``wait()`` re-poll a permanently-dead id until +# the timeout — 24h by default — and then report it as a wait timeout, +# hiding the real cause. 1.x stops on it for the same reason +# (client/base_api.py lists TaskStatus.UNKNOWN alongside SUCCEEDED / +# FAILED / CANCELED); the fine-tune plane deliberately does not, and +# ``fine_tunes._TERMINAL`` mirrors that narrower 1.x rule. +_TERMINAL = frozenset({SUCCEEDED, FAILED, CANCELED, UNKNOWN}) + +ASYNC_HEADER = {"X-DashScope-Async": "enable"} + + +def _image_choices(output: Dict[str, Any]) -> List[Dict[str, Any]]: + """Image parts of a choices-style output (multimodal-generation). + + Image models on the multimodal-generation protocol answer with + chat-style ``choices`` instead of ``results``; lift every ``image`` + part so the typed views (``results`` / ``image_urls``) read the same + for both output shapes. + """ + results: List[Dict[str, Any]] = [] + for choice in output.get("choices") or []: + if not isinstance(choice, dict): + continue + message = choice.get("message") or {} + for part in message.get("content") or []: + if isinstance(part, dict) and part.get("image"): + results.append({"url": part["image"]}) + return results + + +DEFAULT_POLL_INTERVAL = 1.0 +DEFAULT_MAX_INTERVAL = 8.0 +# 1.x waits indefinitely; 2.0 caps the default at 24h so a stuck task +# cannot hang a process forever (recorded for the M7 migration guide). +DEFAULT_WAIT_TIMEOUT = 86400.0 # 24 hours + + +class JobResult(BaseModel): + """One artifact a finished task produced. + + Image tasks answer with ``url``, transcription tasks with + ``transcription_url``; anything else a service adds stays readable as an + attribute. + """ + + model_config = ConfigDict(extra="allow") + + url: Optional[str] = None + transcription_url: Optional[str] = None + + +class Job(ResponseBase): + """One async task snapshot (submit or fetch result).""" + + task_id: str = "" + status: str = Field( + "", + validation_alias=AliasChoices("status", "task_status"), + ) + submitted_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("submitted_at", "submit_time"), + ) + scheduled_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("scheduled_at", "scheduled_time"), + ) + finished_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("finished_at", "end_time"), + ) + # Raw task payload: result fields differ per service, so the dict stays. + # ``results`` / ``image_urls`` / ``video_url`` / ``result_url`` are the + # typed views over it. + output: Optional[Dict[str, Any]] = None + usage: Optional[Usage] = None + + @property + def job_id(self) -> str: + """``FineTune`` spells the same identifier ``job_id``.""" + return self.task_id + + @property + def succeeded(self) -> bool: + return self.status == SUCCEEDED + + @property + def finished(self) -> bool: + return self.status in _TERMINAL + + @property + def results(self) -> List[JobResult]: + items = (self.output or {}).get("results") or [] + if not items: + items = _image_choices(self.output or {}) + return [JobResult(**item) for item in items if isinstance(item, dict)] + + @property + def image_urls(self) -> List[str]: + return [result.url for result in self.results if result.url] + + @property + def video_url(self) -> Optional[str]: + return (self.output or {}).get("video_url") + + @property + def result_url(self) -> Optional[str]: + """The single artifact of a one-result task.""" + urls = self.image_urls + if urls: + return urls[0] + output = self.output or {} + return output.get("url") or self.video_url + + @classmethod + def from_payload(cls, body: Dict[str, Any]) -> "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) + + +class JobList(ResponseBase): + """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: int = 1 + page_size: int = 10 + total: int = 0 + tasks: List[Job] = [] + + @classmethod + def from_payload(cls, body: Dict[str, Any]) -> "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 [] + 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), + ) + + +def _list_params( + start_time: Optional[str], + end_time: Optional[str], + model_name: Optional[str], + api_key_id: Optional[str], + region: Optional[str], + status: Optional[str], + page_no: int, + page_size: int, +) -> Dict[str, Any]: + """Query params of 1.x ``GET /tasks``: pagination always, filters when set.""" + params: Dict[str, Any] = {"page_no": page_no, "page_size": page_size} + for key, value in ( + ("start_time", start_time), + ("end_time", end_time), + ("model_name", model_name), + ("api_key_id", api_key_id), + ("region", region), + ("status", status), + ): + if value is not None: + params[key] = value + return params + + +class Jobs: + """Sync async-task helper for one service (images, videos, ...).""" + + def __init__( + self, + client, + service: str, + module: Optional[str] = None, + ) -> None: + self._client = client + self._service = service + # Functional-area tag for x-dashscope-sdk-client (see _ident); + # the resource that mounts this helper passes its own module. + self._module = module + + @property + def submit_path(self) -> str: + return f"/services/{self._service}" + + def create( # pylint: disable=redefined-builtin + self, + *, + model: str, + input: Dict[str, Any], + parameters: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + resources: Optional[List[Dict[str, Any]]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Submit a task; returns immediately with a PENDING/RUNNING Job.""" + body: Dict[str, Any] = {"model": model, "input": input} + if parameters: + body["parameters"] = parameters + if resources is not None: + body["resources"] = resources + submit_headers = dict(ASYNC_HEADER) + if headers: + submit_headers.update(headers) + response = self._client.transport.request( + "POST", + self.submit_path, + json=body, + headers=submit_headers, + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return self._parse(response) + + def fetch( + self, + task_id: str, + *, + 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( + "GET", + f"/tasks/{task_id}", + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return self._parse(response) + + def cancel( + self, + task_id: str, + *, + 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( + "POST", + f"/tasks/{task_id}/cancel", + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return self._parse(response) + + 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, + ) -> JobList: + """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" + response = self._client.transport.request( + "GET", + "/tasks", + params=_list_params( + start_time, + end_time, + model_name, + api_key_id, + region, + status, + page_no, + page_size, + ), + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(JobList.from_payload(response.json()), response) + + def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Any]] = None, + ) -> Job: + """Poll with exponential backoff until the task finishes. + + With ``raise_on_error=False`` a terminal FAILED/CANCELED task (or + a transport-level failure) comes back as the :class:`Job` envelope + instead of raising; check ``job.status`` / ``job.is_error``. + + An ``UNKNOWN`` status is terminal too and is returned, not raised: + the gateway reports it for a task id that does not exist, so + polling again could never resolve it. Callers that care about the + difference between "succeeded" and "no such task" should check + ``job.succeeded`` rather than relying on ``wait()`` to raise. + + ``_sleep`` is a test seam — it replaces the pause between polls. + """ + task_id = job.task_id if isinstance(job, Job) else job + sleep_fn = _sleep or time.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, Job) + else self.fetch(task_id, api_key=api_key, workspace=workspace) + ) + interval = poll_interval + while not current.finished: + # Compat mode (raise_on_error=False): a transport-level failure + # comes back as an error envelope with no task status, which + # would otherwise poll until the timeout. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + sleep_fn(min(interval, remaining)) + interval = min(interval * 2, max_interval) + current = self.fetch(task_id, api_key=api_key, workspace=workspace) + return self._check_canceled(current) + + def _parse(self, response: Any) -> Job: + payload = response.json() + job = with_headers(Job.from_payload(payload), 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", + code=job.code, + body=payload, + headers=response.headers, + ) + return job + + def _check_canceled(self, job: Job) -> Job: + if job.status == CANCELED and self._client.raise_on_error: + raise DashScopeError( + f"task {job.task_id} was canceled while waiting", + request_id=job.request_id, + ) + return job + + +class AsyncJobs: + """Async async-task helper for one service (images, videos, ...).""" + + def __init__( + self, + client, + service: str, + module: Optional[str] = None, + ) -> None: + self._client = client + self._service = service + self._module = module + + @property + def submit_path(self) -> str: + return f"/services/{self._service}" + + async def create( # pylint: disable=redefined-builtin + self, + *, + model: str, + input: Dict[str, Any], + parameters: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + resources: Optional[List[Dict[str, Any]]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + body: Dict[str, Any] = {"model": model, "input": input} + if parameters: + body["parameters"] = parameters + if resources is not None: + body["resources"] = resources + submit_headers = dict(ASYNC_HEADER) + if headers: + submit_headers.update(headers) + response = await self._client.transport.request( + "POST", + self.submit_path, + json=body, + headers=submit_headers, + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return self._parse(response) + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + response = await self._client.transport.request( + "GET", + f"/tasks/{task_id}", + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return self._parse(response) + + async def cancel( + self, + task_id: str, + *, + 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( + "POST", + f"/tasks/{task_id}/cancel", + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return self._parse(response) + + 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, + ) -> JobList: + """List async tasks (1.x: ``GET /tasks`` with page_no/page_size).""" + response = await self._client.transport.request( + "GET", + "/tasks", + params=_list_params( + start_time, + end_time, + model_name, + api_key_id, + region, + status, + page_no, + page_size, + ), + timeout=timeout, + sdk_module=self._module, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(JobList.from_payload(response.json()), response) + + async def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = 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 = ( + job + if isinstance(job, Job) + else await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + ) + ) + interval = poll_interval + while not current.finished: + # Compat mode (raise_on_error=False): a transport-level failure + # comes back as an error envelope with no task status, which + # would otherwise poll until the timeout. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + await sleep_fn(min(interval, remaining)) + interval = min(interval * 2, max_interval) + current = await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + ) + return self._check_canceled(current) + + def _parse(self, response: Any) -> Job: + payload = response.json() + job = with_headers(Job.from_payload(payload), 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", + code=job.code, + body=payload, + headers=response.headers, + ) + return job + + def _check_canceled(self, job: Job) -> Job: + if job.status == CANCELED and self._client.raise_on_error: + raise DashScopeError( + f"task {job.task_id} was canceled while waiting", + request_id=job.request_id, + ) + return job diff --git a/dashscope/v2/resources/_oss.py b/dashscope/v2/resources/_oss.py new file mode 100644 index 00000000..2235e4a0 --- /dev/null +++ b/dashscope/v2/resources/_oss.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Local-file → OSS upload preprocessing for media inputs. + +DashScope serves media (image / audio / video) from URLs; a local path or +``file://`` URI must be uploaded to OSS first and the request opted into +OSS resolution. This reuses the 1.x uploader (``dashscope.utils.oss_utils``) +so 2.0 keeps the same upload behavior instead of re-implementing it. + +Two things are anchored to the v2 client rather than to 1.x globals: + +* The upload certificate is fetched from the client's own ``base_url`` + (``base_address=`` below), so a region- or gateway-scoped client uploads + to the matching regional OSS and the returned ``oss://`` URL resolves + under the same endpoint the inference request is sent to. The 1.x global + endpoint would otherwise pin every upload to the default region. +* The effective per-call ``api_key`` / ``workspace`` (the override, else the + client default) drive both the certificate fetch and the upload, so the + asset lands in the same account the request runs under. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from dashscope.utils.oss_utils import check_and_upload_local +from dashscope.v2.types.messages import ( + AudioPart, + ImagePart, + Message, + VideoPart, +) + +__all__ = [ + "OSS_RESOLVE_HEADERS", + "upload_embedding_media", + "upload_media_value", + "upload_message_media", +] + +# The service only resolves oss:// inputs when the request opts in. +OSS_RESOLVE_HEADERS = {"X-DashScope-OssResourceResolve": "enable"} + +_EMBEDDING_MEDIA_KEYS = ("image", "audio", "video") + + +def _effective( + client, + api_key: Optional[str], + workspace: Optional[str], +) -> Tuple[Optional[str], Optional[str], str]: + """Resolve the per-call override against the client defaults. + + Returns ``(api_key, workspace, base_url)``. The certificate fetch and + the upload both run under these, so the asset lands in the same + account/region the request is sent to. + """ + return ( + api_key or client.config.api_key, + workspace or client.config.workspace, + client.config.base_url, + ) + + +def upload_message_media( + client, + messages: List[Message], + model: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, +) -> bool: + """Upload local/oss refs in chat media parts in place. + + Returns True when any part holds an ``oss://`` URL afterwards (uploaded + or already remote), so the caller opts the request into OSS resolution. + """ + key, space, base_url = _effective(client, api_key, workspace) + has_upload = False + cert: Optional[dict] = None + for message in messages: + content = message.content + if not isinstance(content, list): + continue + for part in content: + if isinstance(part, (ImagePart, AudioPart)): + if part.url is None: + # Inline base64 audio has no URL to upload. + continue + is_up, new, cert = check_and_upload_local( + model, + part.url, + key, + cert, + base_address=base_url, + workspace=space, + ) + if is_up: + part.url = new + has_upload = True + elif isinstance(part, VideoPart): + if part.url is not None: + is_up, new, cert = check_and_upload_local( + model, + part.url, + key, + cert, + base_address=base_url, + workspace=space, + ) + if is_up: + part.url = new + has_upload = True + if part.frames: + frames = [] + for frame in part.frames: + is_up, new, cert = check_and_upload_local( + model, + frame, + key, + cert, + base_address=base_url, + workspace=space, + ) + has_upload = has_upload or is_up + frames.append(new) + part.frames = frames + return has_upload + + +def _upload_value( + value: Any, + model: str, + api_key: Optional[str], + workspace: Optional[str], + base_url: str, + cert: Optional[dict], +) -> Tuple[Any, bool, Optional[dict]]: + if isinstance(value, str): + is_up, new, cert = check_and_upload_local( + model, + value, + api_key, + cert, + base_address=base_url, + workspace=workspace, + ) + return new, is_up, cert + if isinstance(value, list): + has_upload = False + out = [] + for item in value: + new_item, is_up, cert = _upload_value( + item, + model, + api_key, + workspace, + base_url, + cert, + ) + has_upload = has_upload or is_up + out.append(new_item) + return out, has_upload, cert + if isinstance(value, dict): + has_upload = False + out = {} + for key, item in value.items(): + new_item, is_up, cert = _upload_value( + item, + model, + api_key, + workspace, + base_url, + cert, + ) + has_upload = has_upload or is_up + out[key] = new_item + return out, has_upload, cert + return value, False, cert + + +def upload_media_value( + client, + value: Any, + model: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, +) -> Tuple[Any, bool]: + """Recursively upload local/oss refs in a media container. + + Walks strings, lists and dicts (``image_urls`` / ``media`` / + ``extra_input``); each string goes through the 1.x uploader, which only + rewrites ``file://`` URIs, existing local paths and ``oss://`` URLs and + passes http(s) and other strings through unchanged. + """ + key, space, base_url = _effective(client, api_key, workspace) + new_value, has_upload, _ = _upload_value( + value, + model, + key, + space, + base_url, + None, + ) + return new_value, has_upload + + +def upload_embedding_media( + client, + contents: List[Dict[str, Any]], + model: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, +) -> None: + """Upload the image/audio/video field of each embedding item in place.""" + key, space, base_url = _effective(client, api_key, workspace) + cert: Optional[dict] = None + for item in contents: + for field_key in _EMBEDDING_MEDIA_KEYS: + field = item.get(field_key) + if isinstance(field, str) and field: + is_up, new, cert = check_and_upload_local( + model, + field, + key, + cert, + base_address=base_url, + workspace=space, + ) + if is_up: + item[field_key] = new diff --git a/dashscope/v2/resources/_sse.py b/dashscope/v2/resources/_sse.py new file mode 100644 index 00000000..d30104ee --- /dev/null +++ b/dashscope/v2/resources/_sse.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SSE framing shared by the job-event streams (fine-tunes, deployments). + +Wire format mirrors 1.x ``StreamEventMixin`` (client/base_api.py): an +``event:error`` line flips the error flag, ``status:`` carries the mapped +HTTP status (optional), and ``data:`` lines carry the payload; an error +frame ends the stream. +""" + +from __future__ import annotations + +import json +from typing import Any, AsyncIterator, Iterator, Optional + +from dashscope.v2.errors import ( + DashScopeError, + error_from_business_code, + error_from_status, +) + +__all__ = [] + + +def _try_json(data: str) -> Any: + try: + return json.loads(data) + except (TypeError, ValueError): + return data + + +def _stream_error(status_code: Optional[int], data: str) -> DashScopeError: + """Build the error for one ``event:error`` frame. + + ``status:`` is optional on the wire. Absent, the frame is a business + failure carried on an HTTP 200 stream -- not a server 500 that nothing + reported -- so the body's own ``code`` picks the class. + """ + body = _try_json(data) + message = data + if isinstance(body, dict): + message = body.get("message") or data + if status_code is None: + return error_from_business_code(message=message, body=body) + return error_from_status(status_code, message=message, body=body) + + +def _iter_sse(response) -> Iterator[Any]: + is_error = False + status_code: Optional[int] = None + for line in response.iter_lines(): + if not line: + continue + if line.startswith("event:error"): + is_error = True + elif line.startswith("status:"): + status_code = int(line[len("status:") :].strip()) + elif line.startswith("data:"): + yield is_error, status_code, line[len("data:") :].strip() + if is_error: + break + + +async def _aiter_sse(response) -> AsyncIterator[Any]: + # Async twin of ``_iter_sse``; the framing rules are identical. + is_error = False + status_code: Optional[int] = None + async for line in response.aiter_lines(): + if not line: + continue + if line.startswith("event:error"): + is_error = True + elif line.startswith("status:"): + status_code = int(line[len("status:") :].strip()) + elif line.startswith("data:"): + yield is_error, status_code, line[len("data:") :].strip() + if is_error: + break diff --git a/dashscope/v2/resources/apps.py b/dashscope/v2/resources/apps.py new file mode 100644 index 00000000..58025941 --- /dev/null +++ b/dashscope/v2/resources/apps.py @@ -0,0 +1,572 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.apps resource (design doc M3 3.8). + +Bailian application completion on the v2 transport: +``POST /apps/{app_id}/completion``; streaming uses the DashScope SSE +convention (``X-DashScope-SSE: enable`` header + ``data:`` frames). +""" + +from __future__ import annotations + +import copy +import json +from typing import ( + Any, + AsyncIterator, + Dict, + Iterator, + List, + Optional, + Union, +) + +from pydantic import BaseModel, ConfigDict + +from dashscope.v2.errors import ( + check_response, + error_from_business_code, +) +from dashscope.v2.types.common import ( + ResponseBase, + Usage, + flatten_envelope, + with_headers, +) + +__all__ = [ + "AppCompletion", + "AppCompletions", + "AppModelUsage", + "AppStream", + "AppUsage", + "Apps", + "AsyncAppCompletions", + "AsyncAppStream", + "AsyncApps", + "DocReference", + "Thought", + "WorkflowMessage", +] + +_SSE_HEADER = {"X-DashScope-SSE": "enable"} + +MODULE = "app" + + +class Thought(BaseModel): + """One planning or tool-call step; set ``has_thoughts`` to get them.""" + + model_config = ConfigDict(extra="allow") + + thought: Optional[str] = None + action_type: Optional[str] = None + action_name: Optional[str] = None + action: Optional[str] = None + action_input: Optional[Dict[str, Any]] = None + action_input_stream: Optional[str] = None + response: Optional[str] = None + observation: Optional[str] = None + + +class DocReference(BaseModel): + """One retrieved document behind the answer (RAG citation).""" + + model_config = ConfigDict(extra="allow") + + index_id: Optional[str] = None + doc_id: Optional[str] = None + doc_name: Optional[str] = None + doc_url: Optional[str] = None + title: Optional[str] = None + text: Optional[str] = None + biz_id: Optional[str] = None + images: List[str] = [] + page_number: List[int] = [] + + +class WorkflowMessage(BaseModel): + """Workflow node progress, from ``output.workflow_message``.""" + + model_config = ConfigDict(extra="allow") + + class Message(BaseModel): + model_config = ConfigDict(extra="allow") + + role: Optional[str] = None + content: Optional[str] = None + + node_id: Optional[str] = None + node_name: Optional[str] = None + node_type: Optional[str] = None + node_status: Optional[str] = None + # The wire spells the completion flag as a string, not a bool. + node_is_completed: Optional[str] = None + node_msg_seq_id: Optional[int] = None + message: Optional[Message] = None + + +class AppModelUsage(BaseModel): + """Per-model token detail of one entry in ``usage.models``.""" + + model_config = ConfigDict(extra="allow") + + model_id: Optional[str] = None + input_tokens: int = 0 + output_tokens: int = 0 + + +class AppUsage(Usage): + """App usage: the shared counters plus per-model detail.""" + + models: List[AppModelUsage] = [] + + +class AppCompletion(ResponseBase): + text: Optional[str] = None + finish_reason: Optional[str] = None + session_id: Optional[str] = None + thoughts: Optional[List[Thought]] = None + doc_references: Optional[List[DocReference]] = None + workflow_message: Optional[WorkflowMessage] = None + usage: Optional[AppUsage] = None + + +def _parse(body: Dict[str, Any]) -> 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) + + +def _try_json(data: str) -> Any: + try: + return json.loads(data) + except (TypeError, ValueError): + return data + + +def _build_input( + prompt: Optional[str], + messages: Optional[List[Dict[str, Any]]], + session_id: Optional[str], + doc_reference_type: Optional[str], + doc_tag_codes: Optional[List[str]], + memory_id: Optional[str], + biz_params: Optional[Dict[str, Any]], + image_list: Optional[List[str]], + file_list: Optional[List[str]], +) -> Dict[str, Any]: + if messages is None: + input_param: Dict[str, Any] = {"prompt": prompt} + else: + msgs = copy.deepcopy(messages) + if prompt: + msgs.append({"role": "user", "content": prompt}) + input_param = {"messages": msgs} + if session_id: + input_param["session_id"] = session_id + if doc_reference_type: + input_param["doc_reference_type"] = doc_reference_type + if doc_tag_codes is not None: + if not ( + isinstance(doc_tag_codes, list) + and all(isinstance(item, str) for item in doc_tag_codes) + ): + raise ValueError("doc_tag_codes must be a list of strings") + input_param["doc_tag_codes"] = doc_tag_codes + if memory_id: + input_param["memory_id"] = memory_id + if biz_params: + input_param["biz_params"] = biz_params + if image_list: + input_param["image_list"] = image_list + if file_list: + input_param["file_list"] = file_list + return input_param + + +def _build_parameters( + temperature: Optional[float], + top_p: Optional[float], + top_k: Optional[int], + seed: Optional[int], + has_thoughts: Optional[bool], + rag_options: Optional[Dict[str, Any]], + incremental_output: Optional[bool], + parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + merged: Dict[str, Any] = {} + if temperature is not None: + merged["temperature"] = temperature + if top_p is not None: + merged["top_p"] = top_p + if top_k is not None: + merged["top_k"] = top_k + if seed is not None: + merged["seed"] = seed + if has_thoughts is not None: + merged["has_thoughts"] = has_thoughts + if rag_options is not None: + merged["rag_options"] = rag_options + if incremental_output is not None: + merged["incremental_output"] = incremental_output + if parameters: + merged.update(parameters) + return merged + + +def _build_body( + prompt: Optional[str], + messages: Optional[List[Dict[str, Any]]], + session_id: Optional[str], + doc_reference_type: Optional[str], + doc_tag_codes: Optional[List[str]], + memory_id: Optional[str], + biz_params: Optional[Dict[str, Any]], + image_list: Optional[List[str]], + file_list: Optional[List[str]], + temperature: Optional[float], + top_p: Optional[float], + top_k: Optional[int], + seed: Optional[int], + has_thoughts: Optional[bool], + rag_options: Optional[Dict[str, Any]], + incremental_output: Optional[bool], + parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + return { + "input": _build_input( + prompt, + messages, + session_id, + doc_reference_type, + doc_tag_codes, + memory_id, + biz_params, + image_list, + file_list, + ), + "parameters": _build_parameters( + temperature, + top_p, + top_k, + seed, + has_thoughts, + rag_options, + incremental_output, + parameters, + ), + } + + +def _validate(app_id: Optional[str], prompt, messages) -> None: + if not app_id: + raise ValueError("app_id is required") + if not prompt and not messages: + raise ValueError("prompt or messages is required") + + +def _sse_frames(response) -> Iterator[Any]: + is_error = False + for line in response.iter_lines(): + if not line: + continue + if line.startswith("event:error"): + is_error = True + elif line.startswith("data:"): + yield is_error, line[len("data:") :].strip() + if is_error: + break + + +def _raise_frame_error(data: str) -> None: + body = _try_json(data) + payload = body if isinstance(body, dict) else {} + raise error_from_business_code( + message=payload.get("message") or data, + code=payload.get("code"), + body=body, + ) + + +def _error_event(data: str) -> AppCompletion: + # Non-raising mode: surface the stream error as a terminal event + # instead of silently truncating the stream. + 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, + ) + + +class AppStream: + """Typed iterator over app completion events, mirroring ChatStream.""" + + def __init__(self, inner: Iterator[AppCompletion], response: Any) -> None: + self._inner = inner + self._response = response + + def __iter__(self) -> Iterator[AppCompletion]: + return iter(self._inner) + + @property + def headers(self) -> Dict[str, str]: + """Raw HTTP response headers of the stream's envelope.""" + return dict(self._response.headers) + + @property + def text_stream(self) -> Iterator[str]: + for event in self._inner: + if event.text: + yield event.text + + def close(self) -> None: + self._inner.close() + + +class AsyncAppStream: + def __init__( + self, + inner: AsyncIterator[AppCompletion], + response: Any, + ) -> None: + self._inner = inner + self._response = response + + def __aiter__(self): + return self._inner.__aiter__() + + @property + def headers(self) -> Dict[str, str]: + """Raw HTTP response headers of the stream's envelope.""" + return dict(self._response.headers) + + @property + def text_stream(self): + return self._text_stream() + + async def _text_stream(self): + async for event in self._inner: + if event.text: + yield event.text + + async def aclose(self) -> None: + await self._inner.aclose() + + +class AppCompletions: + """One Bailian application call, at ``client.apps.completions``.""" + + def __init__(self, client) -> None: + self._client = client + + def create( # pylint: disable=too-many-locals + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: bool = 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, + ) -> Union[AppCompletion, AppStream]: + """Call the application; ``stream=True`` returns an AppStream. + + Send ``prompt`` for one turn, ``messages`` for a transcript the + caller maintains, or both — ``prompt`` is then appended as the + latest user turn. ``session_id`` and ``memory_id`` ask the + service to keep the conversation state instead. + + ``biz_params`` carries the application's own flow and plugin + inputs; ``parameters`` holds model knobs and anything this + signature does not name. + """ + _validate(app_id, prompt, messages) + body = _build_body( + prompt, + messages, + session_id, + doc_reference_type, + doc_tag_codes, + memory_id, + biz_params, + image_list, + file_list, + temperature, + top_p, + top_k, + seed, + has_thoughts, + rag_options, + incremental_output, + parameters, + ) + path = f"/apps/{app_id}/completion" + transport = self._client.transport + if stream: + response = transport.open_stream( + "POST", + path, + json=body, + headers=_SSE_HEADER, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + return AppStream(self._events(response), response) + response = transport.request( + "POST", + path, + json=body, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + def _events(self, response) -> Iterator[AppCompletion]: + 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) + return + yield _parse(_try_json(data)) + finally: + response.close() + + +class Apps: + """``client.apps``: Bailian application resources.""" + + def __init__(self, client) -> None: + self.completions = AppCompletions(client) + + +class AsyncAppCompletions: + """Async ``client.apps.completions``; same contract as the sync one.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( # pylint: disable=too-many-locals + self, + *, + app_id: str, + prompt: Optional[str] = None, + messages: Optional[List[Dict[str, Any]]] = None, + stream: bool = 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, + ) -> Union[AppCompletion, AsyncAppStream]: + _validate(app_id, prompt, messages) + body = _build_body( + prompt, + messages, + session_id, + doc_reference_type, + doc_tag_codes, + memory_id, + biz_params, + image_list, + file_list, + temperature, + top_p, + top_k, + seed, + has_thoughts, + rag_options, + incremental_output, + parameters, + ) + path = f"/apps/{app_id}/completion" + transport = self._client.transport + if stream: + response = await transport.open_stream( + "POST", + path, + json=body, + headers=_SSE_HEADER, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + return AsyncAppStream(self._events(response), response) + response = await transport.request( + "POST", + path, + json=body, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + async def _events(self, response) -> AsyncIterator[AppCompletion]: + try: + is_error = False + async for line in response.aiter_lines(): + if not line: + continue + if line.startswith("event:error"): + is_error = True + elif line.startswith("data:"): + data = line[len("data:") :].strip() + if is_error: + if self._client.raise_on_error: + _raise_frame_error(data) + yield with_headers(_error_event(data), response) + return + yield _parse(_try_json(data)) + finally: + await response.aclose() + + +class AsyncApps: + """Async ``client.apps``.""" + + def __init__(self, client) -> None: + self.completions = AsyncAppCompletions(client) diff --git a/dashscope/v2/resources/audio/__init__.py b/dashscope/v2/resources/audio/__init__.py new file mode 100644 index 00000000..411e9ff0 --- /dev/null +++ b/dashscope/v2/resources/audio/__init__.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.audio resource group (asr/tts/realtime/translation, design doc +M3 3.4/3.5). + +Assembly only — each sub-resource lives in its own module, so the tree +here is two levels deep at most: ``client.audio.asr.transcribe``, +``client.audio.asr.recognize``, ``client.audio.tts.synthesize``, +``client.audio.tts.http.synthesize``, ``client.audio.realtime.connect``, +``client.audio.translation.recognize``, and the management plane +``client.audio.vocabularies`` / ``phrases`` / ``voices`` / +``voice_design``. +""" + +from dashscope.v2.resources.audio.asr import ( + Asr, + AsyncAsr, + Recognition, +) +from dashscope.v2.resources.audio.customization import ( + DesignedVoice, + DesignedVoiceList, + Vocabularies, + Vocabulary, + VocabularyList, + Voice, + VoiceDesign, + VoiceList, + Voices, +) +from dashscope.v2.resources.audio.http_tts import ( + HttpSynthesis, + HttpSynthesisChunk, + HttpSynthesisStream, + HttpTts, +) +from dashscope.v2.resources.audio.phrases import ( + Phrase, + PhraseList, + Phrases, +) +from dashscope.v2.resources.audio.realtime import Realtime, RealtimeSession +from dashscope.v2.resources.audio.translation import ( + Translation, + TranslationSession, +) +from dashscope.v2.resources.audio.tts import SynthesisSession, Tts + +__all__ = [ + "Asr", + "AsyncAsr", + "AsyncAudio", + "Audio", + "DesignedVoice", + "DesignedVoiceList", + "HttpSynthesis", + "HttpSynthesisChunk", + "HttpSynthesisStream", + "HttpTts", + "Phrase", + "PhraseList", + "Phrases", + "Realtime", + "RealtimeSession", + "Recognition", + "SynthesisSession", + "Translation", + "TranslationSession", + "Tts", + "Vocabularies", + "Vocabulary", + "VocabularyList", + "Voice", + "VoiceDesign", + "VoiceList", + "Voices", +] + + +class Audio: + def __init__(self, client) -> None: + self.asr = Asr(client) + self.tts = Tts(client) + self.realtime = Realtime(client) + self.translation = Translation(client) + self.vocabularies = Vocabularies(client) + self.phrases = Phrases(client) + self.voices = Voices(client) + self.voice_design = VoiceDesign(client) + + +class AsyncAudio: + """Async audio surface. + + Only batch transcription is exposed here; the ws realtime resources + (``asr.recognize``, ``tts``, ``realtime``) are sync-only by design + (design doc M3 3.5). + """ + + def __init__(self, client) -> None: + self.asr = AsyncAsr(client) diff --git a/dashscope/v2/resources/audio/_callbacks.py b/dashscope/v2/resources/audio/_callbacks.py new file mode 100644 index 00000000..aea8fc1f --- /dev/null +++ b/dashscope/v2/resources/audio/_callbacks.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Optional adapter letting 1.x-style callback objects consume v2 streams. + +Both 1.x callback shapes are duck-typed — asr ``RecognitionCallback`` +(on_open/on_event/on_complete/on_error/on_close, recognition.py:140) and +tts_v2 ``ResultCallback`` (which adds ``on_data`` for binary audio, +speech_synthesizer.py:69-90) — so one helper serves both: only the +methods the object defines are invoked. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from dashscope.v2.transport.ws import EVENT_FINISHED + +__all__ = [ + "run_with_callback", +] + + +def run_with_callback(frames: Iterable[Any], callback: Any) -> None: + """Consume a v2 frame stream, driving a 1.x-style callback object. + + ``frames`` is any v2 stream: ``Recognition.events()`` (raw dicts) or + ``Recognition.results()`` (typed ``RecognitionResult``, matching what + 1.x hands ``on_event``), ``Tts.stream()``/ + ``SynthesisSession.audio_chunks()``. + Bytes go to ``on_data``; dict events go to ``on_event``. + + v2 sessions expose no open/close hooks, so the adapter fires + ``on_open`` itself before the first frame and ``on_close`` last — + the points where 1.x raises them around the websocket lifecycle. A + terminal ``task-finished`` event maps to ``on_complete`` (1.x never + routes it to ``on_event``). A stream error is delivered through + ``on_error`` as the raised exception and is not re-raised, matching + 1.x callback mode. + """ + _invoke(callback, "on_open") + try: + for frame in frames: + if isinstance(frame, (bytes, bytearray)): + _invoke(callback, "on_data", bytes(frame)) + elif _is_task_finished(frame): + _invoke(callback, "on_complete") + break + else: + _invoke(callback, "on_event", frame) + else: + # Streams without a terminal event (e.g. TTS audio chunks) + # still report completion, as 1.x does on task-finished. + _invoke(callback, "on_complete") + except Exception as exc: # pylint: disable=broad-except + _invoke(callback, "on_error", exc) + finally: + _invoke(callback, "on_close") + + +def _is_task_finished(frame: Any) -> bool: + return ( + isinstance(frame, dict) + and (frame.get("header") or {}).get("event") == EVENT_FINISHED + ) + + +def _invoke(callback: Any, name: str, *args: Any) -> None: + method = getattr(callback, name, None) + if callable(method): + method(*args) diff --git a/dashscope/v2/resources/audio/asr.py b/dashscope/v2/resources/audio/asr.py new file mode 100644 index 00000000..144f7386 --- /dev/null +++ b/dashscope/v2/resources/audio/asr.py @@ -0,0 +1,628 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.audio.asr resource (design doc M3 3.4). + +Two shapes on one resource: batch file transcription (async Job over +HTTP, ``transcribe`` + ``jobs``) and realtime recognition (duplex +websocket session used as a context manager, ``recognize``). +""" + +# pylint: disable=redefined-builtin + +from __future__ import annotations + +import os +import time +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterator, + List, + Mapping, + Optional, + Union, +) + +from pydantic import BaseModel, ConfigDict, Field + +from dashscope.v2.resources._jobs import ( + DEFAULT_MAX_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + AsyncJobs, + Job, + Jobs, +) +from dashscope.v2.transport.ws import Connector, EVENT_GENERATED, WsSession +from dashscope.v2.transport.ws_connection import WsConnection, WsTask + +__all__ = [ + "Asr", + "AsyncAsr", + "AsyncTranscriptionJobs", + "Recognition", + "RecognitionResult", + "TranscriptionJobs", +] + +SERVICE = "audio/asr/transcription" + +MODULE = "audio" + +# Local-file chunk size, mirroring 1.x Recognition.call (recognition.py:519 +# reads 12800 bytes per frame). +FILE_CHUNK_SIZE = 12800 + + +def _input_for(file_urls: List[str]) -> Dict[str, Any]: + if not file_urls: + raise ValueError("file_urls must not be empty") + return {"file_urls": list(file_urls)} + + +def _stream_file_frames( + recognition: "Recognition", + path: str, +) -> Iterator[Dict[str, Any]]: + """Send ``path`` in 1.x-sized chunks, then drain the result events.""" + with recognition: + with open(path, "rb") as audio_file: + while True: + chunk = audio_file.read(FILE_CHUNK_SIZE) + if not chunk: + break + recognition.send_audio_frame(chunk) + recognition.stop() + yield from recognition.events() + + +def _phrase_resources( + phrase_id: Optional[str], +) -> Optional[List[Dict[str, str]]]: + if not phrase_id: + return None + return [{"resource_id": phrase_id, "resource_type": "asr_phrase"}] + + +class TranscriptionJobs: + """Async task facade over the batch transcription service.""" + + def __init__(self, client) -> None: + self._jobs = Jobs(client, SERVICE, MODULE) + + 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, + ) -> Job: + return self._jobs.create( + model=model, + input=_input_for(file_urls), + parameters=parameters, + resources=_phrase_resources(phrase_id), + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return self._jobs.fetch( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Any]] = None, + ) -> Job: + return self._jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class AsyncTranscriptionJobs: + def __init__(self, client) -> None: + self._jobs = AsyncJobs(client, SERVICE, MODULE) + + 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, + ) -> Job: + return await self._jobs.create( + model=model, + input=_input_for(file_urls), + parameters=parameters, + resources=_phrase_resources(phrase_id), + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return await self._jobs.fetch( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> Job: + return await self._jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class RecognitionResult(BaseModel): + """Typed view over one ``result-generated`` event payload. + + ``Recognition.events()`` keeps yielding raw dicts; this model adds the + 1.x ``RecognitionResult`` accessors (recognition.py:99-125) for + callers that want them. Unknown payload fields stay readable as + attributes (``extra="allow"``). + """ + + model_config = ConfigDict(extra="allow") + + output: Dict[str, Any] = Field(default_factory=dict) + usage: Optional[Dict[str, Any]] = None + request_id: Optional[str] = None + + def get_sentence(self) -> Optional[Dict[str, Any]]: + """The sentence dict of this result, or None.""" + if self.output and "sentence" in self.output: + return self.output["sentence"] + return None + + def is_sentence_end(self) -> bool: + """True when the sentence carries its end marker (``end_time``).""" + sentence = self.get_sentence() + return sentence is not None and sentence.get("end_time") is not None + + def get_usage(self) -> Optional[Dict[str, Any]]: + # 1.x joins a usages list by the sentence's end_time; v2 events + # carry usage on the payload itself, so it comes back directly. + return self.usage + + +class Recognition: + """Duplex recognition; consume events on one thread while sending audio.""" + + def __init__( + self, + client, + *, + model: str, + format: str = "pcm", + sample_rate: int = 16000, + vocabulary_id: Optional[str] = None, + disfluency_removal_enabled: Optional[bool] = None, + diarization_enabled: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + input: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> None: + config = client.config + self._model = model + merged = {"format": format, "sample_rate": sample_rate} + if parameters: + merged.update(parameters) + # Named kwargs win over the ``parameters=`` escape hatch; the keys + # match the 1.x Recognition wire names. + if vocabulary_id is not None: + merged["vocabulary_id"] = vocabulary_id + if disfluency_removal_enabled is not None: + merged["disfluency_removal_enabled"] = disfluency_removal_enabled + if diarization_enabled is not None: + merged["diarization_enabled"] = diarization_enabled + self._parameters = merged + self._input = dict(input or {}) + # Phrases ride as top-level ``resources`` on the run-task payload + # (verified against the service), not inside ``parameters``. + self._resources = _phrase_resources(phrase_id) + # A shared connection runs this recognition as one task on it; + # ws_url/connector/api_key/workspace/headers then belong to the + # connection and the per-session values are ignored. + self._connection = connection + self._session_kwargs = { + "url": ws_url, + "api_key": api_key or config.api_key, + "workspace": workspace or config.workspace, + "region": config.region, + "connector": connector, + "module": MODULE, + "timeout": config.timeout if timeout is None else timeout, + "headers": headers, + } + self._session: Union[WsSession, WsTask, None] = None + self.last_response: Optional[Dict[str, Any]] = None + self.usage: Optional[Dict[str, Any]] = None + self.first_package_delay: Optional[float] = None + self.last_package_delay: Optional[float] = None + self._first_sent_at: Optional[float] = None + self._stopped_at: Optional[float] = None + self._request_id: Optional[str] = None + + @property + def session(self) -> Union[WsSession, WsTask]: + if self._session is None: + raise RuntimeError("recognition session is not open") + return self._session + + @property + def request_id(self) -> Optional[str]: + return self._request_id + + def start(self) -> "Recognition": + if self._session is not None: + raise RuntimeError("recognition session is already open") + if self._connection is not None: + self._session = self._connection.open_task( + timeout=self._session_kwargs["timeout"], + ) + else: + self._session = WsSession(**self._session_kwargs) + self.last_response = None + self.usage = None + self.first_package_delay = None + self.last_package_delay = None + self._first_sent_at = None + self._stopped_at = None + self._request_id = self._session.task_id + try: + self.last_response = self.session.run_task( + task_group="audio", + task="asr", + function="recognition", + model=self._model, + parameters=self._parameters, + input=self._input, + resources=self._resources, + ) + self._request_id = self.session.request_id + except BaseException: + self.close() + raise + return self + + def __enter__(self) -> "Recognition": + return self.start() + + def __exit__(self, *exc: Any) -> None: + self.close() + + def send_audio_frame(self, data: bytes) -> None: + if self._first_sent_at is None: + self._first_sent_at = time.monotonic() + self.session.send_binary(data) + + def update_context(self, context: Dict[str, Any]) -> None: + """Apply context to subsequent audio without restarting recognition.""" + if not isinstance(context, dict): + raise TypeError("context must be a dict") + self.session.continue_task(context) + + def stop(self) -> None: + """End input; keep reading events through task-finished.""" + if self._stopped_at is None: + self._stopped_at = time.monotonic() + self.session.finish_task() + + def events(self) -> Iterator[Dict[str, Any]]: + session = self.session + while not session.finished: + event = session.recv_event() + self.last_response = event + self._request_id = session.request_id + payload = event.get("payload") or {} + if payload.get("usage") is not None: + self.usage = payload["usage"] + output = payload.get("output") or {} + sentence = output.get("sentence") or {} + transcription = output.get("transcription") or {} + translations = output.get("translations") or [] + # First-package delay fires on the first frame carrying real + # recognized text. Plain ASR puts it in ``output.sentence``; the + # Gummy translation wire shares this base but reports text under + # ``output.transcription`` / ``output.translations`` instead, so + # all three shapes count. Heartbeat frames carry no text and so + # never trigger the timestamp. + has_text = bool( + (sentence.get("text") and not sentence.get("heartbeat")) + or ( + transcription.get("text") + and not transcription.get("heartbeat") + ) + or any( + isinstance(item, dict) + and item.get("text") + and not item.get("heartbeat") + for item in translations + ), + ) + if ( + has_text + and self.first_package_delay is None + and self._first_sent_at is not None + ): + self.first_package_delay = ( + time.monotonic() - self._first_sent_at + ) * 1000 + if session.finished and self._stopped_at is not None: + self.last_package_delay = ( + time.monotonic() - self._stopped_at + ) * 1000 + yield event + + def close(self) -> None: + # With a shared connection this only detaches the task; the + # socket belongs to the WsConnection and stays open. + if self._session is not None: + self._session.close() + self._session = None + + def results(self) -> Iterator[RecognitionResult]: + """Yield typed results until the task finishes. + + Non-result events (task-finished, ...) are skipped: 1.x reports + them through ``on_complete`` instead of ``on_event``. Heartbeat + sentences are dropped too, as 1.x does before invoking callbacks. + """ + for event in self.events(): + if event["header"].get("event") != EVENT_GENERATED: + continue + data = dict(event.get("payload") or {}) + data.setdefault("request_id", event["header"].get("request_id")) + result = RecognitionResult.model_validate(data) + sentence = result.get_sentence() + if sentence and sentence.get("heartbeat"): + continue + yield result + + +class Asr: + """``client.audio.asr``: batch transcription + realtime recognition.""" + + def __init__(self, client) -> None: + self._client = client + self.jobs = TranscriptionJobs(client) + + def transcribe( + self, + file_urls: List[str], + *, + model: str = "paraformer-v2", + parameters: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + timeout: float = DEFAULT_WAIT_TIMEOUT, + submit_timeout: Optional[float] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Submit and wait; output.results carries per-file transcripts. + + ``timeout`` is the wait budget — the same ``timeout`` as + ``jobs.wait``. ``submit_timeout`` caps only the submit request. + """ + job = self.jobs.create( + model=model, + file_urls=file_urls, + parameters=parameters, + phrase_id=phrase_id, + timeout=submit_timeout, + api_key=api_key, + workspace=workspace, + ) + return self.jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def recognize( + self, + *, + model: str, + format: str = "pcm", + sample_rate: int = 16000, + vocabulary_id: Optional[str] = None, + disfluency_removal_enabled: Optional[bool] = None, + diarization_enabled: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + input: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Recognition: + """Open recognition; send audio and consume events concurrently. + + With ``connection=`` the session runs as one task on the shared + socket (binary uplink is single-owner per connection, so one + active recognition per connection). + """ + return Recognition( + self._client, + model=model, + format=format, + sample_rate=sample_rate, + vocabulary_id=vocabulary_id, + disfluency_removal_enabled=disfluency_removal_enabled, + diarization_enabled=diarization_enabled, + parameters=parameters, + input=input, + phrase_id=phrase_id, + ws_url=ws_url, + connector=connector, + connection=connection, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + + def recognize_file( + self, + path: str, + *, + model: str, + format: str = "pcm", # pylint: disable=redefined-builtin + sample_rate: int = 16000, + vocabulary_id: Optional[str] = None, + disfluency_removal_enabled: Optional[bool] = None, + diarization_enabled: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Iterator[Dict[str, Any]]: + """Recognize a local audio file, yielding the same events as + ``Recognition.events()``. + + The file is auto-chunked and driven to completion in one session, + the 1.x ``Recognition.call(file)`` equivalent. Validation happens + eagerly; the session itself opens on first iteration. + """ + if not os.path.isfile(path): + raise ValueError(f"audio file not found: {path}") + if os.path.getsize(path) == 0: + raise ValueError(f"audio file is empty: {path}") + recognition = self.recognize( + model=model, + format=format, + sample_rate=sample_rate, + vocabulary_id=vocabulary_id, + disfluency_removal_enabled=disfluency_removal_enabled, + diarization_enabled=diarization_enabled, + parameters=parameters, + ws_url=ws_url, + connector=connector, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + return _stream_file_frames(recognition, path) + + +class AsyncAsr: + """Async ``client.audio.asr``. + + Realtime ``recognize`` sessions stay sync-only (design doc M3 3.5). + """ + + def __init__(self, client) -> None: + self.jobs = AsyncTranscriptionJobs(client) + + async def transcribe( + self, + file_urls: List[str], + *, + model: str = "paraformer-v2", + parameters: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + timeout: float = DEFAULT_WAIT_TIMEOUT, + submit_timeout: Optional[float] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Submit and wait; output.results carries per-file transcripts. + + ``timeout`` is the wait budget — the same ``timeout`` as + ``jobs.wait``. ``submit_timeout`` caps only the submit request. + """ + job = await self.jobs.create( + model=model, + file_urls=file_urls, + parameters=parameters, + phrase_id=phrase_id, + timeout=submit_timeout, + api_key=api_key, + workspace=workspace, + ) + return await self.jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) diff --git a/dashscope/v2/resources/audio/customization.py b/dashscope/v2/resources/audio/customization.py new file mode 100644 index 00000000..bde917fb --- /dev/null +++ b/dashscope/v2/resources/audio/customization.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Audio customization management plane: vocabularies, voices, voice design. + +V2 form of 1.x ``VocabularyService`` (asr hot-word tables, +``audio/asr/vocabulary.py``) and ``VoiceEnrollmentService`` (voice +cloning, ``audio/tts_v2/enrollment.py``), plus the qwen voice-design +family (声音设计, no 1.x SDK class — modeled from the official REST API). +All three ride the same envelope: ``POST /services/audio/{asr|tts}/ +customization`` with ``{model, input: {action, ...}, parameters?}``; +only the action words and id fields differ per family. 1.x retried +transport failures inside these services; v2 leaves that to the +transport's retry loop. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional + +from pydantic import AliasChoices, Field + +from dashscope.v2.errors import check_response +from dashscope.v2.types.common import ( + JobStatus, + ResponseBase, + flatten_envelope, + with_headers, +) + +__all__ = [ + "DesignedVoice", + "DesignedVoiceList", + "Vocabularies", + "Vocabulary", + "VocabularyList", + "Voice", + "VoiceDesign", + "VoiceList", + "Voices", +] + +MODULE = "audio" + + +def _require_id(value: str, name: str) -> None: + if not value or not value.strip(): + raise ValueError(f"{name} must be a non-empty string") + + +class Vocabulary(ResponseBase): + """One hot-word table; ``create``/``get`` return it.""" + + vocabulary_id: Optional[str] = None + vocabulary: Optional[List[Dict[str, Any]]] = None + + +class VocabularyList(ResponseBase): + vocabularies: List[Dict[str, Any]] = Field( + default_factory=list, + validation_alias=AliasChoices("vocabularies", "vocabulary_list"), + ) + + +class Voice(ResponseBase): + """One cloned voice; ``create``/``get`` return it.""" + + voice_id: Optional[str] = None + status: Optional[str] = None + preview_audio: Optional[Dict[str, Any]] = None + + +class VoiceList(ResponseBase): + voices: List[Dict[str, Any]] = Field( + default_factory=list, + validation_alias=AliasChoices("voices", "voice_list"), + ) + + +class DesignedVoice(ResponseBase): + """One designed voice; its id field is ``voice`` on the wire.""" + + voice: Optional[str] = None + preview_audio: Optional[Dict[str, Any]] = None + language: Optional[str] = None + target_model: Optional[str] = None + + +class DesignedVoiceList(ResponseBase): + voices: List[Dict[str, Any]] = Field( + default_factory=list, + validation_alias=AliasChoices("voices", "voice_list"), + ) + + +class _CustomizationBase: + """Shared POST helper for the customization envelope.""" + + PATH = "" + DEFAULT_MODEL = "" + + def __init__(self, client) -> None: + self._client = client + + def _call( + self, + action: str, + fields: 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, + ) -> Any: + input_ = {"action": action} + input_.update(fields) + body: Dict[str, Any] = { + "model": model or self.DEFAULT_MODEL, + "input": input_, + } + if parameters is not None: + body["parameters"] = parameters + response = self._client.transport.request( + "POST", + self.PATH, + json=body, + headers=headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return response + + +class Vocabularies(_CustomizationBase): + """``client.audio.vocabularies`` -- asr hot-word tables.""" + + PATH = "/services/audio/asr/customization" + DEFAULT_MODEL = "speech-biasing" + + 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, + ) -> Vocabulary: + """Create a table; the result carries ``vocabulary_id``.""" + response = self._call( + "create_vocabulary", + { + "target_model": target_model, + "prefix": prefix, + "vocabulary": vocabulary, + }, + model=model, + parameters=parameters, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + Vocabulary(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> VocabularyList: + """List tables, optionally narrowed to one ``prefix``.""" + fields: Dict[str, Any] = { + "page_index": page_index, + "page_size": page_size, + } + if prefix: + fields["prefix"] = prefix + response = self._call( + "list_vocabulary", + fields, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + VocabularyList(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> Vocabulary: + """Fetch one table's contents (1.x ``query_vocabulary``).""" + _require_id(vocabulary_id, "vocabulary_id") + response = self._call( + "query_vocabulary", + {"vocabulary_id": vocabulary_id}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + Vocabulary(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> JobStatus: + """Replace one table's contents.""" + _require_id(vocabulary_id, "vocabulary_id") + response = self._call( + "update_vocabulary", + {"vocabulary_id": vocabulary_id, "vocabulary": vocabulary}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> JobStatus: + """Delete one table.""" + _require_id(vocabulary_id, "vocabulary_id") + response = self._call( + "delete_vocabulary", + {"vocabulary_id": vocabulary_id}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) + + +class Voices(_CustomizationBase): + """``client.audio.voices`` -- voice cloning (声音复刻).""" + + PATH = "/services/audio/tts/customization" + DEFAULT_MODEL = "voice-enrollment" + + 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, + ) -> Voice: + """Clone a voice from the audio at ``url``; carries ``voice_id``.""" + fields: Dict[str, Any] = dict(extra_input or {}) + # Named arguments win over the ``extra_input=`` escape hatch. + fields.update( + {"target_model": target_model, "prefix": prefix, "url": url}, + ) + if language_hints is not None: + 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( + "create_voice", + fields, + model=model, + parameters=parameters, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + Voice(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> VoiceList: + """List cloned voices, optionally narrowed to one ``prefix``.""" + fields: Dict[str, Any] = { + "page_index": page_index, + "page_size": page_size, + } + if prefix: + fields["prefix"] = prefix + response = self._call( + "list_voice", + fields, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + VoiceList(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> Voice: + """Fetch one voice's details (1.x ``query_voice``).""" + _require_id(voice_id, "voice_id") + response = self._call( + "query_voice", + {"voice_id": voice_id}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + Voice(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> JobStatus: + """Re-clone one voice from the audio at ``url``.""" + _require_id(voice_id, "voice_id") + response = self._call( + "update_voice", + {"voice_id": voice_id, "url": url}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> JobStatus: + """Delete one cloned voice.""" + _require_id(voice_id, "voice_id") + response = self._call( + "delete_voice", + {"voice_id": voice_id}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) + + +class VoiceDesign(_CustomizationBase): + """``client.audio.voice_design`` -- qwen voice design (声音设计). + + No 1.x SDK class; modeled from the official REST API, which shares + the voices endpoint with shorter action words and a ``voice`` id + field instead of ``voice_id``. + """ + + PATH = "/services/audio/tts/customization" + DEFAULT_MODEL = "qwen-voice-design" + + def create( + self, + *, + target_model: str, + voice_prompt: str, + preview_text: str, + preferred_name: Optional[str] = None, + language: Optional[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, + ) -> DesignedVoice: + """Design a voice from a text prompt; carries ``voice`` + preview.""" + fields: Dict[str, Any] = dict(extra_input or {}) + fields.update( + { + "target_model": target_model, + "voice_prompt": voice_prompt, + "preview_text": preview_text, + }, + ) + if preferred_name is not None: + fields["preferred_name"] = preferred_name + if language is not None: + fields["language"] = language + response = self._call( + "create", + fields, + model=model, + parameters=parameters, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + DesignedVoice(**flatten_envelope(response.json())), + response, + ) + + def list( + self, + *, + 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, + ) -> DesignedVoiceList: + """List designed voices.""" + response = self._call( + "list", + {"page_index": page_index, "page_size": page_size}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + DesignedVoiceList(**flatten_envelope(response.json())), + response, + ) + + def get( + self, + voice: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> DesignedVoice: + """Fetch one designed voice (wire action ``query``).""" + _require_id(voice, "voice") + response = self._call( + "query", + {"voice": voice}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + DesignedVoice(**flatten_envelope(response.json())), + response, + ) + + def delete( + self, + voice: str, + *, + model: Optional[str] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + """Delete one designed voice.""" + _require_id(voice, "voice") + response = self._call( + "delete", + {"voice": voice}, + model=model, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) diff --git a/dashscope/v2/resources/audio/http_tts.py b/dashscope/v2/resources/audio/http_tts.py new file mode 100644 index 00000000..c3a92087 --- /dev/null +++ b/dashscope/v2/resources/audio/http_tts.py @@ -0,0 +1,323 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""HTTP one-shot and streaming speech synthesis (real HTTP POST, no ws). + +V2 form of the two 1.x HTTP TTS families, sharing one entry: + +- sambert/cosyvoice (1.x ``HttpSpeechSynthesizer``) -> + ``services/audio/tts/SpeechSynthesizer`` +- qwen-tts family (1.x qwen ``SpeechSynthesizer``: qwen-tts, + qwen3-tts-flash) -> ``services/aigc/multimodal-generation/generation`` + +Both POST the same ``{model, input}`` envelope and answer with +``output.audio``; streaming rides SSE via the ``X-DashScope-SSE: enable`` +header, framed exactly like the 1.x ``StreamEventMixin`` wire (see +``resources/_sse.py``). +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any, Dict, Iterator, Mapping, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from dashscope.v2.errors import DashScopeError, check_response +from dashscope.v2.resources._sse import _iter_sse, _stream_error +from dashscope.v2.types.common import with_headers + +__all__ = [ + "HttpSynthesis", + "HttpSynthesisChunk", + "HttpSynthesisStream", + "HttpTts", +] + +MODULE = "audio" + +_SSE_HEADER = {"X-DashScope-SSE": "enable"} +_DEFAULT_PATH = "/services/audio/tts/SpeechSynthesizer" +_QWEN_PATH = "/services/aigc/multimodal-generation/generation" + + +def _path_for(model: str) -> str: + # qwen-tts / qwen3-tts-flash ride the multimodal-generation service; + # sambert/cosyvoice ride the audio tts service. + if model.startswith("qwen"): + return _QWEN_PATH + return _DEFAULT_PATH + + +def _build_body( + text: str, + *, + model: str, + voice: str, + format: Optional[str], # pylint: disable=redefined-builtin + sample_rate: Optional[int], + language_type: Optional[str], + parameters: Optional[Dict[str, Any]], + extra_input: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + input_: Dict[str, Any] = dict(extra_input or {}) + # Named arguments win over the ``extra_input=`` escape hatch; the + # keys match the 1.x wire names. + input_["text"] = text + input_["voice"] = voice + if format is not None: + input_["format"] = format + if sample_rate is not None: + input_["sample_rate"] = sample_rate + if language_type is not None: + input_["language_type"] = language_type + body: Dict[str, Any] = {"model": model, "input": input_} + if parameters is not None: + body["parameters"] = parameters + return body + + +def _audio_dict(output: Dict[str, Any]) -> Dict[str, Any]: + audio = output.get("audio") + return audio if isinstance(audio, dict) else {} + + +def _usage_dict(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: + usage = body.get("usage") + return usage if isinstance(usage, dict) else None + + +class HttpSynthesis(BaseModel): + """Non-streaming result: a URL reference to the rendered audio.""" + + model_config = ConfigDict(extra="allow") + + output: Dict[str, Any] = {} + usage: Optional[Dict[str, Any]] = None + request_id: str = "" + # Raw HTTP response headers, 1.x parity (``GenerationResponse.headers``); + # filled via ``with_headers`` like every other non-streaming success. + headers: Dict[str, str] = Field(default_factory=dict) + + @property + def audio_url(self) -> Optional[str]: + """Temporary URL of the rendered audio.""" + return _audio_dict(self.output).get("url") + + @property + def audio_id(self) -> Optional[str]: + return _audio_dict(self.output).get("id") + + @property + def expires_at(self) -> Optional[int]: + """Expiry timestamp of :attr:`audio_url`.""" + return _audio_dict(self.output).get("expires_at") + + +class HttpSynthesisChunk(BaseModel): + """One SSE part of a streaming synthesis.""" + + model_config = ConfigDict(extra="allow") + + output: Dict[str, Any] = {} + audio: Optional[bytes] = None + usage: Optional[Dict[str, Any]] = None + request_id: str = "" + + @property + def sentence(self) -> Optional[Dict[str, Any]]: + """Sentence metadata of cosyvoice ``sentence-*`` parts.""" + sentence = self.output.get("sentence") + return sentence if isinstance(sentence, dict) else None + + @property + def finish_reason(self) -> Optional[str]: + return self.output.get("finish_reason") + + @property + def is_final(self) -> bool: + """True on the closing part (``finish_reason == "stop"``).""" + return self.finish_reason == "stop" + + @property + def audio_url(self) -> Optional[str]: + """Temporary URL of the whole audio; final part only.""" + return _audio_dict(self.output).get("url") + + @property + def audio_id(self) -> Optional[str]: + return _audio_dict(self.output).get("id") + + @property + def expires_at(self) -> Optional[int]: + return _audio_dict(self.output).get("expires_at") + + +def _synthesis_from_body( + body: Dict[str, Any], + header_request_id: str, +) -> HttpSynthesis: + output = body.get("output") + return HttpSynthesis( + output=output if isinstance(output, dict) else {}, + usage=_usage_dict(body), + request_id=body.get("request_id") or header_request_id, + ) + + +def _chunk_from_data( + data: str, + header_request_id: str, +) -> HttpSynthesisChunk: + try: + body = json.loads(data) + except ValueError as error: + raise DashScopeError( + f"invalid synthesis stream data: {data!r}", + ) from error + if not isinstance(body, dict): + raise DashScopeError(f"invalid synthesis stream data: {data!r}") + output = body.get("output") + output = output if isinstance(output, dict) else {} + payload = _audio_dict(output).get("data") + return HttpSynthesisChunk( + output=output, + audio=base64.b64decode(payload) if payload else None, + usage=_usage_dict(body), + request_id=body.get("request_id") or header_request_id, + ) + + +class HttpSynthesisStream: + """Typed iterator over streaming synthesis parts, mirroring AppStream.""" + + def __init__( + self, + inner: Iterator[HttpSynthesisChunk], + response: Any, + ) -> None: + self._inner = inner + self._response = response + + def __iter__(self) -> Iterator[HttpSynthesisChunk]: + return iter(self._inner) + + def __next__(self) -> HttpSynthesisChunk: + return next(self._inner) + + @property + def headers(self) -> Dict[str, str]: + """Raw HTTP response headers of the stream's envelope.""" + return dict(self._response.headers) + + def close(self) -> None: + self._inner.close() + + +class HttpTts: + """``client.audio.tts.http`` -- HTTP synthesis for both families.""" + + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> HttpSynthesis: + """Synthesize in one POST; the audio comes back as a URL.""" + response = self._client.transport.request( + "POST", + _path_for(model), + json=_build_body( + text, + model=model, + voice=voice, + format=format, + sample_rate=sample_rate, + language_type=language_type, + parameters=parameters, + extra_input=extra_input, + ), + headers=headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + _synthesis_from_body( + response.json(), + response.headers.get("x-request-id", ""), + ), + response, + ) + + def stream( + 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, + ) -> HttpSynthesisStream: + """Stream the synthesis over SSE, one chunk per part. + + Audio frames arrive base64-decoded on :attr:`HttpSynthesisChunk.audio`; + the final chunk (``is_final``) carries the whole-audio URL reference. + The envelope's raw HTTP headers are on + :attr:`HttpSynthesisStream.headers`. + """ + sse_headers = dict(_SSE_HEADER) + if headers: + sse_headers.update(headers) + response = self._client.transport.open_stream( + "POST", + _path_for(model), + json=_build_body( + text, + model=model, + voice=voice, + format=format, + sample_rate=sample_rate, + language_type=language_type, + parameters=parameters, + extra_input=extra_input, + ), + headers=sse_headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + return HttpSynthesisStream(self._events(response), response) + + def _events(self, response) -> Iterator[HttpSynthesisChunk]: + header_request_id = response.headers.get("x-request-id", "") + try: + for is_error, status, data in _iter_sse(response): + if is_error: + raise _stream_error(status, data) + yield _chunk_from_data(data, header_request_id) + finally: + response.close() diff --git a/dashscope/v2/resources/audio/phrases.py b/dashscope/v2/resources/audio/phrases.py new file mode 100644 index 00000000..084f24ba --- /dev/null +++ b/dashscope/v2/resources/audio/phrases.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Paraformer hot-phrase management (1.x ``AsrPhraseManager``). + +A compile job in disguise: ``create``/``update`` POST ``/fine-tunes`` +with ``mode=compile_asr_phrase`` and an empty file set; ``get``/ +``list``/``delete`` hit ``/fine-tunes/outputs``. The resulting job id +doubles as the ``phrase_id`` that ``asr.recognize`` consumes. 1.x +``list_phrases`` accepted ``page=`` but dropped it in ``**kwargs`` +before it reached the wire; v2 sends the documented ``page_no``. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional + +from pydantic import AliasChoices, Field + +from dashscope.v2.errors import check_response +from dashscope.v2.types.common import ( + JobStatus, + ResponseBase, + flatten_envelope, + with_headers, +) + +__all__ = [ + "Phrase", + "PhraseList", + "Phrases", +] + +MODULE = "audio" + +_CREATE_PATH = "/fine-tunes" +_OUTPUTS_PATH = "/fine-tunes/outputs" + + +def _require_id(value: str, name: str) -> None: + if not value or not value.strip(): + raise ValueError(f"{name} must be a non-empty string") + + +def _require_phrases(phrases: Dict[str, Any]) -> None: + if not phrases: + raise ValueError("phrases must be a non-empty dict") + + +def _compile_body( + *, + model: str, + phrases: Dict[str, Any], + training_type: str, + finetuned_output: Optional[str], +) -> Dict[str, Any]: + body: Dict[str, Any] = { + "model": model, + "training_file_ids": [], + "validation_file_ids": [], + "training_type": training_type, + "hyper_parameters": {"phrase_list": phrases}, + } + if finetuned_output is not None: + body["finetuned_output"] = finetuned_output + return body + + +class Phrase(ResponseBase): + """One compiled hot-phrase set; the job id doubles as ``phrase_id``.""" + + phrase_id: Optional[str] = Field( + None, + validation_alias=AliasChoices( + "phrase_id", + "job_id", + "finetuned_output", + ), + ) + status: Optional[str] = None + + +class PhraseList(ResponseBase): + page_no: int = 1 + page_size: int = 10 + total_count: int = Field( + 0, + validation_alias=AliasChoices("total_count", "total"), + ) + phrases: List[Phrase] = Field( + default_factory=list, + validation_alias=AliasChoices("phrases", "jobs", "outputs"), + ) + + +class Phrases: + """``client.audio.phrases`` -- paraformer hot phrases.""" + + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> Phrase: + """Compile a new phrase set; the result carries ``phrase_id``.""" + _require_phrases(phrases) + response = self._client.transport.request( + "POST", + _CREATE_PATH, + json=_compile_body( + model=model, + phrases=phrases, + training_type=training_type, + finetuned_output=None, + ), + headers=headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + Phrase(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> Phrase: + """Recompile the phrase set behind ``phrase_id``.""" + _require_id(phrase_id, "phrase_id") + _require_phrases(phrases) + response = self._client.transport.request( + "POST", + _CREATE_PATH, + json=_compile_body( + model=model, + phrases=phrases, + training_type=training_type, + finetuned_output=phrase_id, + ), + headers=headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + Phrase(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> Phrase: + """Fetch one compiled phrase set (1.x ``query_phrases``).""" + _require_id(phrase_id, "phrase_id") + response = self._client.transport.request( + "GET", + f"{_OUTPUTS_PATH}/{phrase_id}", + headers=headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + Phrase(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> PhraseList: + """List compiled phrase sets.""" + response = self._client.transport.request( + "GET", + _OUTPUTS_PATH, + params={"page_no": page_no, "page_size": page_size}, + headers=headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + PhraseList(**flatten_envelope(response.json())), + response, + ) + + 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, + ) -> JobStatus: + """Delete one compiled phrase set.""" + _require_id(phrase_id, "phrase_id") + response = self._client.transport.request( + "DELETE", + f"{_OUTPUTS_PATH}/{phrase_id}", + headers=headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) diff --git a/dashscope/v2/resources/audio/realtime.py b/dashscope/v2/resources/audio/realtime.py new file mode 100644 index 00000000..e2decb99 --- /dev/null +++ b/dashscope/v2/resources/audio/realtime.py @@ -0,0 +1,802 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Omni, Qwen-Audio and Livetranslate sessions over realtime WebSockets.""" + +from __future__ import annotations + +import base64 +import json +import threading +import time +import uuid +from typing import Any, Dict, Iterator, List, Mapping, Optional, Union, cast +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from dashscope.v2._config import WS_REALTIME_PATH, workspace_ws_url +from dashscope.v2.errors import APIConnectionError, error_from_business_code +from dashscope.v2.transport import default_user_agent +from dashscope.v2.transport._ident import sdk_headers +from dashscope.v2.transport.ws import Connector, _close_websocket + +__all__ = [ + "Realtime", + "RealtimeSession", +] + +MODULE = "audio" + +_UNSET = object() + + +def _default_connector( + url: str, + headers: Dict[str, str], + timeout: Optional[float], +) -> Any: + import websocket # pylint: disable=import-outside-toplevel + + return websocket.create_connection(url, header=headers, timeout=timeout) + + +def _as_base64(data: Union[str, bytes]) -> str: + if isinstance(data, str): + return data + if isinstance(data, bytes): + return base64.b64encode(data).decode("ascii") + raise TypeError("expected a base64 string or bytes") + + +class RealtimeSession: + """One realtime websocket session (context manager). + + Usage:: + + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + ) as rt: + rt.send_event({"type": "session.update", "session": {...}}) + for event in rt.events(): + ... + + Unlike the run-task family (asr/tts/translation/tingwu), the + realtime protocol has no task id: one socket IS one session and the + server closes it when the session ends, so connection reuse does + not apply here -- every session opens its own socket (see + ``WsConnection`` for the multiplexable family). Latency-sensitive + callers can shave the handshake by connecting ahead of use rather + than by pooling. + + This one-socket-per-session rule is a published contract: it is + pinned by tests/v2/test_audio_omni_session.py and relied on by + examples/04_real_world_scenarios/ + realtime_audio_meeting_harness.py, which sizes its meeting + concurrency on it. + """ + + def __init__( + self, + client, + *, + model: str, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> None: + config = client.config + if timeout is not None and timeout <= 0: + raise ValueError("timeout must be positive") + # A per-call workspace override picks that workspace's gateway + # for the URL as well, so the socket and the + # X-DashScope-WorkSpace header never name different workspaces. + space = workspace or config.workspace + # URL precedence: explicit ``ws_url`` > the workspace-scoped MaaS + # gateway realtime endpoint; without a workspace the legacy shared + # gateway is the fallback, with a warning. (The inference + # endpoint's DASHSCOPE_WEBSOCKET_BASE_URL does not apply here: the + # realtime path differs.) + url = ws_url or workspace_ws_url( + space, + WS_REALTIME_PATH, + config.region, + ) + parts = urlsplit(url) + query = parse_qsl(parts.query, keep_blank_values=True) + if not any(key == "model" for key, _ in query): + query.append(("model", model)) + url = urlunsplit(parts._replace(query=urlencode(query))) + all_headers = { + "user-agent": default_user_agent(), + **sdk_headers(MODULE), + } + key = api_key or config.api_key + if key: + all_headers["Authorization"] = f"Bearer {key}" + if space: + all_headers["X-DashScope-WorkSpace"] = space + user_headers = dict(headers or {}) + user_keys = {key.lower() for key in user_headers} + all_headers = { + key: value + for key, value in all_headers.items() + if key.lower() not in user_keys + } + all_headers.update(user_headers) + self._model = model + wire_model = next(value for key, value in query if key == "model") + if wire_model.startswith("qwen-audio-"): + self._protocol = "qwen_audio" + elif wire_model.startswith("qwen3.8-livetranslate-"): + self._protocol = "livetranslate_v38" + elif wire_model.startswith( + ("qwen3-livetranslate-", "qwen3.5-livetranslate-"), + ): + self._protocol = "livetranslate" + else: + self._protocol = "omni" + self._url = url + self._headers = all_headers + self._connector = connector + self._timeout = config.timeout if timeout is None else timeout + self._lock = threading.RLock() + self._ws = None + self._reset_state() + + def _reset_state(self) -> None: + self._closed = False + self._finish_requested = False + self._finished = False + self._response_active = False + self.session_id: Optional[str] = None + self.last_message: Optional[Dict[str, Any]] = None + self.last_response_id: Optional[str] = None + self.last_response_create_time: Optional[float] = None + self.last_first_text_delay: Optional[float] = None + self.last_first_audio_delay: Optional[float] = None + + @property + def model(self) -> str: + return self._model + + @property + def url(self) -> str: + return self._url + + def connect(self) -> "RealtimeSession": + """Open the connection, either explicitly or on context entry.""" + with self._lock: + if self._ws is not None: + raise RuntimeError("realtime session is already open") + try: + if self._connector is None: + self._ws = _default_connector( + self._url, + self._headers, + self._timeout, + ) + else: + self._ws = self._connector(self._url, self._headers) + if self._timeout is not None: + self._ws.settimeout(self._timeout) + except BaseException: + self.close() + self._reset_state() + raise + self._reset_state() + return self + + def __enter__(self) -> "RealtimeSession": + return self.connect() + + def __exit__(self, *exc: Any) -> None: + self.close() + + def send_event(self, event: Dict[str, Any]) -> None: + """Send a raw event, assigning an event_id unless one is supplied.""" + with self._lock: + if self._ws is None: + raise RuntimeError("realtime session is not open") + if self._finish_requested: + if event.get("type") == "session.finish": + return + raise RuntimeError("realtime session is finishing") + payload = dict(event) + payload.setdefault("event_id", "event_" + uuid.uuid4().hex) + raw = json.dumps(payload) + if payload.get("type") == "session.finish": + self._finish_requested = True + try: + self._ws.send(raw) + except BaseException: + self.close() + raise + + def update_session( + self, + output_modalities: List[str] = cast(List[str], _UNSET), + *, + voice: Optional[str] = cast(Optional[str], _UNSET), + input_audio_format: str = cast(str, _UNSET), + output_audio_format: str = cast(str, _UNSET), + enable_input_audio_transcription: bool = cast(bool, _UNSET), + input_audio_transcription_model: Optional[str] = cast( + Optional[str], + _UNSET, + ), + enable_turn_detection: bool = cast(bool, _UNSET), + turn_detection_type: str = cast(str, _UNSET), + prefix_padding_ms: int = cast(int, _UNSET), + turn_detection_threshold: float = cast(float, _UNSET), + turn_detection_silence_duration_ms: int = cast(int, _UNSET), + turn_detection_param: Optional[Dict[str, Any]] = cast( + Optional[Dict[str, Any]], + _UNSET, + ), + translation_params: Optional[Dict[str, Any]] = cast( + Optional[Dict[str, Any]], + _UNSET, + ), + transcription_params: Optional[Dict[str, Any]] = cast( + Optional[Dict[str, Any]], + _UNSET, + ), + input_audio_config: Optional[Dict[str, Any]] = cast( + Optional[Dict[str, Any]], + _UNSET, + ), + output_audio_config: Optional[Dict[str, Any]] = cast( + Optional[Dict[str, Any]], + _UNSET, + ), + input_video_representation_compact: Optional[str] = cast( + Optional[str], + _UNSET, + ), + **kwargs: Any, + ) -> None: + """Patch Qwen-Audio/Live settings; Omni retains its legacy defaults. + + ``**kwargs`` is the protocol-evolution escape hatch: extra keys + merge into the wire ``session`` object after the named helpers, + so on key overlap the kwargs value wins and falsey values are + preserved as sent. + """ + options = { + key: value + for key, value in { + "output_modalities": output_modalities, + "voice": voice, + "input_audio_format": input_audio_format, + "output_audio_format": output_audio_format, + "enable_input_audio_transcription": ( + enable_input_audio_transcription + ), + "input_audio_transcription_model": ( + input_audio_transcription_model + ), + "enable_turn_detection": enable_turn_detection, + "turn_detection_type": turn_detection_type, + "prefix_padding_ms": prefix_padding_ms, + "turn_detection_threshold": turn_detection_threshold, + "turn_detection_silence_duration_ms": ( + turn_detection_silence_duration_ms + ), + "turn_detection_param": turn_detection_param, + "translation_params": translation_params, + "transcription_params": transcription_params, + "input_audio_config": input_audio_config, + "output_audio_config": output_audio_config, + "input_video_representation_compact": ( + input_video_representation_compact + ), + }.items() + if value is not _UNSET + } + if self._protocol == "omni": + session = self._omni_session(**options, **kwargs) + else: + session = self._incremental_session(options, kwargs) + self.send_event({"type": "session.update", "session": session}) + + def _omni_session( + self, + output_modalities: List[str], + *, + voice: Optional[str] = None, + input_audio_format: str = "pcm16", + output_audio_format: str = "pcm16", + enable_input_audio_transcription: bool = True, + input_audio_transcription_model: Optional[str] = None, + enable_turn_detection: bool = True, + turn_detection_type: str = "server_vad", + prefix_padding_ms: int = 300, + turn_detection_threshold: float = 0.2, + turn_detection_silence_duration_ms: int = 800, + turn_detection_param: Optional[Dict[str, Any]] = None, + translation_params: Optional[Dict[str, Any]] = None, + transcription_params: Optional[Dict[str, Any]] = None, + input_audio_config: Optional[Dict[str, Any]] = None, + output_audio_config: Optional[Dict[str, Any]] = None, + input_video_representation_compact: Optional[str] = None, + **kwargs: Any, + ) -> Dict[str, Any]: + session: Dict[str, Any] = { + "modalities": output_modalities, + "voice": voice, + } + if input_audio_config is None and output_audio_config is None: + session["input_audio_format"] = input_audio_format + session["output_audio_format"] = output_audio_format + else: + audio = {} + if input_audio_config is not None: + audio["input"] = {"format": dict(input_audio_config)} + if output_audio_config is not None: + audio["output"] = {"format": dict(output_audio_config)} + session["audio"] = audio + session["input_audio_transcription"] = ( + {"model": input_audio_transcription_model} + if enable_input_audio_transcription + else None + ) + session["turn_detection"] = None + if enable_turn_detection: + session["turn_detection"] = { + "type": turn_detection_type, + "threshold": turn_detection_threshold, + "prefix_padding_ms": prefix_padding_ms, + "silence_duration_ms": turn_detection_silence_duration_ms, + } + if turn_detection_param is not None: + session["turn_detection"].update(turn_detection_param) + if translation_params is not None: + session["translation"] = {"language": None, **translation_params} + if transcription_params is not None: + transcription = { + key: value + for key, value in transcription_params.items() + if key + not in { + "input_audio_format", + "sample_rate", + "corpus_text", + } + and value is not None + } + if transcription_params.get("corpus_text") is not None: + transcription["corpus"] = { + "text": transcription_params["corpus_text"], + } + session["input_audio_transcription"] = transcription + if "audio" not in session: + session["input_audio_format"] = transcription_params.get( + "input_audio_format", + "pcm", + ) + session["sample_rate"] = transcription_params.get( + "sample_rate", + 16000, + ) + if input_video_representation_compact is not None: + session["video"] = { + "input": { + "representation_compact": ( + input_video_representation_compact + ), + }, + } + session.update(kwargs) + return session + + def _incremental_session( # pylint: disable=too-many-branches,too-many-statements + self, + options: Dict[str, Any], + kwargs: Dict[str, Any], + ) -> Dict[str, Any]: + session = {} + for option, field in { + "output_modalities": "modalities", + "voice": "voice", + "input_audio_format": "input_audio_format", + "output_audio_format": "output_audio_format", + "translation_params": "translation", + }.items(): + if option in options: + session[field] = options[option] + + vad = {} + if options.get("enable_turn_detection"): + vad["type"] = ( + "speaker_detection" + if self._protocol == "livetranslate_v38" + else "server_vad" + ) + for option, field in { + "turn_detection_type": "type", + "prefix_padding_ms": "prefix_padding_ms", + "turn_detection_threshold": "threshold", + "turn_detection_silence_duration_ms": "silence_duration_ms", + }.items(): + if option in options: + vad[field] = options[option] + params = options.get("turn_detection_param", _UNSET) + if params is not _UNSET and params is not None: + vad.update(params) + if options.get("enable_turn_detection") is False or params is None: + session["turn_detection"] = None + elif vad or params is not _UNSET: + session["turn_detection"] = vad + + transcription: Any = _UNSET + enabled = options.get("enable_input_audio_transcription", _UNSET) + if enabled is True and self._protocol != "livetranslate_v38": + transcription = ( + {"model": "qwen3-asr-flash-realtime"} + if self._protocol == "livetranslate" + else {} + ) + if "input_audio_transcription_model" in options: + transcription = { + "model": options["input_audio_transcription_model"], + } + if "transcription_params" in options: + params = options["transcription_params"] + if params is None: + transcription = ( + {"model": None} + if self._protocol == "livetranslate" + else None + ) + else: + transcription = { + **( + transcription + if isinstance(transcription, dict) + else {} + ), + **{ + key: value + for key, value in params.items() + if key + not in { + "input_audio_format", + "sample_rate", + "corpus_text", + } + }, + } + if "corpus_text" in params: + transcription["corpus"] = {"text": params["corpus_text"]} + for key in ("input_audio_format", "sample_rate"): + if key in params: + session[key] = params[key] + if enabled is False: + transcription = ( + {"model": None} if self._protocol == "livetranslate" else None + ) + if transcription is not _UNSET: + session["input_audio_transcription"] = transcription + + audio = {} + for direction in ("input", "output"): + key = f"{direction}_audio_config" + if key in options: + audio[direction] = {"format": options[key]} + if audio: + session["audio"] = audio + if self._protocol != "livetranslate_v38": + for key in ( + "input_audio_format", + "output_audio_format", + "sample_rate", + ): + session.pop(key, None) + if "input_video_representation_compact" in options: + session["video"] = { + "input": { + "representation_compact": ( + options["input_video_representation_compact"] + ), + }, + } + session.update(kwargs) + if self._protocol == "livetranslate_v38": + transcription = session.get("input_audio_transcription", _UNSET) + if transcription is None or ( + isinstance(transcription, dict) + and transcription.get("model", _UNSET) is None + ): + raise ValueError("Livetranslate 3.8 ASR cannot be disabled") + if "same_language_skip_options" in session: + raise ValueError( + "Livetranslate 3.8 does not support " + "same_language_skip_options", + ) + session = self._nested_translation_session(session) + return session + + @staticmethod + def _nested_translation_session(session: Dict[str, Any]) -> Dict[str, Any]: + if "modalities" in session: + session["output_modalities"] = session.pop("modalities") + audio: Dict[str, Any] = {} + for field, direction, nested_key in ( + ("input_audio_format", "input", "type"), + ("sample_rate", "input", "sample_rate"), + ("output_audio_format", "output", "type"), + ): + if field in session: + audio.setdefault(direction, {}).setdefault("format", {})[ + nested_key + ] = session.pop(field) + for field, direction in ( + ("turn_detection", "input"), + ("voice", "output"), + ): + if field in session: + audio.setdefault(direction, {})[field] = session.pop(field) + raw_audio = session.pop("audio", _UNSET) + if isinstance(raw_audio, dict): + for direction, config in raw_audio.items(): + if isinstance(config, dict): + merged = {**audio.get(direction, {}), **config} + if isinstance(config.get("format"), dict): + merged["format"] = { + **audio.get(direction, {}).get("format", {}), + **config["format"], + } + audio[direction] = merged + else: + audio[direction] = config + if raw_audio is not _UNSET and not isinstance(raw_audio, dict): + session["audio"] = raw_audio + elif audio or raw_audio is not _UNSET: + session["audio"] = audio + return session + + def append_audio(self, audio_b64: Union[str, bytes]) -> None: + """Append base64 audio, or encode raw bytes before appending.""" + self.send_event( + { + "type": "input_audio_buffer.append", + "audio": _as_base64(audio_b64), + }, + ) + + def append_video(self, video_b64: Union[str, bytes]) -> None: + """Append a base64 image frame, or encode raw image bytes.""" + self.send_event( + { + "type": "input_image_buffer.append", + "image": _as_base64(video_b64), + }, + ) + + def commit(self) -> None: + """Commit the appended audio and video.""" + self.send_event({"type": "input_audio_buffer.commit"}) + + def clear_appended_audio(self) -> None: + self.send_event({"type": "input_audio_buffer.clear"}) + + def create_item( + self, + item: Dict[str, Any], + *, + previous_item_id: Optional[str] = None, + ) -> None: + event = {"type": "conversation.item.create", "item": item} + if previous_item_id is not None: + event["previous_item_id"] = previous_item_id + self.send_event(event) + + def retrieve_item(self, item_id: str) -> None: + self.send_event( + { + "type": "conversation.item.retrieve", + "item_id": item_id, + }, + ) + + def delete_item(self, item_id: str) -> None: + self.send_event( + { + "type": "conversation.item.delete", + "item_id": item_id, + }, + ) + + def create_response( + self, + modalities: Optional[List[str]] = cast(Optional[List[str]], _UNSET), + instructions: Optional[str] = cast(Optional[str], _UNSET), + **kwargs: Any, + ) -> None: + """Send ``response.create``. + + ``**kwargs`` merges extra keys into the wire ``response`` object + (e.g. ``temperature``), applied after ``modalities``/ + ``instructions`` and preserved verbatim, including falsey values. + """ + response: Dict[str, Any] = {} + if instructions is not _UNSET: + response["instructions"] = instructions + elif self._protocol == "omni": + response["instructions"] = None + if modalities is not _UNSET and ( + modalities is not None or self._protocol != "omni" + ): + response["modalities"] = modalities + response.update(kwargs) + self.send_event({"type": "response.create", "response": response}) + + def cancel_response(self) -> None: + self.send_event({"type": "response.cancel"}) + + def end_session(self) -> None: + """Request finish for Omni/Live; Qwen-Audio must drain then close().""" + if self._protocol == "qwen_audio": + raise NotImplementedError( + "Qwen-Audio does not define session.finish; " + "read through response.done, then call close()", + ) + self.send_event({"type": "session.finish"}) + + def recv_event(self) -> Optional[Dict[str, Any]]: + """Receive a raw event; unfinished responses cannot end silently.""" + ws = self._ws + if ws is None: + self._check_unfinished() + if self._closed: + return None + raise RuntimeError("realtime session is not open") + from websocket import ( # pylint: disable=import-outside-toplevel + WebSocketConnectionClosedException, + ) + + try: + try: + raw = ws.recv() + except WebSocketConnectionClosedException: + raw = None + if raw is None or raw == "" or raw == b"": + self.close() + self._check_unfinished() + return None + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + event = json.loads(raw) + if not isinstance(event, dict): + raise ValueError("expected a JSON event object") + self.last_message = event + self._track_event(event) + if event.get("type") == "session.finished": + self._finished = True + self.close() + except BaseException: + self.close() + raise + if event.get("type") == "error": + error = event.get("error") or {} + if ( + error.get("type") != "invalid_request_error" + or self._finish_requested + ): + self.close() + raise error_from_business_code( + message=error.get("message") or "realtime error", + code=error.get("code"), + body=event, + ) + return event + + def _check_unfinished(self) -> None: + if self._finish_requested and not self._finished: + raise APIConnectionError( + "realtime connection closed before session.finished", + ) + if self._response_active and not self._finished: + raise APIConnectionError( + "realtime connection closed before response.done", + ) + + def _track_event(self, event: Dict[str, Any]) -> None: + """Track first-output delays in monotonic milliseconds per response.""" + kind = event.get("type") + if kind in {"session.created", "session.updated"}: + self.session_id = (event.get("session") or {}).get( + "id", + self.session_id, + ) + elif kind == "response.created": + self.last_response_id = (event.get("response") or {}).get("id") + self.last_response_create_time = time.monotonic() * 1000 + self.last_first_audio_delay = None + self.last_first_text_delay = None + self._response_active = True + elif kind == "response.done": + response_id = (event.get("response") or {}).get( + "id", + event.get("response_id"), + ) + if response_id is None or response_id == self.last_response_id: + self._response_active = False + elif ( + self._response_active + and self.last_response_create_time is not None + ): + response_id = event.get("response_id") + if ( + response_id is not None + and response_id != self.last_response_id + ): + return + if ( + kind == "response.audio.delta" + and self.last_first_audio_delay is None + ): + self.last_first_audio_delay = ( + time.monotonic() * 1000 - self.last_response_create_time + ) + elif ( + kind + in { + "response.text.delta", + "response.audio_transcript.delta", + "response.text.text", + "response.audio_transcript.text", + } + and self.last_first_text_delay is None + ): + self.last_first_text_delay = ( + time.monotonic() * 1000 - self.last_response_create_time + ) + + def events(self) -> Iterator[Dict[str, Any]]: + """Yield through session.finished, or until a normal close.""" + while True: + event = self.recv_event() + if event is None: + return + yield event + if event.get("type") == "session.finished": + return + + def close(self) -> None: + with self._lock: + ws, self._ws = self._ws, None + if ws is not None: + self._closed = True + _close_websocket(ws) + + +class Realtime: + """``client.audio.realtime``: Omni, Qwen-Audio and Livetranslate.""" + + def __init__(self, client) -> None: + self._client = client + + def connect( + self, + *, + model: str, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> RealtimeSession: + """Create a session; enter its context or call session.connect().""" + return RealtimeSession( + self._client, + model=model, + ws_url=ws_url, + connector=connector, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) diff --git a/dashscope/v2/resources/audio/translation.py b/dashscope/v2/resources/audio/translation.py new file mode 100644 index 00000000..3d097a3b --- /dev/null +++ b/dashscope/v2/resources/audio/translation.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.audio.translation resource (Gummy realtime speech translation). + +Same duplex wire as ``asr.recognize`` (task_group=audio, task=asr, +function=recognition) — the 1.x ``TranslationRecognizerRealtime`` and +``TranslationRecognizerChat`` pair rides it too +(translation_recognizer.py:451/987). This resource names the translation +parameters and adds the bilingual typed view; the chat models +(gummy-chat-v1) share the wire and use the same entry — 1.x Chat only +differs by dropping audio after the first sentence end, which callers +express through ``TranslationResult.is_sentence_end()``. + +The newer qwen3-livetranslate realtime models are a different protocol +and live under ``client.audio.realtime.connect`` instead. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Iterator, List, Mapping, Optional + +from dashscope.v2.resources.audio.asr import ( + Recognition, + RecognitionResult, + _stream_file_frames, +) +from dashscope.v2.transport.ws import EVENT_GENERATED, Connector +from dashscope.v2.transport.ws_connection import WsConnection + +__all__ = [ + "Translation", + "TranslationResult", + "TranslationSession", +] + + +class TranslationResult(RecognitionResult): + """Typed view over one bilingual ``result-generated`` payload. + + Gummy frames carry ``output.transcription`` (source-language + sentence) and/or ``output.translations`` (one dict per target + language, ``lang``/``text``/``words``/...), unlike the plain ASR + ``output.sentence`` shape ``RecognitionResult.get_sentence`` reads. + """ + + def get_transcription(self) -> Optional[Dict[str, Any]]: + """The source-language sentence dict, or None on this frame.""" + transcription = self.output.get("transcription") + return transcription if isinstance(transcription, dict) else None + + def get_translations(self) -> List[Dict[str, Any]]: + """Per-target-language sentence dicts (possibly empty).""" + translations = self.output.get("translations") + return translations if isinstance(translations, list) else [] + + def is_sentence_end(self) -> bool: + """True when transcription or any translation ends its sentence.""" + transcription = self.get_transcription() + if transcription and transcription.get("sentence_end"): + return True + return any( + item.get("sentence_end") for item in self.get_translations() + ) + + +def _results_from_events( + events: Iterator[Dict[str, Any]], +) -> Iterator[TranslationResult]: + for event in events: + if event["header"].get("event") != EVENT_GENERATED: + continue + data = dict(event.get("payload") or {}) + data.setdefault("request_id", event["header"].get("request_id")) + result = TranslationResult.model_validate(data) + transcription = result.get_transcription() + if transcription and transcription.get("heartbeat"): + continue + yield result + + +class TranslationSession(Recognition): + """Duplex translation session; lifecycle identical to ``Recognition``.""" + + def __init__( + self, + client, + *, + model: str, + format: str = "pcm", # pylint: disable=redefined-builtin + sample_rate: int = 16000, + source_language: Optional[str] = None, + transcription_enabled: Optional[bool] = None, + translation_enabled: Optional[bool] = None, + translation_target_languages: Optional[List[str]] = None, + max_end_silence: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + # pylint: disable-next=redefined-builtin + input: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> None: + merged = dict(parameters or {}) + # Named kwargs win over the ``parameters=`` escape hatch; the keys + # match the 1.x TranslationRecognizerRealtime wire names. Omitted + # parameters fall to the service defaults (transcription on, + # translation off, source language auto). + for key, value in ( + ("source_language", source_language), + ("transcription_enabled", transcription_enabled), + ("translation_enabled", translation_enabled), + ("translation_target_languages", translation_target_languages), + ("max_end_silence", max_end_silence), + ): + if value is not None: + merged[key] = value + super().__init__( + client, + model=model, + format=format, + sample_rate=sample_rate, + parameters=merged, + input=input, + phrase_id=phrase_id, + ws_url=ws_url, + connector=connector, + connection=connection, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + + def results(self) -> Iterator[TranslationResult]: + """Yield typed bilingual results until the task finishes. + + Non-result events (task-finished, ...) are skipped. Frames whose + ``transcription`` is only a heartbeat are dropped too — a v2 + convenience so callers see real sentences; unlike the plain-ASR + path, the 1.x translation recognizer forwards such frames to + ``on_event`` rather than filtering them. + """ + return _results_from_events(self.events()) + + +class Translation: + """``client.audio.translation``: realtime speech translation (Gummy).""" + + def __init__(self, client) -> None: + self._client = client + + def recognize( + self, + *, + model: str, + format: str = "pcm", # pylint: disable=redefined-builtin + sample_rate: int = 16000, + source_language: Optional[str] = None, + transcription_enabled: Optional[bool] = None, + translation_enabled: Optional[bool] = None, + translation_target_languages: Optional[List[str]] = None, + max_end_silence: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + # pylint: disable-next=redefined-builtin + input: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> TranslationSession: + """Open translation; send audio and consume events concurrently.""" + return TranslationSession( + self._client, + model=model, + format=format, + sample_rate=sample_rate, + source_language=source_language, + transcription_enabled=transcription_enabled, + translation_enabled=translation_enabled, + translation_target_languages=translation_target_languages, + max_end_silence=max_end_silence, + parameters=parameters, + input=input, + phrase_id=phrase_id, + ws_url=ws_url, + connector=connector, + connection=connection, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + + def recognize_file( + self, + path: str, + *, + model: str, + format: str = "pcm", # pylint: disable=redefined-builtin + sample_rate: int = 16000, + source_language: Optional[str] = None, + transcription_enabled: Optional[bool] = None, + translation_enabled: Optional[bool] = None, + translation_target_languages: Optional[List[str]] = None, + max_end_silence: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + phrase_id: Optional[str] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Iterator[TranslationResult]: + """Translate a local audio file, yielding typed bilingual results. + + The 1.x ``TranslationRecognizerRealtime.call(file)`` equivalent + (its ``TranslationRecognizerResultPack`` items arrive one + ``TranslationResult`` at a time): the file is auto-chunked and + driven to completion in one session. Unlike + ``asr.recognize_file`` this yields typed results — the bilingual + nesting is the point of this facade. Validation happens eagerly; + the session itself opens on first iteration. + """ + if not os.path.isfile(path): + raise ValueError(f"audio file not found: {path}") + if os.path.getsize(path) == 0: + raise ValueError(f"audio file is empty: {path}") + session = self.recognize( + model=model, + format=format, + sample_rate=sample_rate, + source_language=source_language, + transcription_enabled=transcription_enabled, + translation_enabled=translation_enabled, + translation_target_languages=translation_target_languages, + max_end_silence=max_end_silence, + parameters=parameters, + phrase_id=phrase_id, + ws_url=ws_url, + connector=connector, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + return _results_from_events(_stream_file_frames(session, path)) diff --git a/dashscope/v2/resources/audio/tts.py b/dashscope/v2/resources/audio/tts.py new file mode 100644 index 00000000..c2f03bb1 --- /dev/null +++ b/dashscope/v2/resources/audio/tts.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.audio.tts resource (design doc M3 3.5). + +Speech synthesis over one duplex websocket task (task_group=audio, +task=tts, function=SpeechSynthesizer). ``Tts.connect`` opens an +incremental session so text pieces can be fed as they arrive — the v2 +form of the 1.x SpeechSynthesizer ``streaming_call``/ +``streaming_complete`` pair. + +``Tts.http`` is the plain-HTTP sibling (no websocket): one-shot and +SSE-streaming synthesis for the sambert/cosyvoice and qwen-tts model +families, returning the audio as a URL reference. +""" + +from __future__ import annotations + +import threading +import time +from typing import ( + Any, + Dict, + Generator, + Iterator, + List, + Mapping, + Optional, + Union, +) + +from dashscope.v2.resources.audio.http_tts import HttpTts +from dashscope.v2.transport.ws import Connector, WsSession +from dashscope.v2.transport.ws_connection import WsConnection, WsTask + +__all__ = [ + "SynthesisSession", + "Tts", +] + +MODULE = "audio" + + +def _merged_parameters( + *, + voice: str, + format: str, # pylint: disable=redefined-builtin + sample_rate: int, + volume: Optional[int], + rate: Optional[float], + pitch: Optional[float], + language_hints: Optional[List[str]], + enable_ssml: Optional[bool], + parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + merged: Dict[str, Any] = { + "voice": voice, + "format": format, + "sample_rate": sample_rate, + "text_type": "PlainText", + } + if parameters: + merged.update(parameters) + # Named kwargs win over the ``parameters=`` escape hatch; the keys + # match the 1.x SpeechSynthesizer wire names. + for key, value in ( + ("volume", volume), + ("rate", rate), + ("pitch", pitch), + ("language_hints", language_hints), + ("enable_ssml", enable_ssml), + ): + if value is not None: + merged[key] = value + # 1.x tts_v2 rides enable_ssml as a plain parameter; text_type follows + # it ("PlainText" -> "SSML") so markup in the text is actually parsed. + if enable_ssml is not None: + merged["text_type"] = "SSML" if enable_ssml else "PlainText" + return merged + + +class SynthesisSession: + """Duplex synthesis with concurrent text input and one result iterator.""" + + def __init__( + self, + client, + *, + model: str = "cosyvoice-v1", + voice: str = "longxiaochun", + format: str = "mp3", # pylint: disable=redefined-builtin + sample_rate: int = 22050, + volume: Optional[int] = None, + rate: Optional[float] = None, + pitch: Optional[float] = None, + language_hints: Optional[List[str]] = None, + enable_ssml: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> None: + config = client.config + self._model = model + self._parameters = _merged_parameters( + voice=voice, + format=format, + sample_rate=sample_rate, + volume=volume, + rate=rate, + pitch=pitch, + language_hints=language_hints, + enable_ssml=enable_ssml, + parameters=parameters, + ) + # A shared connection runs this synthesis as one task on it; + # ws_url/connector/api_key/workspace/headers then belong to the + # connection and the per-session values are ignored. + self._connection = connection + self._session_kwargs = { + "url": ws_url, + "api_key": api_key or config.api_key, + "workspace": workspace or config.workspace, + "region": config.region, + "headers": dict(headers or {}), + "connector": connector, + "module": MODULE, + "timeout": config.timeout if timeout is None else timeout, + } + self._session: Union[WsSession, WsTask, None] = None + self._closed = False + self._reader_lock = threading.Lock() + self._first_text_at: Optional[float] = None + self.last_response: Optional[Dict[str, Any]] = None + self.usage: Optional[Dict[str, Any]] = None + self.first_package_delay: Optional[float] = None + + @property + def session(self) -> Union[WsSession, WsTask]: + """Underlying websocket session, including its ``task_id``.""" + if self._closed or self._session is None: + raise RuntimeError("synthesis session is not open") + return self._session + + def start(self) -> "SynthesisSession": + """Open the connection and wait for the task-started handshake.""" + if self._closed: + raise RuntimeError("synthesis session is closed") + if self._session is not None: + raise RuntimeError("synthesis session is already started") + try: + if self._connection is not None: + self._session = self._connection.open_task( + timeout=self._session_kwargs["timeout"], + ) + # Synthesis output is binary audio, which carries no task + # id on the wire; claim the downlink before it arrives. + self._session.claim_binary_downlink() + else: + self._session = WsSession(**self._session_kwargs) + response = self._session.run_task( + task_group="audio", + task="tts", + function="SpeechSynthesizer", + model=self._model, + parameters=self._parameters, + input={}, + ) + self._record_response(response) + except Exception as error: + self._record_response(getattr(error, "body", None)) + self.close() + raise + return self + + def __enter__(self) -> "SynthesisSession": + return self.start() + + def __exit__(self, *exc: Any) -> None: + self.close() + + def send_text(self, text: str) -> None: + """Send another text fragment; not allowed after stop or cancel.""" + if not isinstance(text, str): + raise TypeError("text must be a string") + try: + session = self.session + if self._first_text_at is None: + self._first_text_at = time.monotonic() + session.continue_task({"text": text}) + except Exception: + self.close() + raise + + def flush(self) -> None: + """Request synthesis of buffered text without ending input.""" + try: + self.session.continue_task({"flush": True}) + except Exception: + self.close() + raise + + def stop(self) -> None: + """End input without blocking for results; keep reading afterward.""" + try: + self.session.finish_task() + except Exception: + self.close() + raise + + def cancel(self) -> None: + """Cancel once, even after stop, before a terminal event arrives.""" + try: + self.session.finish_task({"directive": "cancel"}) + except Exception: + self.close() + raise + + def _record_response(self, response: Any) -> None: + if isinstance(response, dict): + self.last_response = response + payload = response.get("payload") + if isinstance(payload, dict) and payload.get("usage") is not None: + self.usage = payload["usage"] + + def events(self) -> Generator[Union[Dict[str, Any], bytes], None, None]: + """Yield JSON and audio through task-finished, raising on failure.""" + session = self.session + # The lock guards the iterator's whole lifetime and is released in + # its finally block, so a ``with`` scope cannot express it. + if not self._reader_lock.acquire( # pylint: disable=consider-using-with + blocking=False, + ): + raise RuntimeError( + "only one events() or audio_chunks() iterator may read " + "the synthesis session", + ) + try: + while not session.finished: + frame = self.session.recv() + if frame is None: + return + if isinstance(frame, bytes): + if ( + self.first_package_delay is None + and self._first_text_at is not None + ): + self.first_package_delay = ( + time.monotonic() - self._first_text_at + ) * 1000 + else: + self._record_response(frame) + yield frame + except Exception as error: + self._record_response(getattr(error, "body", None)) + self.close() + raise + finally: + self._reader_lock.release() + + def audio_chunks(self) -> Iterator[bytes]: + """Yield only audio, retaining JSON metadata on this session.""" + events = self.events() + try: + for frame in events: + if isinstance(frame, bytes): + yield frame + finally: + events.close() + + def close(self) -> None: + """Close the connection; a closed session cannot be restarted. + + With a shared ``WsConnection`` this only detaches the task; the + socket belongs to the connection and stays open. + """ + if not self._closed: + self._closed = True + if self._session is not None: + self._session.close() + + +class Tts: + """Speech synthesis (one-shot + streaming) over websocket.""" + + def __init__(self, client) -> None: + self._client = client + self.http = HttpTts(client) + + def connect( + self, + *, + model: str = "cosyvoice-v1", + voice: str = "longxiaochun", + format: str = "mp3", # pylint: disable=redefined-builtin + sample_rate: int = 22050, + volume: Optional[int] = None, + rate: Optional[float] = None, + pitch: Optional[float] = None, + language_hints: Optional[List[str]] = None, + enable_ssml: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> SynthesisSession: + """Create a duplex session; enter its context or call start(). + + With ``connection=`` the session runs as one task on the shared + socket and claims its binary downlink (one active synthesis per + connection). + """ + return SynthesisSession( + self._client, + model=model, + voice=voice, + format=format, + sample_rate=sample_rate, + volume=volume, + rate=rate, + pitch=pitch, + language_hints=language_hints, + enable_ssml=enable_ssml, + parameters=parameters, + ws_url=ws_url, + connector=connector, + connection=connection, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) + + def synthesize( + self, + text: str, + *, + model: str = "cosyvoice-v1", + voice: str = "longxiaochun", + format: str = "mp3", # pylint: disable=redefined-builtin + sample_rate: int = 22050, + volume: Optional[int] = None, + rate: Optional[float] = None, + pitch: Optional[float] = None, + language_hints: Optional[List[str]] = None, + enable_ssml: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> bytes: + """Synthesize the whole text; returns the concatenated audio.""" + return b"".join( + self.stream( + text, + model=model, + voice=voice, + format=format, + sample_rate=sample_rate, + volume=volume, + rate=rate, + pitch=pitch, + language_hints=language_hints, + enable_ssml=enable_ssml, + parameters=parameters, + ws_url=ws_url, + connector=connector, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ), + ) + + def stream( + self, + text: str, + *, + model: str = "cosyvoice-v1", + voice: str = "longxiaochun", + format: str = "mp3", # pylint: disable=redefined-builtin + sample_rate: int = 22050, + volume: Optional[int] = None, + rate: Optional[float] = None, + pitch: Optional[float] = None, + language_hints: Optional[List[str]] = None, + enable_ssml: Optional[bool] = None, + parameters: Optional[Dict[str, Any]] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + headers: Optional[Mapping[str, str]] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Iterator[bytes]: + """Yield audio chunks as they arrive.""" + with self.connect( + model=model, + voice=voice, + format=format, + sample_rate=sample_rate, + volume=volume, + rate=rate, + pitch=pitch, + language_hints=language_hints, + enable_ssml=enable_ssml, + parameters=parameters, + ws_url=ws_url, + connector=connector, + timeout=timeout, + headers=headers, + api_key=api_key, + workspace=workspace, + ) as session: + session.send_text(text) + session.stop() + yield from session.audio_chunks() diff --git a/dashscope/v2/resources/chat/__init__.py b/dashscope/v2/resources/chat/__init__.py new file mode 100644 index 00000000..60af2252 --- /dev/null +++ b/dashscope/v2/resources/chat/__init__.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.chat resource.""" + +from dashscope.v2.resources.chat.completions import ( + AsyncCompletions, + Completions, +) +from dashscope.v2.types.common import Usage +from dashscope.v2.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + Choice, + Delta, +) + +__all__ = [ + "AsyncChat", + "AsyncCompletions", + "Chat", + "ChatCompletion", + "ChatCompletionChunk", + "ChatCompletionMessage", + "Choice", + "Completions", + "Delta", + "Usage", +] + + +class Chat: + def __init__(self, client) -> None: + self.completions = Completions(client) + + +class AsyncChat: + def __init__(self, client) -> None: + self.completions = AsyncCompletions(client) diff --git a/dashscope/v2/resources/chat/completions.py b/dashscope/v2/resources/chat/completions.py new file mode 100644 index 00000000..17c2b1c2 --- /dev/null +++ b/dashscope/v2/resources/chat/completions.py @@ -0,0 +1,996 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""chat.completions resource (protocol-driven, see v2/protocols). + +Also the shared implementation behind ``multimodal.chat``: messages +carrying media parts route to the multimodal endpoint on their own. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from typing import Any, Dict, Iterator, List, Optional, Tuple, Union, overload + +from typing_extensions import Literal + +from dashscope.utils.param_utils import ParamUtil +from dashscope.v2.errors import response_error +from dashscope.v2.protocols.base import ChatRequest +from dashscope.v2.resources._oss import ( + OSS_RESOLVE_HEADERS, + upload_message_media, +) +from dashscope.v2.types.common import Usage, with_headers +from dashscope.v2.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + Choice, + SearchInfo, + StreamChoice, +) +from dashscope.v2.transport.sse import AsyncStream, Stream +from dashscope.v2.types.messages import ( + Message, + MessageInput, + ToolCall, + ToolCallFunction, + has_media_parts, + normalize_messages, +) + +__all__ = [ + "AsyncChatStream", + "AsyncCompletions", + "ChatStream", + "Completions", +] + + +def _endpoint( + client, + normalized: List[Message], + *, + multimodal: bool, +) -> Optional[str]: + """Endpoint override for one call; None keeps the adapter default. + + ``multimodal`` forces the media endpoint, which is the whole of + ``multimodal.chat``'s contract as an explicit escape hatch. Otherwise + media parts route there on their own and text-only conversations stay + on the text endpoint. Adapters without a separate media endpoint + translate parts on their own chat path, so they always get None. + """ + path = client.protocol.MULTIMODAL_PATH + if path is None or multimodal: + return path + if has_media_parts(normalized): + return path + return None + + +def _build_request( + client, + *, + multimodal: bool, + model: str, + messages: Union[str, List[MessageInput]], + stream: bool, + tools: Optional[List[Dict[str, Any]]], + temperature: Optional[float], + top_p: Optional[float], + max_tokens: Optional[int], + stop: Union[str, List[str], None], + seed: Optional[int], + repetition_penalty: Optional[float], + presence_penalty: Optional[float], + enable_thinking: Optional[bool], + response_format: Optional[Dict[str, Any]], + top_k: Optional[int], + tool_choice: Union[str, Dict[str, Any], None], + n: Optional[int], + logprobs: Optional[bool], + top_logprobs: Optional[int], + thinking_budget: Optional[int], + enable_search: Optional[bool], + search_options: Optional[Dict[str, Any]], + parallel_tool_calls: Optional[bool], + incremental_output: Optional[bool], + parameters: Optional[Dict[str, Any]], + api_key: Optional[str] = None, + workspace: Optional[str] = None, +) -> Tuple[ChatRequest, str, bool, bool]: + normalized = normalize_messages(messages) + # 1.x tags the whole chat family "aigc" (MultiModalConversation lives + # in dashscope.aigc); media routing does not change the module tag. + module = "aigc" + # incremental_output=False asks for cumulative snapshots. Most native + # models only stream incrementally, so (like 1.x) the wire keeps + # incremental_output=True and the SDK merges deltas client-side; the + # exempt models (tts/omni/qwen-deep-research) honor the flag + # server-side. Compatible protocols stream incremental-only, so the + # cumulative view is always client-side there. + merge_incremental = False + if stream and incremental_output is not None: + if client.protocol.name == "native": + server_side = not ParamUtil.should_modify_incremental_output(model) + if incremental_output is False and not server_side: + merge_incremental = True + # 1.x also forces the wire incremental while merging + # client-side (Generation sets parameters[ + # "incremental_output"] = True), so an escape-hatch + # parameters={"incremental_output": False} cannot ask the + # server for cumulative frames the merge would double. + parameters = { + **(parameters or {}), + "incremental_output": True, + } + else: + parameters = { + **(parameters or {}), + "incremental_output": incremental_output, + } + elif incremental_output is False: + merge_incremental = True + # The merge block above decided the wire value of incremental_output + # (native defaults it to True for streams and extra_parameters are + # applied last), so the resource layer knows the exact frame shape. + # A wire value of False means the server itself streams cumulative + # snapshots (escape-hatch-only, or the named flag on exempt models); + # the final message must then be each choice's last snapshot, not a + # concatenation of deltas. + server_cumulative = ( + stream + and not merge_incremental + and client.protocol.name == "native" + and (parameters or {}).get("incremental_output") is False + ) + # Local paths / file:// URIs in media parts upload to OSS first; only + # the native protocol resolves oss:// inputs (1.x behavior). The + # per-call key/workspace win so the upload lands in the same account + # and region the request runs under (1.x uploads with the call-level + # key too); the certificate is fetched from the client's own base_url. + uploaded = False + if client.protocol.name == "native": + uploaded = upload_message_media( + client, + normalized, + model, + api_key, + workspace, + ) + request = client.protocol.build_chat_request( + model=model, + messages=[m.model_dump(exclude_none=True) for m in normalized], + stream=stream, + tools=tools, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stop=stop, + seed=seed, + repetition_penalty=repetition_penalty, + presence_penalty=presence_penalty, + enable_thinking=enable_thinking, + response_format=response_format, + top_k=top_k, + tool_choice=tool_choice, + n=n, + logprobs=logprobs, + top_logprobs=top_logprobs, + thinking_budget=thinking_budget, + enable_search=enable_search, + search_options=search_options, + parallel_tool_calls=parallel_tool_calls, + extra_parameters=parameters, + path=_endpoint(client, normalized, multimodal=multimodal), + ) + if uploaded: + request = replace( + request, + headers={**request.headers, **OSS_RESOLVE_HEADERS}, + ) + return request, module, merge_incremental, server_cumulative + + +class ChatStream: + """Typed iterator over chat chunks with text/aggregate helpers. + + With ``merge_incremental=True`` every yielded chunk is a cumulative + snapshot (1.x ``incremental_output=False`` behavior), so + ``text_stream`` emits the full text so far on each step. + ``server_cumulative=True`` means the server itself streamed + cumulative snapshots (native wire ``incremental_output=False``), so + ``get_final_message`` takes each choice's last snapshot instead of + concatenating deltas. + """ + + def __init__( + self, + inner: Stream[ChatCompletionChunk], + *, + merge_incremental: bool = False, + server_cumulative: bool = False, + ) -> None: + self._inner = inner + self._merge = merge_incremental + self._server_cumulative = server_cumulative + + def __iter__(self) -> Iterator[ChatCompletionChunk]: + if self._merge: + return _accumulate_chunks(iter(self._inner)) + return iter(self._inner) + + @property + def text_stream(self) -> Iterator[str]: + for chunk in self: + delta = chunk.delta + if delta is not None and delta.content: + yield delta.content + + @property + def reasoning_stream(self) -> Iterator[str]: + for chunk in self: + delta = chunk.delta + if delta is not None and delta.reasoning_content: + yield delta.reasoning_content + + @property + def tool_call_stream(self) -> Iterator[List[ToolCall]]: + for chunk in self: + delta = chunk.delta + if delta is not None and delta.tool_calls: + yield delta.tool_calls + + def get_final_message(self) -> ChatCompletion: + if self._merge or self._server_cumulative: + final = _final_from_cumulative(list(self)) + else: + final = _aggregate(self) + final.headers = self.headers + return final + + @property + def headers(self) -> Dict[str, str]: + """Raw HTTP response headers of the stream's envelope.""" + return self._inner.headers + + def close(self) -> None: + self._inner.close() + + +class AsyncChatStream: + def __init__( + self, + inner: AsyncStream[ChatCompletionChunk], + *, + merge_incremental: bool = False, + server_cumulative: bool = False, + ) -> None: + self._inner = inner + self._merge = merge_incremental + self._server_cumulative = server_cumulative + + def __aiter__(self): + if self._merge: + return self._accumulated() + return self._inner.__aiter__() + + async def _accumulated(self): + acc: Dict[int, Dict[str, Any]] = {} + async for chunk in self._inner: + yield _cumulative_chunk(acc, chunk) + + @property + def text_stream(self): + return self._text_stream() + + async def _text_stream(self): + async for chunk in self: + delta = chunk.delta + if delta is not None and delta.content: + yield delta.content + + @property + def reasoning_stream(self): + return self._reasoning_stream() + + async def _reasoning_stream(self): + async for chunk in self: + delta = chunk.delta + if delta is not None and delta.reasoning_content: + yield delta.reasoning_content + + @property + def tool_call_stream(self): + return self._tool_call_stream() + + async def _tool_call_stream(self): + async for chunk in self: + delta = chunk.delta + if delta is not None and delta.tool_calls: + yield delta.tool_calls + + async def get_final_message(self) -> ChatCompletion: + chunks = [chunk async for chunk in self] + if self._merge or self._server_cumulative: + final = _final_from_cumulative(chunks) + else: + final = _aggregate(chunks) + final.headers = self.headers + return final + + @property + def headers(self) -> Dict[str, str]: + """Raw HTTP response headers of the stream's envelope.""" + return self._inner.headers + + async def aclose(self) -> None: + await self._inner.aclose() + + +def _concat_logprobs(existing, new): + """Concatenate streaming logprobs fragments. + + Each chunk's choice-level logprobs carries the entries for that + chunk's tokens, so the aggregated value is the ordered + concatenation of the list-valued keys (OpenAI shape: + ``{"content": [...], "refusal": [...]}``), aligning with the + concatenated content. Unknown shapes fall back to the latest + fragment rather than risking corruption. + """ + if existing is None: + return new + if new is None: + return existing + if isinstance(existing, dict) and isinstance(new, dict): + merged = dict(existing) + for key, value in new.items(): + if isinstance(value, list) and isinstance(merged.get(key), list): + merged[key] = merged[key] + value + else: + merged[key] = value + return merged + return new + + +def _merge_tool_calls( + existing: Optional[List[ToolCall]], + delta_calls: List[ToolCall], +) -> List[ToolCall]: + # OpenAI streams tool_calls as non-cumulative fragments addressed by + # ``index``; native/anthropic chunks carry cumulative snapshots. + if all(call.index is None for call in delta_calls): + return [call.model_copy(deep=True) for call in delta_calls] + merged = [call.model_copy(deep=True) for call in existing or []] + for call in delta_calls: + index = call.index or 0 + while len(merged) <= index: + merged.append(ToolCall(function=ToolCallFunction())) + target = merged[index] + if call.id: + target.id = call.id + if call.type: + target.type = call.type + if call.function.name: + target.function.name = ( + target.function.name or "" + ) + call.function.name + if call.function.arguments: + target.function.arguments += call.function.arguments + return merged + + +def _cumulative_chunk( + acc: Dict[int, Dict[str, Any]], + chunk: ChatCompletionChunk, +) -> ChatCompletionChunk: + """Fold one incremental chunk into per-choice cumulative snapshots.""" + choices = [] + for choice in chunk.choices: + slot = acc.setdefault( + choice.index, + { + "role": None, + "content": "", + "reasoning": "", + "signature": "", + "tool_calls": None, + "logprobs": None, + }, + ) + delta = choice.delta + if delta.role: + slot["role"] = delta.role + if delta.content: + slot["content"] += delta.content + if delta.reasoning_content: + slot["reasoning"] += delta.reasoning_content + if delta.reasoning_signature: + slot["signature"] += delta.reasoning_signature + if delta.tool_calls: + slot["tool_calls"] = _merge_tool_calls( + slot["tool_calls"], + delta.tool_calls, + ) + # Choice-level logprobs stream as per-frame fragments; the + # cumulative snapshot carries their ordered concatenation, + # mirroring the accumulated content. + slot["logprobs"] = _concat_logprobs( + slot["logprobs"], + (choice.model_extra or {}).get("logprobs"), + ) + # model_copy keeps provider extras the rebuild would otherwise + # drop (delta-level omni audio); accumulated logprobs override + # the current frame's fragment. + update: Dict[str, Any] = { + "delta": choice.delta.model_copy( + update={ + "role": slot["role"], + "content": slot["content"] or None, + "reasoning_content": slot["reasoning"] or None, + "reasoning_signature": slot["signature"] or None, + "tool_calls": slot["tool_calls"], + }, + ), + } + if slot["logprobs"] is not None: + update["logprobs"] = slot["logprobs"] + choices.append(choice.model_copy(update=update)) + return chunk.model_copy(update={"choices": choices}) + + +def _accumulate_chunks( + chunks: Iterator[ChatCompletionChunk], +) -> Iterator[ChatCompletionChunk]: + acc: Dict[int, Dict[str, Any]] = {} + for chunk in chunks: + yield _cumulative_chunk(acc, chunk) + + +def _final_from_cumulative( + chunks: List[ChatCompletionChunk], +) -> ChatCompletion: + """Final message for a cumulative stream: per choice, its last snapshot. + + The trailing frame may carry usage with an empty choices list (the + openai adapter always sets ``stream_options.include_usage``), and with + ``n > 1`` each choice's finish frame arrives separately, so the final + message cannot be read off the last chunk alone. + """ + if not chunks: + return ChatCompletion() + request_id = model = None + usage: Optional[Usage] = None + search_info: Optional[SearchInfo] = None + last_by_index: Dict[int, StreamChoice] = {} + for chunk in chunks: + request_id = chunk.request_id or request_id + model = chunk.model or model + if chunk.usage is not None: + usage = chunk.usage + if chunk.search_info is not None: + search_info = chunk.search_info + for choice in chunk.choices: + last_by_index[choice.index] = choice + choices = [] + for index in sorted(last_by_index): + choice = last_by_index[index] + message = ChatCompletionMessage( + role=choice.delta.role or "assistant", + content=choice.delta.content, + reasoning_content=choice.delta.reasoning_content, + reasoning_signature=choice.delta.reasoning_signature, + tool_calls=choice.delta.tool_calls, + **(choice.delta.model_extra or {}), + ) + choices.append( + Choice( + index=index, + finish_reason=choice.finish_reason, + message=message, + **(choice.model_extra or {}), + ), + ) + return ChatCompletion( + request_id=request_id, + model=model, + choices=choices, + usage=usage, + search_info=search_info, + ) + + +def _aggregate(chunks) -> ChatCompletion: # pylint: disable=too-many-branches + acc: Dict[int, Dict[str, Any]] = {} + request_id = model = None + usage: Optional[Usage] = None + search_info: Optional[SearchInfo] = None + for chunk in chunks: + request_id = chunk.request_id or request_id + model = chunk.model or model + if chunk.usage is not None: + usage = chunk.usage + if chunk.search_info is not None: + search_info = chunk.search_info + for choice in chunk.choices: + slot = acc.setdefault( + choice.index, + { + "content": [], + "reasoning": [], + "signature": [], + "message": {}, + "logprobs": None, + }, + ) + delta = choice.delta + if delta.role: + slot["message"]["role"] = delta.role + if delta.content: + slot["content"].append(delta.content) + if delta.reasoning_content: + slot["reasoning"].append(delta.reasoning_content) + if delta.reasoning_signature: + slot["signature"].append(delta.reasoning_signature) + if delta.tool_calls: + slot["message"]["tool_calls"] = _merge_tool_calls( + slot["message"].get("tool_calls"), + delta.tool_calls, + ) + logprobs = (choice.model_extra or {}).get("logprobs") + if logprobs is not None: + slot["logprobs"] = _concat_logprobs( + slot["logprobs"], + logprobs, + ) + if choice.finish_reason: + slot["message"]["finish_reason"] = choice.finish_reason + choices = [] + for index in sorted(acc): + slot = acc[index] + message = ChatCompletionMessage( + role=slot["message"].get("role", "assistant"), + content="".join(slot["content"]) or None, + reasoning_content="".join(slot["reasoning"]) or None, + reasoning_signature="".join(slot["signature"]) or None, + tool_calls=slot["message"].get("tool_calls"), + ) + extras: Dict[str, Any] = {} + if slot["logprobs"] is not None: + extras["logprobs"] = slot["logprobs"] + choices.append( + Choice( + index=index, + finish_reason=slot["message"].get("finish_reason"), + message=message, + **extras, + ), + ) + return ChatCompletion( + request_id=request_id, + model=model, + choices=choices, + usage=usage, + search_info=search_info, + ) + + +def _checked_completion( + client, + response, +) -> Optional[ChatCompletion]: + """Raise on failure, or return an error envelope in compat mode.""" + error = response_error(response) + if error is None: + 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), + ) + + +def _send( + client, + request: ChatRequest, + *, + stream: bool, + module: str, + merge_incremental: bool, + server_cumulative: bool, + api_key: Optional[str], + workspace: Optional[str], + timeout: Optional[float], +) -> Union[ChatCompletion, ChatStream]: + transport = client.transport + base_url = client.config.protocol_base_url + if stream: + response = transport.open_stream( + "POST", + request.path, + json=request.body, + headers=request.headers or None, + base_url=base_url, + api_key=api_key, + workspace=workspace, + sdk_module=module, + timeout=timeout, + ) + return ChatStream( + Stream(response, client.protocol.make_chunk_parser()), + merge_incremental=merge_incremental, + server_cumulative=server_cumulative, + ) + response = transport.request( + "POST", + request.path, + json=request.body, + headers=request.headers or None, + base_url=base_url, + api_key=api_key, + workspace=workspace, + sdk_module=module, + timeout=timeout, + ) + error_result = _checked_completion(client, response) + if error_result is not None: + return error_result + return with_headers( + client.protocol.parse_completion(response.json()), + response, + ) + + +async def _asend( + client, + request: ChatRequest, + *, + stream: bool, + module: str, + merge_incremental: bool, + server_cumulative: bool, + api_key: Optional[str], + workspace: Optional[str], + timeout: Optional[float], +) -> Union[ChatCompletion, AsyncChatStream]: + transport = client.transport + base_url = client.config.protocol_base_url + if stream: + response = await transport.open_stream( + "POST", + request.path, + json=request.body, + headers=request.headers or None, + base_url=base_url, + api_key=api_key, + workspace=workspace, + sdk_module=module, + timeout=timeout, + ) + return AsyncChatStream( + AsyncStream(response, client.protocol.make_chunk_parser()), + merge_incremental=merge_incremental, + server_cumulative=server_cumulative, + ) + response = await transport.request( + "POST", + request.path, + json=request.body, + headers=request.headers or None, + base_url=base_url, + api_key=api_key, + workspace=workspace, + sdk_module=module, + timeout=timeout, + ) + error_result = _checked_completion(client, response) + if error_result is not None: + return error_result + return with_headers( + client.protocol.parse_completion(response.json()), + response, + ) + + +class Completions: + def __init__(self, client) -> None: + self._client = client + + @overload + def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: Literal[True], + 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, + ) -> ChatStream: + ... + + @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, + ) -> ChatCompletion: + ... + + def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: bool = 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, + ) -> Union[ChatCompletion, ChatStream]: + built = _build_request( + self._client, + multimodal=False, + model=model, + messages=messages, + stream=stream, + tools=tools, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stop=stop, + seed=seed, + repetition_penalty=repetition_penalty, + presence_penalty=presence_penalty, + enable_thinking=enable_thinking, + response_format=response_format, + top_k=top_k, + tool_choice=tool_choice, + n=n, + logprobs=logprobs, + top_logprobs=top_logprobs, + thinking_budget=thinking_budget, + enable_search=enable_search, + search_options=search_options, + parallel_tool_calls=parallel_tool_calls, + incremental_output=incremental_output, + parameters=parameters, + api_key=api_key, + workspace=workspace, + ) + request, module, merge_incremental, server_cumulative = built + return _send( + self._client, + request, + stream=stream, + module=module, + merge_incremental=merge_incremental, + server_cumulative=server_cumulative, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + +class AsyncCompletions: + def __init__(self, client) -> None: + self._client = client + + @overload + async def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: Literal[True], + 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, + ) -> AsyncChatStream: + ... + + @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, + ) -> ChatCompletion: + ... + + async def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: bool = 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, + ) -> Union[ChatCompletion, AsyncChatStream]: + # The upload path is blocking (requests + getPolicy); run the whole + # request build off the event loop so it never stalls other tasks. + built = await asyncio.to_thread( + _build_request, + self._client, + multimodal=False, + model=model, + messages=messages, + stream=stream, + tools=tools, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stop=stop, + seed=seed, + repetition_penalty=repetition_penalty, + presence_penalty=presence_penalty, + enable_thinking=enable_thinking, + response_format=response_format, + top_k=top_k, + tool_choice=tool_choice, + n=n, + logprobs=logprobs, + top_logprobs=top_logprobs, + thinking_budget=thinking_budget, + enable_search=enable_search, + search_options=search_options, + parallel_tool_calls=parallel_tool_calls, + incremental_output=incremental_output, + parameters=parameters, + api_key=api_key, + workspace=workspace, + ) + request, module, merge_incremental, server_cumulative = built + return await _asend( + self._client, + request, + stream=stream, + module=module, + merge_incremental=merge_incremental, + server_cumulative=server_cumulative, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) diff --git a/dashscope/v2/resources/decision.py b/dashscope/v2/resources/decision.py new file mode 100644 index 00000000..a89f9519 --- /dev/null +++ b/dashscope/v2/resources/decision.py @@ -0,0 +1,287 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.decision resource (TypeSafe System One decision model). + +The decision model (``decision-model-preview``) answers typed questions +about a business ``state`` in one forward pass -- multi-choice +(``choice``), yes/no (``noul``) and ordinal scale (``score``) -- and +returns each answer with its probability distribution, plus a confidence +for ``choice`` / ``score``. It generates no text, so latency and cost do +not grow with output length. Typical uses: ticket routing, content +moderation, agent routing and result verification. + +Wire: ``POST {gateway}/compatible-mode/v1/systemone`` (no streaming, no +function calling, no web search). The endpoint lives under the +compatible-mode path on every gateway, independent of the client's chat +``protocol`` setting, so this resource derives its own base URL via +``compatible_mode_base_url`` instead of using ``Config.base_url`` +(native) or ``Config.protocol_base_url`` (chat-protocol dependent). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + +from dashscope.v2._config import Config, compatible_mode_base_url +from dashscope.v2.errors import response_error +from dashscope.v2.types.common import ResponseBase, Usage, with_headers + +_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 +# rides the same V2_MODULE_MARKER mechanism (emitted as "decision2"). +MODULE = "decision" + +__all__ = [ + "AsyncDecision", + "ChoiceQuestion", + "Decision", + "DecisionAnswer", + "DecisionResponse", + "NoulQuestion", + "QuestionInput", + "ScoreQuestion", +] + + +class ChoiceQuestion(BaseModel): + """A pick-one-of-N question (``type="choice"``). + + ``criteria`` maps each option name to its description (1-255 + options; give the full set and add an ``other`` fallback). + """ + + model_config = ConfigDict(extra="allow") + + type: Literal["choice"] = "choice" + instructions: Optional[str] = None + criteria: Dict[str, str] = Field(min_length=1, max_length=255) + + +class NoulQuestion(BaseModel): + """A yes/no judgement (``type="noul"``); the answer is P(yes). + + ``criteria`` may describe the two outcomes as + ``{"true": ..., "false": ...}`` and is optional. + """ + + model_config = ConfigDict(extra="allow") + + type: Literal["noul"] = "noul" + instructions: Optional[str] = None + criteria: Optional[Dict[str, str]] = None + + +class ScoreQuestion(BaseModel): + """An ordinal-scale rating (``type="score"``). + + ``criteria`` describes the levels from low to high (2-255 levels; + 3-7 clearly distinguishable levels work best). The answer is the + probability-weighted level index and may fall between two levels. + """ + + model_config = ConfigDict(extra="allow") + + type: Literal["score"] = "score" + instructions: Optional[str] = None + criteria: List[str] = Field(min_length=2, max_length=255) + + +# A question is given either as one of the typed models above or as a +# raw wire dict (``{"type": "choice", "criteria": {...}, ...}``); raw +# dicts pass through untouched, which keeps future question fields +# usable before the SDK models learn them. +QuestionInput = Union[ + ChoiceQuestion, + NoulQuestion, + ScoreQuestion, + Dict[str, Any], +] + +# ``state`` is the business state under decision: free text, a dialogue, +# or a structured object (serialized before the model sees it). +StateInput = Union[str, Dict[str, Any], List[Any]] + + +class DecisionAnswer(BaseModel): + """The answer to one question; the filled field follows ``type``. + + ``choice`` carries the selected option name, ``noul`` P(yes) with + 0=no and 1=yes, and ``score`` the probability-weighted level index + (e.g. ``1.43``). ``probabilities`` gives every option's / level's + probability (they sum to 1); ``confidence`` is returned for + ``choice`` / ``score`` only, and ``legend`` maps a ``score`` level + index back to its description. + """ + + model_config = ConfigDict(extra="allow") + + type: Optional[str] = None + choice: Optional[str] = None + noul: Optional[float] = None + score: Optional[float] = None + probabilities: Optional[Dict[str, float]] = None + confidence: Optional[float] = None + legend: Optional[Dict[str, str]] = None + + +class DecisionResponse(ResponseBase): + """One System One decision round; ``answers`` keys mirror the + request's question ids.""" + + model: Optional[str] = None + answers: Dict[str, DecisionAnswer] = {} + usage: Optional[Usage] = None + latency_ms: Optional[float] = None + + +def _parse(body: Dict[str, Any]) -> 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) + + +def _dump_question(question: QuestionInput) -> Dict[str, Any]: + if isinstance(question, BaseModel): + # exclude_none keeps an unset optional (noul criteria, missing + # instructions) off the wire instead of sending explicit nulls. + return question.model_dump(exclude_none=True) + return dict(question) + + +def _build_body( + *, + model: str, + state: StateInput, + questions: Dict[str, QuestionInput], +) -> Dict[str, Any]: + if not questions: + raise ValueError("questions must not be empty") + return { + "model": model, + "state": state, + "questions": { + key: _dump_question(question) + for key, question in questions.items() + }, + } + + +def _endpoint_base(config: Config) -> str: + # The System One endpoint sits under the compatible-mode path on + # every gateway; the chat protocol setting must not move it. + return compatible_mode_base_url( + config.workspace, + config.region, + config.configured_base_url, + ) + + +def _checked_response(client, response) -> DecisionResponse: + """Raise on failure, or fill the error envelope when not raising. + + A failed call (non-2xx, or HTTP 200 with a business ``code``) + carries its ``code`` / ``message`` in the error envelope -- nested + under ``error`` on this compatible-mode endpoint -- which the + success parser cannot see. Routing the reply through + ``response_error`` keeps ``raise_on_error=False`` reporting the + failure via ``is_error`` / ``code`` / ``message`` / ``request_id`` + / ``status_code`` instead of surfacing it as an empty success. + """ + error = response_error(response) + if error is None: + return with_headers(_parse(response.json()), response) + if client.raise_on_error: + raise error + return with_headers( + DecisionResponse( + request_id=error.request_id, + status_code=error.status_code, + code=error.code, + message=error.message, + is_error=True, + ), + response, + ) + + +class Decision: + """Synchronous decision-model calls (``client.decision``).""" + + def __init__(self, client) -> None: + self._client = client + + def create( + self, + *, + model: str, + state: StateInput, + questions: Dict[str, QuestionInput], + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> DecisionResponse: + """Run one decision round. + + Usage:: + + resp = client.decision.create( + model="decision-model-preview", + state={"ticket_id": "T-1001", "content": "..."}, + questions={ + "department": ChoiceQuestion( + instructions="Which team should handle this?", + criteria={"billing": "...", "technical": "..."}, + ), + "escalate": NoulQuestion( + instructions="Page the on-call engineer?", + ), + }, + ) + print(resp.answers["department"].choice) + """ + body = _build_body(model=model, state=state, questions=questions) + response = self._client.transport.request( + "POST", + _PATH, + json=body, + base_url=_endpoint_base(self._client.config), + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + return _checked_response(self._client, response) + + +class AsyncDecision: + """Asynchronous decision-model calls (``client.decision``).""" + + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> DecisionResponse: + body = _build_body(model=model, state=state, questions=questions) + response = await self._client.transport.request( + "POST", + _PATH, + json=body, + base_url=_endpoint_base(self._client.config), + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + return _checked_response(self._client, response) diff --git a/dashscope/v2/resources/deployments.py b/dashscope/v2/resources/deployments.py new file mode 100644 index 00000000..ba68dfa3 --- /dev/null +++ b/dashscope/v2/resources/deployments.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.deployments resource (design doc M3 3.6). + +Wire contract follows the 1.x ``Deployments`` REST surface: +``POST /deployments``, ``GET /deployments``, ``GET /deployments/{id}``, +``PUT /deployments/{id}/scale``, ``DELETE /deployments/{id}``, +``GET /deployments/{id}/stream`` (SSE deployment events). +""" + +from __future__ import annotations + +from typing import ( + Any, + AsyncIterator, + Dict, + Iterator, + List, + Optional, + Union, +) + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from dashscope.v2.errors import check_response +from dashscope.v2.resources._sse import ( + _aiter_sse, + _iter_sse, + _stream_error, + _try_json, +) +from dashscope.v2.types.common import ( + JobStatus, + ResponseBase, + flatten_envelope, + response_envelope, + with_headers, +) + +_BASE_PATH = "/deployments" + +# 1.x tags this API "finetune" (Deployments lives in dashscope.finetune). +MODULE = "finetune" + +__all__ = [ + "AsyncDeployments", + "Deployment", + "DeploymentEvent", + "DeploymentList", + "Deployments", +] + + +class Deployment(ResponseBase): + """One model deployment. + + The same type comes back from ``create`` / ``get`` / ``scale`` and is + nested in ``DeploymentList.deployments``, so there is one shape to learn; + the response envelope therefore rides along on the nested copies too, + empty there because the server reports it once per response. + """ + + deployed_model: Optional[str] = None + model: Optional[str] = Field( + None, + validation_alias=AliasChoices("model", "model_name"), + ) + base_model: Optional[str] = None + status: Optional[str] = None + capacity: Optional[int] = None + base_capacity: Optional[int] = None + ready_capacity: Optional[int] = None + workspace_id: Optional[str] = None + charge_type: Optional[str] = None + created_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("created_at", "gmt_create"), + ) + updated_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("updated_at", "gmt_modified"), + ) + modifier: Optional[str] = None + creator: Optional[str] = None + + +class DeploymentList(ResponseBase): + page_no: int = 1 + page_size: int = 10 + total_count: int = 0 + deployments: List[Deployment] = [] + + +class DeploymentEvent(BaseModel): + """One SSE frame from ``/deployments/{id}/stream``.""" + + model_config = ConfigDict(extra="allow") + + output: Union[Dict[str, Any], str, None] = None + is_error: bool = False + + +def _parse(body: Dict[str, Any]) -> Deployment: + return Deployment(**flatten_envelope(body)) + + +def _parse_list(body: Dict[str, Any]) -> 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), + ) + + +def _build_create_body( + model: str, + capacity: int, + model_version: Optional[str], + suffix: Optional[str], + plan: Optional[str], + template_id: Optional[str], + deploy_spec: Optional[Dict[str, Any]], + billing_method: Optional[str], +) -> Dict[str, Any]: + # Wire spellings; the public names are ``model`` / ``model_version``. + body: Dict[str, Any] = {"model_name": model, "capacity": capacity} + if model_version is not None: + body["model_version"] = model_version + if suffix is not None: + body["suffix"] = suffix + if plan is not None: + body["plan"] = plan + if template_id is not None: + body["template_id"] = template_id + + # MU deployments reject the create call unless both are present. + if deploy_spec is not None: + body["deploy_spec"] = deploy_spec + if billing_method is not None: + body["billing_method"] = billing_method + return body + + +class Deployments: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> Deployment: + body = _build_create_body( + model, + capacity, + model_version, + suffix, + plan, + template_id, + deploy_spec, + billing_method, + ) + response = self._client.transport.request( + "POST", + _BASE_PATH, + json=body, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + def get( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Deployment: + response = self._client.transport.request( + "GET", + f"{_BASE_PATH}/{deployed_model}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> DeploymentList: + response = self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + def scale( + self, + deployed_model: str, + *, + capacity: int, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Deployment: + response = self._client.transport.request( + "PUT", + f"{_BASE_PATH}/{deployed_model}/scale", + json={"deployed_model": deployed_model, "capacity": capacity}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + def delete( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{deployed_model}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) + + def stream_events( + self, + deployed_model_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Iterator[DeploymentEvent]: + response = self._client.transport.open_stream( + "GET", + f"{_BASE_PATH}/{deployed_model_id}/stream", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + try: + for is_error, status_code, data in _iter_sse(response): + if is_error: + if self._client.raise_on_error: + raise _stream_error(status_code, data) + yield DeploymentEvent( + output=_try_json(data), + is_error=True, + ) + return + yield DeploymentEvent(output=_try_json(data)) + finally: + response.close() + + +class AsyncDeployments: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> Deployment: + body = _build_create_body( + model, + capacity, + model_version, + suffix, + plan, + template_id, + deploy_spec, + billing_method, + ) + response = await self._client.transport.request( + "POST", + _BASE_PATH, + json=body, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + async def get( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Deployment: + response = await self._client.transport.request( + "GET", + f"{_BASE_PATH}/{deployed_model}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + 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, + ) -> DeploymentList: + response = await self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + async def scale( + self, + deployed_model: str, + *, + capacity: int, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Deployment: + response = await self._client.transport.request( + "PUT", + f"{_BASE_PATH}/{deployed_model}/scale", + json={"deployed_model": deployed_model, "capacity": capacity}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + async def delete( + self, + deployed_model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = await self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{deployed_model}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + JobStatus(**flatten_envelope(response.json())), + response, + ) + + async def stream_events( + self, + deployed_model_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> AsyncIterator[DeploymentEvent]: + response = await self._client.transport.open_stream( + "GET", + f"{_BASE_PATH}/{deployed_model_id}/stream", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + try: + async for is_error, status_code, data in _aiter_sse(response): + if is_error: + if self._client.raise_on_error: + raise _stream_error(status_code, data) + yield DeploymentEvent( + output=_try_json(data), + is_error=True, + ) + return + yield DeploymentEvent(output=_try_json(data)) + finally: + await response.aclose() diff --git a/dashscope/v2/resources/embeddings.py b/dashscope/v2/resources/embeddings.py new file mode 100644 index 00000000..f5614de5 --- /dev/null +++ b/dashscope/v2/resources/embeddings.py @@ -0,0 +1,429 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.embeddings resource (native text embedding + batch jobs). + +Multimodal embedding lives at ``client.multimodal.embeddings``. +""" + +# "input" is the public API parameter name (mirrors OpenAI embeddings). +# pylint: disable=redefined-builtin + +from __future__ import annotations + +from typing import Any, Awaitable, Callable, Dict, List, Optional, Union + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + +from dashscope.v2.errors import check_response +from dashscope.v2.resources._jobs import ( + DEFAULT_MAX_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + AsyncJobs, + Job, + JobList, + Jobs, +) +from dashscope.v2.types.common import ( + ResponseBase, + Usage, + response_envelope, + with_headers, +) + +_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``). +MODULE = "embeddings" + +__all__ = [ + "AsyncEmbeddings", + "Embedding", + "EmbeddingResponse", + "Embeddings", +] + + +class Embedding(BaseModel): + """One embedding vector; ``type`` says which modality produced it.""" + + model_config = ConfigDict(extra="allow") + + # The text endpoint reports the position as "text_index"; every other + # endpoint calls it "index". One public name, both wire spellings. + index: int = Field(0, validation_alias=AliasChoices("index", "text_index")) + type: Optional[str] = None + embedding: List[float] = [] + + +class EmbeddingResponse(ResponseBase): + embeddings: List[Embedding] = [] + 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 {} + embeddings = output.get("embeddings") or [] + if not embeddings and output.get("embedding"): + # multimodal-embedding-one-peace-v1 predates the plural list: it + # fuses every content into one vector returned as output.embedding. + # Surface it as item 0 of the canonical list so callers read one + # 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), + ) + + +def _build_body( + *, + model: str, + input: Union[str, List[str]], + text_type: Optional[str], + dimension: Optional[int], + output_type: Optional[str], + instruct: Optional[str], + parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + texts = [input] if isinstance(input, str) else list(input) + if not texts: + raise ValueError("input must not be empty") + merged: Dict[str, Any] = {} + for key, value in ( + ("text_type", text_type), + ("dimension", dimension), + ("output_type", output_type), + ("instruct", instruct), + ): + if value is not None: + merged[key] = value + if parameters: + merged.update(parameters) + return { + "model": model, + "input": {"texts": texts}, + "parameters": merged, + } + + +class EmbeddingJobs: + """Async batch embedding task facade over the Jobs infra.""" + + def __init__(self, client) -> None: + self._jobs = Jobs(client, _BATCH_SERVICE, MODULE) + + 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, + ) -> Job: + """Submit a batch task over one file whose lines are the texts. + + Singular, unlike ``asr.transcribe(file_urls=)``: this endpoint + accepts exactly one file. + """ + if not file_url: + raise ValueError("file_url must not be empty") + merged = dict(parameters or {}) + if text_type is not None: + merged["text_type"] = text_type + return self._jobs.create( + model=model, + input={"url": file_url}, + parameters=merged or None, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return self._jobs.fetch( + task_id, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + return self._jobs.cancel( + task_id, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + 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, + ) -> JobList: + """List async tasks (``GET /tasks``).""" + return self._jobs.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=page_no, + page_size=page_size, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Any]] = None, + ) -> Job: + return self._jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class AsyncEmbeddingJobs: + def __init__(self, client) -> None: + self._jobs = AsyncJobs(client, _BATCH_SERVICE, MODULE) + + 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, + ) -> Job: + if not file_url: + raise ValueError("file_url must not be empty") + merged = dict(parameters or {}) + if text_type is not None: + merged["text_type"] = text_type + return await self._jobs.create( + model=model, + input={"url": file_url}, + parameters=merged or None, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return await self._jobs.fetch( + task_id, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + return await self._jobs.cancel( + task_id, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + 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, + ) -> JobList: + """List async tasks (``GET /tasks``).""" + return await self._jobs.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=page_no, + page_size=page_size, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + async def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> Job: + return await self._jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class Embeddings: + def __init__(self, client) -> None: + self._client = client + self.jobs = EmbeddingJobs(client) + + 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, + ) -> EmbeddingResponse: + body = _build_body( + model=model, + input=input, + text_type=text_type, + dimension=dimension, + output_type=output_type, + instruct=instruct, + parameters=parameters, + ) + response = self._client.transport.request( + "POST", + _PATH, + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + parse_embedding_response(response.json()), + response, + ) + + +class AsyncEmbeddings: + def __init__(self, client) -> None: + self._client = client + self.jobs = AsyncEmbeddingJobs(client) + + 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, + ) -> EmbeddingResponse: + body = _build_body( + model=model, + input=input, + text_type=text_type, + dimension=dimension, + output_type=output_type, + instruct=instruct, + parameters=parameters, + ) + response = await self._client.transport.request( + "POST", + _PATH, + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + parse_embedding_response(response.json()), + response, + ) diff --git a/dashscope/v2/resources/files.py b/dashscope/v2/resources/files.py new file mode 100644 index 00000000..3d1b94e2 --- /dev/null +++ b/dashscope/v2/resources/files.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.files resource (upload/list/get/delete).""" + +from __future__ import annotations + +import json +import os +import re +from typing import Any, Dict, List, Optional + +from pydantic import AliasChoices, Field + +from dashscope.v2.errors import ( + BadRequestError, + NotFoundError, + check_response, + response_error, +) +from dashscope.v2.types.common import ( + ResponseBase, + response_envelope, + with_headers, +) + +_BASE_PATH = "/files" + +MODULE = "files" + +# DashScope reports a file that does not exist as a 400-family error (wire +# code ``InvalidParameter``, message "File not found.") rather than a 404, so +# the catalog-driven mapper in ``errors`` yields ``BadRequestError``. The +# message is the only field that states the file is missing; ``get``/``delete`` +# match it narrowly to surface ``NotFoundError`` instead. English-only by +# design -- the v2 tree stays ASCII and the gateway's documented text is +# English. +_FILE_NOT_FOUND = re.compile( + r"not\s+found|not\s+exist|no\s+such\s+file", + re.IGNORECASE, +) + +__all__ = [ + "AsyncFiles", + "DeletionStatus", + "File", + "FileList", + "Files", +] + + +class File(ResponseBase): + """One uploaded file. + + Returned on its own by ``upload`` / ``get`` and nested in + ``FileList.files``; both roles share one shape. The envelope is filled in + on the standalone result and stays empty on the nested copies, because + the server reports it once per response rather than once per item. + """ + + file_id: Optional[str] = None + name: Optional[str] = None + purpose: Optional[str] = None + size: Optional[int] = None + uploaded_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("uploaded_at", "upload_time"), + ) + + +class FileList(ResponseBase): + page_no: int = 1 + page_size: int = 10 + total_count: int = 0 + files: List[File] = [] + + +class DeletionStatus(ResponseBase): + deleted: bool = False + + +def _parse_upload(body: Dict[str, Any]) -> File: + data = body.get("data") or {} + if not isinstance(data, dict): + return File(**response_envelope(body)) + 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)) + + +def _parse_list(body: Dict[str, Any]) -> FileList: + data = body.get("data") or {} + if isinstance(data, dict): + files = data.get("files") or [] + 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), + ) + + +def _parse_file(body: Dict[str, Any]) -> File: + data = body.get("data") or {} + if not isinstance(data, dict): + return File(**response_envelope(body)) + return File(**data, **response_envelope(body)) + + +def _parse_delete(body: Dict[str, Any]) -> 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)) + + +def _is_valid_jsonl(file_path: str) -> bool: + with open(file_path, encoding="utf-8") as f: + for line in f: + try: + json.loads(line) + except json.decoder.JSONDecodeError: + return False + return True + + +def _validate_upload(file_path: str, purpose: str) -> None: + if purpose in ("fine-tune", "fine_tune"): + if not _is_valid_jsonl(file_path): + raise ValueError( + f"file {file_path} is not in valid jsonl format", + ) + if not os.path.isfile(file_path): + raise FileNotFoundError(f"file not found: {file_path}") + + +def _upload_form(file_path: str, description: Optional[str]): + data = {} + if description is not None: + data["descriptions"] = description + with open(file_path, "rb") as f: + # Bytes, not a live handle: transport retries must be able + # to re-send the full body. + content = f.read() + return data, {"files": (os.path.basename(file_path), content)} + + +def _require_file_id(file_id: str) -> None: + # An empty id would collapse the path to "/files/", silently hitting + # the list endpoint (or a confusing gateway error); fail locally. + if not file_id or not file_id.strip(): + raise ValueError("file_id must be a non-empty string") + + +def _check_by_id_response(response, *, raise_on_error: bool) -> None: + """``check_response`` for a by-id ``get``/``delete``. + + A missing file arrives as a 400 ``InvalidParameter`` ("File not + found."), which the catalog mapper turns into ``BadRequestError``. + Because these two calls address exactly one ``file_id``, that response + can only mean "no such file", so it is re-raised as ``NotFoundError``. + This is a deliberate refinement *outside* the catalog: the crosswalk + maps ``InvalidParameter`` to ``BadRequestError``, and the registry has + no file-not-found entry to route through (it is generated, DO-NOT-EDIT). + So unlike ``models.get`` -- catalog-legal because it re-uses the + ``Model.NotExist`` wire code -- the class is picked here directly. The + server's real ``status_code`` is preserved (never an invented 404) and + every other failure propagates unchanged. + """ + error = response_error(response) + if error is None or not raise_on_error: + return + if isinstance(error, BadRequestError) and _FILE_NOT_FOUND.search( + error.message or "", + ): + raise NotFoundError( + error.message, + code=error.code, + status_code=error.status_code, + request_id=error.request_id, + body=error.body, + headers=error.headers, + ) + raise error + + +class Files: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> File: + _validate_upload(file_path, purpose) + data, files = _upload_form(file_path, description) + response = self._client.transport.request( + "POST", + _BASE_PATH, + data=data, + files=files, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_upload(response.json()), response) + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> FileList: + response = self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + def get( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> File: + _require_file_id(file_id) + response = self._client.transport.request( + "GET", + f"{_BASE_PATH}/{file_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + _check_by_id_response( + response, + raise_on_error=self._client.raise_on_error, + ) + return with_headers(_parse_file(response.json()), response) + + def delete( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> DeletionStatus: + _require_file_id(file_id) + response = self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{file_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + _check_by_id_response( + response, + raise_on_error=self._client.raise_on_error, + ) + return with_headers(_parse_delete(response.json()), response) + + +class AsyncFiles: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> File: + _validate_upload(file_path, purpose) + data, files = _upload_form(file_path, description) + response = await self._client.transport.request( + "POST", + _BASE_PATH, + data=data, + files=files, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_upload(response.json()), response) + + 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, + ) -> FileList: + response = await self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + async def get( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> File: + _require_file_id(file_id) + response = await self._client.transport.request( + "GET", + f"{_BASE_PATH}/{file_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + _check_by_id_response( + response, + raise_on_error=self._client.raise_on_error, + ) + return with_headers(_parse_file(response.json()), response) + + async def delete( + self, + file_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> DeletionStatus: + _require_file_id(file_id) + response = await self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{file_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + _check_by_id_response( + response, + raise_on_error=self._client.raise_on_error, + ) + return with_headers(_parse_delete(response.json()), response) diff --git a/dashscope/v2/resources/fine_tunes.py b/dashscope/v2/resources/fine_tunes.py new file mode 100644 index 00000000..e9551869 --- /dev/null +++ b/dashscope/v2/resources/fine_tunes.py @@ -0,0 +1,691 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.fine_tunes resource (design doc M3 3.6). + +Wire contract follows the 1.x ``FineTunes`` REST surface, except that +``create`` names the produced model with ``model_name`` (1.x sent the +response-only ``finetuned_output``, which current servers reject): +``POST /fine-tunes``, ``GET /fine-tunes``, ``GET /fine-tunes/{id}``, +``POST /fine-tunes/{id}/cancel``, ``DELETE /fine-tunes/{id}``, +``GET /fine-tunes/{id}/logs``, ``GET /fine-tunes/{id}/stream`` (SSE job +events). +""" + +from __future__ import annotations + +import asyncio +import time +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, + Iterator, + List, + Optional, + Union, +) + +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + field_validator, +) + +from dashscope.v2.errors import ( + DashScopeError, + check_response, + error_from_business_code, +) +from dashscope.v2.resources._jobs import ( + CANCELED, + DEFAULT_MAX_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + FAILED, + SUCCEEDED, +) +from dashscope.v2.resources._sse import ( + _aiter_sse, + _iter_sse, + _stream_error, + _try_json, +) +from dashscope.v2.types.common import ( + JobStatus, + ResponseBase, + Usage, + flatten_envelope, + response_envelope, + with_headers, +) + +_BASE_PATH = "/fine-tunes" + +MODULE = "finetune" + +__all__ = [ + "AsyncFineTunes", + "FineTune", + "FineTuneEvent", + "FineTuneList", + "FineTunes", + "JobLogs", +] + +_TERMINAL = frozenset({SUCCEEDED, FAILED, CANCELED}) + + +class FineTune(ResponseBase): + """One fine-tune job. + + The same type comes back from ``create`` / ``get`` and is nested in + ``FineTuneList.jobs``, so there is one shape to learn. The server reports + a failure ``code`` per job, so the envelope is meaningful in both roles: + a failed job in a list flags itself through ``is_error`` as well. + """ + + job_id: Optional[str] = None + job_name: Optional[str] = None + status: Optional[str] = None + model: Optional[str] = None + base_model: Optional[str] = None + finetuned_model: Optional[str] = Field( + None, + validation_alias=AliasChoices("finetuned_model", "finetuned_output"), + ) + training_file_ids: List[str] = [] + validation_file_ids: List[str] = [] + hyper_parameters: Dict[str, Any] = {} + training_type: Optional[str] = None + created_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("created_at", "create_time"), + ) + finished_at: Optional[str] = Field( + None, + validation_alias=AliasChoices("finished_at", "end_time"), + ) + user_identity: Optional[str] = None + modifier: Optional[str] = None + creator: Optional[str] = None + group: Optional[str] = None + usage: Optional[Usage] = None + + @property + def task_id(self) -> Optional[str]: + """``Job`` spells the same identifier ``task_id``.""" + return self.job_id + + @property + def succeeded(self) -> bool: + return self.status == SUCCEEDED + + @property + def finished(self) -> bool: + return self.status in _TERMINAL + + @field_validator("usage", mode="before") + @classmethod + def _usage_from_count(cls, value: Any) -> Any: + # Fine-tune jobs may report usage as a bare token count rather than + # the usual object. + if isinstance(value, (int, float)) and not isinstance(value, bool): + return {"total_tokens": int(value)} + return value + + +class FineTuneList(ResponseBase): + page_no: int = 1 + page_size: int = 10 + total_count: int = 0 + jobs: List[FineTune] = [] + + +class JobLogs(ResponseBase): + """Training logs of one job, from ``fine_tunes.logs``.""" + + job_id: Optional[str] = None + content: str = "" + + @field_validator("content", mode="before") + @classmethod + def _content_from_lines(cls, value: Any) -> Any: + # The server may return logs either as a single string or as an + # array of lines; normalize the array form into newline-joined text + # so the public ``content`` contract stays a plain string. + if isinstance(value, (list, tuple)): + return "\n".join( + "" if item is None else str(item) for item in value + ) + return value + + +class FineTuneEvent(BaseModel): + """One SSE frame from ``/fine-tunes/{id}/stream``.""" + + model_config = ConfigDict(extra="allow") + + output: Union[Dict[str, Any], str, None] = None + is_error: bool = False + + +def _parse_job(body: Dict[str, Any]) -> FineTune: + return FineTune(**flatten_envelope(body)) + + +def _parse_list(body: Dict[str, Any]) -> 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), + ) + + +def _parse_status(body: Dict[str, Any]) -> JobStatus: + return JobStatus(**flatten_envelope(body)) + + +def _parse_logs(job_id: str, body: Dict[str, Any]) -> JobLogs: + data = flatten_envelope(body) + return JobLogs( + job_id=job_id, + content=data.get("logs") or "", + **response_envelope(data), + ) + + +def _job_id(job: Union[FineTune, str]) -> str: + job_id = job.job_id if isinstance(job, FineTune) else job + if not job_id: + raise DashScopeError("fine-tune job id required") + return job_id + + +def _build_create_body( + model: str, + training_file_ids: Union[List[str], str], + validation_file_ids: Union[List[str], str, None], + mode: Optional[str], + hyper_parameters: Optional[Dict[str, Any]], + finetuned_model: Optional[str], +) -> Dict[str, Any]: + if isinstance(training_file_ids, str): + training_file_ids = [training_file_ids] + if validation_file_ids and isinstance(validation_file_ids, str): + validation_file_ids = [validation_file_ids] + body: Dict[str, Any] = { + "model": model, + "training_file_ids": training_file_ids, + "validation_file_ids": validation_file_ids, + "hyper_parameters": hyper_parameters or {}, + } + if mode is not None: + body["training_type"] = mode + if finetuned_model is not None: + # The server names the produced model via ``model_name``; the + # response-only ``finetuned_output`` cannot be specified on create. + body["model_name"] = finetuned_model + return body + + +class FineTunes: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> FineTune: + body = _build_create_body( + model, + training_file_ids, + validation_file_ids, + mode, + hyper_parameters, + finetuned_model, + ) + response = self._client.transport.request( + "POST", + _BASE_PATH, + json=body, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_job(response.json()), response) + + def get( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> FineTune: + response = self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_job(response.json()), response) + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> FineTuneList: + response = self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = self._client.transport.request( + "POST", + f"{_BASE_PATH}/{job_id}/cancel", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + 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, + ) -> JobLogs: + response = self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}/logs", + # The wire parameter is singular "line". + params={"offset": offset, "line": lines}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_logs(job_id, response.json()), response) + + def stream_events( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Iterator[FineTuneEvent]: + response = self._client.transport.open_stream( + "GET", + f"{_BASE_PATH}/{job_id}/stream", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + try: + for is_error, status_code, data in _iter_sse(response): + if is_error: + if self._client.raise_on_error: + raise _stream_error(status_code, data) + yield FineTuneEvent(output=_try_json(data), is_error=True) + return + yield FineTuneEvent(output=_try_json(data)) + finally: + response.close() + + def wait( + self, + job: Union[FineTune, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Any]] = None, + ) -> FineTune: + """Poll until the job reaches a terminal status. + + With ``raise_on_error=False`` a transport-level failure comes + back as the :class:`FineTune` envelope instead of raising. + ``_sleep`` is a test seam — it replaces the pause between polls. + """ + job_id = _job_id(job) + sleep_fn = _sleep or time.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, FineTune) + else self.get(job_id, api_key=api_key, workspace=workspace) + ) + interval = poll_interval + while not current.finished: + # Compat mode (raise_on_error=False): a transport-level failure + # comes back as an error envelope with no job status, which + # would otherwise poll until the timeout. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for" + f" fine-tune job {job_id}", + ) + sleep_fn(min(interval, remaining)) + interval = min(interval * 2, max_interval) + current = self.get(job_id, api_key=api_key, workspace=workspace) + return self._check_terminal(current, job_id) + + def _check_terminal(self, job: FineTune, job_id: str) -> FineTune: + if job.status == FAILED and self._client.raise_on_error: + raise error_from_business_code( + message=job.message or f"fine-tune job {job_id} failed", + code=job.code, + # ``headers`` is transport metadata, not wire body. + body=job.model_dump(exclude={"headers"}), + headers=job.headers, + ) + if job.status == CANCELED and self._client.raise_on_error: + raise DashScopeError( + f"fine-tune job {job_id} was canceled while waiting", + request_id=job.request_id, + ) + return job + + +class AsyncFineTunes: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> FineTune: + body = _build_create_body( + model, + training_file_ids, + validation_file_ids, + mode, + hyper_parameters, + finetuned_model, + ) + response = await self._client.transport.request( + "POST", + _BASE_PATH, + json=body, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_job(response.json()), response) + + async def get( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> FineTune: + response = await self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_job(response.json()), response) + + 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, + ) -> FineTuneList: + response = await self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + async def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = await self._client.transport.request( + "POST", + f"{_BASE_PATH}/{job_id}/cancel", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + async def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = await self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + 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, + ) -> JobLogs: + response = await self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}/logs", + # The wire parameter is singular "line". + params={"offset": offset, "line": lines}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_logs(job_id, response.json()), response) + + async def stream_events( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> AsyncIterator[FineTuneEvent]: + response = await self._client.transport.open_stream( + "GET", + f"{_BASE_PATH}/{job_id}/stream", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + try: + async for is_error, status_code, data in _aiter_sse(response): + if is_error: + if self._client.raise_on_error: + raise _stream_error(status_code, data) + yield FineTuneEvent( + output=_try_json(data), + is_error=True, + ) + return + yield FineTuneEvent(output=_try_json(data)) + finally: + await response.aclose() + + async def wait( + self, + job: Union[FineTune, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> FineTune: + job_id = _job_id(job) + sleep_fn = _sleep or asyncio.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, FineTune) + else await self.get( + job_id, + api_key=api_key, + workspace=workspace, + ) + ) + interval = poll_interval + while not current.finished: + # Compat mode (raise_on_error=False): a transport-level failure + # comes back as an error envelope with no job status, which + # would otherwise poll until the timeout. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for" + f" fine-tune job {job_id}", + ) + await sleep_fn(min(interval, remaining)) + interval = min(interval * 2, max_interval) + current = await self.get( + job_id, + api_key=api_key, + workspace=workspace, + ) + return self._check_terminal(current, job_id) + + def _check_terminal(self, job: FineTune, job_id: str) -> FineTune: + if job.status == FAILED and self._client.raise_on_error: + raise error_from_business_code( + message=job.message or f"fine-tune job {job_id} failed", + code=job.code, + # ``headers`` is transport metadata, not wire body. + body=job.model_dump(exclude={"headers"}), + headers=job.headers, + ) + if job.status == CANCELED and self._client.raise_on_error: + raise DashScopeError( + f"fine-tune job {job_id} was canceled while waiting", + request_id=job.request_id, + ) + return job diff --git a/dashscope/v2/resources/images.py b/dashscope/v2/resources/images.py new file mode 100644 index 00000000..7821121b --- /dev/null +++ b/dashscope/v2/resources/images.py @@ -0,0 +1,812 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.images resource (design doc M3 3.3). + +Two wire protocols hide behind one resource, picked by model name: + +* legacy text2image — ``/services/aigc/text2image/image-synthesis``, + async tasks only, ``prompt`` input, ``results`` output. The endpoint + serves wan2.5 and below (wan2.5-t2i-preview, wan2.2-t2i-*, + wanx2.1-t2i-*, ...). +* 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. + +The split follows the same convention as ``chat.completions`` routing +media messages to the multimodal endpoint on its own: the caller passes +a model, the resource picks the protocol. Either way ``generate`` +returns the unified ``Job`` — ``results`` / ``image_urls`` read the +same for both output shapes. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union + +from dashscope.v2.errors import check_response +from dashscope.v2.resources._jobs import ( + DEFAULT_MAX_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + SUCCEEDED, + AsyncJobs, + Job, + JobList, + Jobs, +) +from dashscope.v2.resources._oss import ( + OSS_RESOLVE_HEADERS, + upload_media_value, +) +from dashscope.v2.types.common import with_headers + +__all__ = [ + "AsyncImages", + "Images", +] + +SERVICE = "aigc/text2image/image-synthesis" +MULTIMODAL_SERVICE = "aigc/multimodal-generation/generation" +TASK_SERVICE = "aigc/image-generation/generation" +# Functional-area tag for x-dashscope-sdk-client (1.x ImageSynthesis lives +# under the ``aigc`` package). +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") + + +def _uses_multimodal_protocol(model: str) -> bool: + return model.startswith(MULTIMODAL_MODEL_PREFIXES) + + +def _upload_inputs( + client, + model: str, + image_urls: Optional[List[str]], + extra_input: Optional[Dict[str, Any]], + api_key: Optional[str], + workspace: Optional[str], +) -> Tuple[Optional[List[str]], Optional[Dict[str, Any]], bool]: + """Upload local/oss reference images before the body is built.""" + has_upload = False + if image_urls: + image_urls, is_up = upload_media_value( + client, + list(image_urls), + model, + api_key, + workspace, + ) + has_upload = has_upload or is_up + if extra_input: + extra_input, is_up = upload_media_value( + client, + dict(extra_input), + model, + api_key, + workspace, + ) + has_upload = has_upload or is_up + return image_urls, extra_input, has_upload + + +def _build_input( + prompt: 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} + if negative_prompt is not None: + input_data["negative_prompt"] = negative_prompt + if extra_input: + input_data.update(extra_input) + if function is not None: + input_data["function"] = function + return input_data + + +def _build_multimodal_input( + prompt: 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}] + for url in image_urls or []: + content.append({"image": url}) + input_data: Dict[str, Any] = { + "messages": [{"role": "user", "content": content}], + } + if extra_input: + input_data.update(extra_input) + if function is not None: + input_data["function"] = function + return input_data + + +def _normalize_size(size: str) -> str: + """Accept ``1024x1024`` as well as the wire form ``1024*1024``.""" + return size.replace("x", "*").replace("X", "*") + + +def _merge_named_parameters( + parameters: Optional[Dict[str, Any]], + named: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Merge named kwargs into ``parameters``; the named kwarg wins.""" + merged = dict(parameters or {}) + for key, value in named.items(): + if value is not None: + merged[key] = value + return merged or None + + +def _named_image_parameters( + n: Optional[int], + size: Optional[str], + seed: Optional[int], + style: Optional[str], + prompt_extend: Optional[bool], + watermark: Optional[bool], +) -> Dict[str, Any]: + """Named kwargs shared by both image protocols (1.x wire spellings).""" + named: Dict[str, Any] = { + "n": n, + "seed": seed, + "style": style, + "prompt_extend": prompt_extend, + "watermark": watermark, + } + if size is not None: + named["size"] = _normalize_size(size) + return named + + +def _build_parameters( + n: Optional[int], + size: Optional[str], + parameters: Optional[Dict[str, Any]], + *, + seed: Optional[int] = None, + style: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, +) -> Optional[Dict[str, Any]]: + return _merge_named_parameters( + parameters, + _named_image_parameters( + n, + size, + seed, + style, + prompt_extend, + watermark, + ), + ) + + +def _build_multimodal_parameters( + negative_prompt: Optional[str], + n: Optional[int], + size: Optional[str], + parameters: Optional[Dict[str, Any]], + *, + seed: Optional[int] = None, + style: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, +) -> Optional[Dict[str, Any]]: + """The multimodal protocol takes ``negative_prompt`` as a parameter.""" + named = _named_image_parameters( + n, + size, + seed, + style, + prompt_extend, + watermark, + ) + named["negative_prompt"] = negative_prompt + return _merge_named_parameters(parameters, named) + + +def _build_multimodal_body( + model: str, + prompt: str, + negative_prompt: Optional[str], + n: Optional[int], + size: Optional[str], + image_urls: Optional[List[str]], + extra_input: Optional[Dict[str, Any]], + parameters: Optional[Dict[str, Any]], + *, + seed: Optional[int] = None, + style: Optional[str] = None, + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + function: Optional[str] = None, +) -> Dict[str, Any]: + body: Dict[str, Any] = { + "model": model, + "input": _build_multimodal_input( + prompt, + image_urls, + extra_input, + function, + ), + } + merged = _build_multimodal_parameters( + negative_prompt, + n, + size, + parameters, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + ) + if merged: + body["parameters"] = merged + return body + + +def _parse_sync_job(payload: Dict[str, Any]) -> Job: + """Wrap a one-shot multimodal answer in the unified Job contract. + + A sync response carries no task fields; mark it finished so + ``succeeded`` / ``finished`` read right. A business error body + (``raise_on_error=False``) keeps its code instead. + """ + output = payload.get("output") + if isinstance(output, dict) and not payload.get("code"): + output.setdefault("task_status", SUCCEEDED) + return Job.from_payload(payload) + + +class ImageJobs: + """Async task facade: create/fetch/wait over the image services. + + ``create`` submits legacy models to the text2image endpoint and + multimodal-protocol models to the image-generation task endpoint; + ``fetch`` / ``wait`` poll ``/tasks/{task_id}`` either way. + """ + + def __init__(self, client) -> None: + self._client = client + self._synthesis = Jobs(client, SERVICE, MODULE) + self._generation = Jobs(client, TASK_SERVICE, MODULE) + + def create( + self, + *, + model: str, + prompt: str, + 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, + ) -> Job: + image_urls, extra_input, has_upload = _upload_inputs( + self._client, + model, + image_urls, + extra_input, + api_key, + workspace, + ) + resolve = OSS_RESOLVE_HEADERS if has_upload else None + if _uses_multimodal_protocol(model): + return self._generation.create( + model=model, + input=_build_multimodal_input( + prompt, + image_urls, + extra_input, + function, + ), + parameters=_build_multimodal_parameters( + negative_prompt, + n, + size, + parameters, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + ), + headers=resolve, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + return self._synthesis.create( + model=model, + input=_build_input(prompt, negative_prompt, extra_input, function), + parameters=_build_parameters( + n, + size, + parameters, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + ), + headers=resolve, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return self._synthesis.fetch( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + return self._synthesis.cancel( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + 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, + ) -> JobList: + """List async tasks (``GET /tasks``).""" + return self._synthesis.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=page_no, + page_size=page_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Any]] = None, + ) -> Job: + return self._synthesis.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class Images: + """Text-to-image: one-shot generate + jobs.create/fetch/wait.""" + + def __init__(self, client) -> None: + self._client = client + self.jobs = ImageJobs(client) + + def generate( + self, + *, + model: str, + prompt: str, + 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: float = DEFAULT_WAIT_TIMEOUT, + submit_timeout: Optional[float] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Submit and wait; returns the finished Job (``image_urls``). + + ``timeout`` is the wait budget — the same ``timeout`` as + ``jobs.wait``. ``submit_timeout`` caps only the submit request. + + Multimodal-protocol models (wan2.6-t2i / wan2.6-image) answer + synchronously: the call is a single request capped by + ``submit_timeout`` and the polling knobs do not apply. + ``image_urls`` carries reference images for wan2.6-image. + """ + if _uses_multimodal_protocol(model): + image_urls, extra_input, has_upload = _upload_inputs( + self._client, + model, + image_urls, + extra_input, + api_key, + workspace, + ) + response = self._client.transport.request( + "POST", + f"/services/{MULTIMODAL_SERVICE}", + json=_build_multimodal_body( + model, + prompt, + negative_prompt, + n, + size, + image_urls, + extra_input, + parameters, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + function=function, + ), + headers=OSS_RESOLVE_HEADERS if has_upload else None, + timeout=submit_timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response( + response, + raise_on_error=self._client.raise_on_error, + ) + return with_headers( + _parse_sync_job(response.json()), + response, + ) + job = self.jobs.create( + model=model, + prompt=prompt, + negative_prompt=negative_prompt, + n=n, + size=size, + image_urls=image_urls, + extra_input=extra_input, + function=function, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parameters, + timeout=submit_timeout, + api_key=api_key, + workspace=workspace, + ) + return self.jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + +class AsyncImageJobs: + """Async counterpart of :class:`ImageJobs`.""" + + def __init__(self, client) -> None: + self._client = client + self._synthesis = AsyncJobs(client, SERVICE, MODULE) + self._generation = AsyncJobs(client, TASK_SERVICE, MODULE) + + async def create( + self, + *, + model: str, + prompt: str, + 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, + ) -> Job: + # The upload path is blocking (requests + getPolicy); run it off the + # event loop so it never stalls other tasks. + image_urls, extra_input, has_upload = await asyncio.to_thread( + _upload_inputs, + self._client, + model, + image_urls, + extra_input, + api_key, + workspace, + ) + resolve = OSS_RESOLVE_HEADERS if has_upload else None + if _uses_multimodal_protocol(model): + return await self._generation.create( + model=model, + input=_build_multimodal_input( + prompt, + image_urls, + extra_input, + function, + ), + parameters=_build_multimodal_parameters( + negative_prompt, + n, + size, + parameters, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + ), + headers=resolve, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + return await self._synthesis.create( + model=model, + input=_build_input(prompt, negative_prompt, extra_input, function), + parameters=_build_parameters( + n, + size, + parameters, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + ), + headers=resolve, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return await self._synthesis.fetch( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + return await self._synthesis.cancel( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + 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, + ) -> JobList: + """List async tasks (``GET /tasks``).""" + return await self._synthesis.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=page_no, + page_size=page_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> Job: + return await self._synthesis.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class AsyncImages: + """Async counterpart of :class:`Images`.""" + + def __init__(self, client) -> None: + self._client = client + self.jobs = AsyncImageJobs(client) + + async def generate( + self, + *, + model: str, + prompt: str, + 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: float = DEFAULT_WAIT_TIMEOUT, + submit_timeout: Optional[float] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """See :meth:`Images.generate`.""" + if _uses_multimodal_protocol(model): + # The upload path is blocking (requests + getPolicy); run it off + # the event loop so it never stalls other tasks. + image_urls, extra_input, has_upload = await asyncio.to_thread( + _upload_inputs, + self._client, + model, + image_urls, + extra_input, + api_key, + workspace, + ) + response = await self._client.transport.request( + "POST", + f"/services/{MULTIMODAL_SERVICE}", + json=_build_multimodal_body( + model, + prompt, + negative_prompt, + n, + size, + image_urls, + extra_input, + parameters, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + function=function, + ), + headers=OSS_RESOLVE_HEADERS if has_upload else None, + timeout=submit_timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + check_response( + response, + raise_on_error=self._client.raise_on_error, + ) + return with_headers( + _parse_sync_job(response.json()), + response, + ) + job = await self.jobs.create( + model=model, + prompt=prompt, + negative_prompt=negative_prompt, + n=n, + size=size, + image_urls=image_urls, + extra_input=extra_input, + function=function, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parameters, + timeout=submit_timeout, + api_key=api_key, + workspace=workspace, + ) + return await self.jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) diff --git a/dashscope/v2/resources/models.py b/dashscope/v2/resources/models.py new file mode 100644 index 00000000..066e10e6 --- /dev/null +++ b/dashscope/v2/resources/models.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.models resource (list/get).""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import AliasChoices, Field + +from dashscope.v2.errors import ( + ErrorCode, + check_response, + error_from_business_code, +) +from dashscope.v2.types.common import ( + ResponseBase, + response_envelope, + with_headers, +) + +_BASE_PATH = "/models" + +MODULE = "models" + +__all__ = ["AsyncModels", "Model", "ModelList", "Models"] + + +class Model(ResponseBase): + """One model entry. + + Returned on its own by ``get`` and nested in ``ModelList.models``; both + roles share one shape. The envelope is filled in on the standalone result + and stays empty on the nested copies, because the server reports it once + per response rather than once per item. + """ + + model: Optional[str] = None + source: Optional[str] = None + provider: Optional[str] = None + # The wire field is "opensource"; the public name is spelled properly. + open_source: Optional[bool] = Field( + None, + validation_alias=AliasChoices("open_source", "opensource"), + ) + + +class ModelList(ResponseBase): + page_no: int = 1 + page_size: int = 10 + total_count: int = 0 + models: List[Model] = [] + + +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. + data = body.get("output") or body.get("data") or {} + if isinstance(data, dict): + models = data.get("models") or [] + page_no = data.get("page_no", 1) + page_size = data.get("page_size", 10) + total = data.get("total", len(models)) + else: + models, page_no, page_size, total = data, 1, 10, len(data) + return ModelList( + page_no=page_no, + page_size=page_size, + total_count=total, + models=models, + **response_envelope(body), + ) + + +def _parse_get_models(body: Dict[str, Any]) -> List[Dict[str, Any]]: + output = body.get("output") or body.get("data") or {} + if isinstance(output, dict): + return output.get("models") or [] + return [] + + +def _match_provider(model: Model, provider: str) -> bool: + wanted = provider.lower() + return wanted in ( + (model.provider or "").lower(), + (model.source or "").lower(), + ) + + +class Models: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> ModelList: + """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. + """ + response = self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + result = _parse_list(response.json()) + if provider: + result.models = [ + m for m in result.models if _match_provider(m, provider) + ] + return with_headers(result, response) + + def get( + self, + model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Model: + # Server filters by model name via query params. + response = self._client.transport.request( + "GET", + _BASE_PATH, + params={"model": model, "page_no": 1, "page_size": 1}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + body = response.json() + models = _parse_get_models(body) + if not models: + if self._client.raise_on_error: + raise error_from_business_code( + message=f"Model '{model}' not found", + code=ErrorCode.MODEL_NOT_EXIST.value, + body=body, + headers=response.headers, + ) + return with_headers(Model(**response_envelope(body)), response) + return with_headers( + Model(**models[0], **response_envelope(body)), + response, + ) + + +class AsyncModels: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> ModelList: + response = await self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + result = _parse_list(response.json()) + if provider: + result.models = [ + m for m in result.models if _match_provider(m, provider) + ] + return with_headers(result, response) + + async def get( + self, + model: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Model: + # Server filters by model name via query params. + response = await self._client.transport.request( + "GET", + _BASE_PATH, + params={"model": model, "page_no": 1, "page_size": 1}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + body = response.json() + models = _parse_get_models(body) + if not models: + if self._client.raise_on_error: + raise error_from_business_code( + message=f"Model '{model}' not found", + code=ErrorCode.MODEL_NOT_EXIST.value, + body=body, + headers=response.headers, + ) + return with_headers(Model(**response_envelope(body)), response) + return with_headers( + Model(**models[0], **response_envelope(body)), + response, + ) diff --git a/dashscope/v2/resources/multimodal/__init__.py b/dashscope/v2/resources/multimodal/__init__.py new file mode 100644 index 00000000..4569217d --- /dev/null +++ b/dashscope/v2/resources/multimodal/__init__.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.multimodal resource.""" + +from dashscope.v2.resources.multimodal.chat import ( + AsyncMultimodalChat, + MultimodalChat, +) +from dashscope.v2.resources.multimodal.embeddings import ( + AsyncMultimodalEmbeddings, + MultimodalEmbeddings, +) + +__all__ = [ + "AsyncMultimodal", + "AsyncMultimodalEmbeddings", + "Multimodal", + "MultimodalEmbeddings", +] + + +class Multimodal: + def __init__(self, client) -> None: + self.chat = MultimodalChat(client) + self.embeddings = MultimodalEmbeddings(client) + + +class AsyncMultimodal: + def __init__(self, client) -> None: + self.chat = AsyncMultimodalChat(client) + self.embeddings = AsyncMultimodalEmbeddings(client) diff --git a/dashscope/v2/resources/multimodal/chat.py b/dashscope/v2/resources/multimodal/chat.py new file mode 100644 index 00000000..2490f086 --- /dev/null +++ b/dashscope/v2/resources/multimodal/chat.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.multimodal.chat resource (design doc §2.1, M3 3.1). + +The explicit escape hatch to the multimodal-generation endpoint: every +call goes there, media parts or not. ``chat.completions.create`` already +routes messages that carry media parts to this endpoint on its own, so +reach for this resource when a text-only conversation still has to hit +the multimodal service (a qwen-vl model, for one). + +Compatible protocols have no separate media endpoint — they translate +parts on their own chat path — so there this resource and +``chat.completions`` are the same call. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional, Union, overload + +from typing_extensions import Literal + +from dashscope.v2.resources.chat.completions import ( + AsyncChatStream, + ChatStream, + _asend, + _build_request, + _send, +) +from dashscope.v2.types.completions import ChatCompletion +from dashscope.v2.types.messages import MessageInput + +__all__ = [ + "AsyncMultimodalChat", + "MultimodalChat", +] + + +class MultimodalChat: + def __init__(self, client) -> None: + self._client = client + + @overload + def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: Literal[True], + 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, + ) -> ChatStream: + ... + + @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, + ) -> ChatCompletion: + ... + + def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: bool = 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, + ) -> Union[ChatCompletion, ChatStream]: + built = _build_request( + self._client, + multimodal=True, + model=model, + messages=messages, + stream=stream, + tools=tools, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stop=stop, + seed=seed, + repetition_penalty=repetition_penalty, + presence_penalty=presence_penalty, + enable_thinking=enable_thinking, + response_format=response_format, + top_k=top_k, + tool_choice=tool_choice, + n=n, + logprobs=logprobs, + top_logprobs=top_logprobs, + thinking_budget=thinking_budget, + enable_search=enable_search, + search_options=search_options, + parallel_tool_calls=parallel_tool_calls, + incremental_output=incremental_output, + parameters=parameters, + api_key=api_key, + workspace=workspace, + ) + request, module, merge_incremental, server_cumulative = built + return _send( + self._client, + request, + stream=stream, + module=module, + merge_incremental=merge_incremental, + server_cumulative=server_cumulative, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) + + +class AsyncMultimodalChat: + def __init__(self, client) -> None: + self._client = client + + @overload + async def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: Literal[True], + 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, + ) -> AsyncChatStream: + ... + + @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, + ) -> ChatCompletion: + ... + + async def create( + self, + *, + model: str, + messages: Union[str, List[MessageInput]], + stream: bool = 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, + ) -> Union[ChatCompletion, AsyncChatStream]: + # The upload path is blocking (requests + getPolicy); run the whole + # request build off the event loop so it never stalls other tasks. + built = await asyncio.to_thread( + _build_request, + self._client, + multimodal=True, + model=model, + messages=messages, + stream=stream, + tools=tools, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + stop=stop, + seed=seed, + repetition_penalty=repetition_penalty, + presence_penalty=presence_penalty, + enable_thinking=enable_thinking, + response_format=response_format, + top_k=top_k, + tool_choice=tool_choice, + n=n, + logprobs=logprobs, + top_logprobs=top_logprobs, + thinking_budget=thinking_budget, + enable_search=enable_search, + search_options=search_options, + parallel_tool_calls=parallel_tool_calls, + incremental_output=incremental_output, + parameters=parameters, + api_key=api_key, + workspace=workspace, + ) + request, module, merge_incremental, server_cumulative = built + return await _asend( + self._client, + request, + stream=stream, + module=module, + merge_incremental=merge_incremental, + server_cumulative=server_cumulative, + api_key=api_key, + workspace=workspace, + timeout=timeout, + ) diff --git a/dashscope/v2/resources/multimodal/embeddings.py b/dashscope/v2/resources/multimodal/embeddings.py new file mode 100644 index 00000000..f214c01d --- /dev/null +++ b/dashscope/v2/resources/multimodal/embeddings.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.multimodal.embeddings resource (design doc M3 3.2).""" + +# "input" is the public API parameter name (mirrors OpenAI embeddings). +# pylint: disable=redefined-builtin + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional, Union + +from pydantic import BaseModel, ConfigDict + +from dashscope.v2.errors import check_response +from dashscope.v2.resources._oss import upload_embedding_media +from dashscope.v2.resources.embeddings import ( + EmbeddingResponse, + parse_embedding_response, +) +from dashscope.v2.types.common import with_headers + +__all__ = [ + "AsyncMultimodalEmbeddings", + "EmbeddingItem", + "MultimodalEmbeddings", +] + +_PATH = "/services/embeddings/multimodal-embedding/multimodal-embedding" + +MODULE = "embeddings" + +# The service only fetches oss:// URLs when the request opts in. +_OSS_RESOLVE_HEADERS = {"X-DashScope-OssResourceResolve": "enable"} + + +class EmbeddingItem(BaseModel): + """One multimodal input element; exactly one modality field is set.""" + + model_config = ConfigDict(extra="allow") + + text: Optional[str] = None + image: Optional[str] = None + audio: Optional[str] = None + video: Optional[str] = None + factor: Optional[float] = None + + +_MODALITY_KEYS = ("text", "image", "audio", "video") + + +def _contents( + input: Union[Dict[str, Any], EmbeddingItem, List[Any]], +) -> List[Dict[str, Any]]: + single = isinstance(input, (dict, EmbeddingItem)) + items = [input] if single else list(input) + if not items: + raise ValueError("input must not be empty") + contents: List[Dict[str, Any]] = [] + for item in items: + data = ( + item.model_dump(exclude_none=True) + if isinstance(item, EmbeddingItem) + else dict(item) + ) + if not any(data.get(key) is not None for key in _MODALITY_KEYS): + raise ValueError( + "each input item needs one of text/image/audio/video", + ) + contents.append(data) + return contents + + +def _uses_oss(contents: List[Dict[str, Any]]) -> bool: + return any( + isinstance(value, str) and value.startswith("oss://") + for item in contents + for value in item.values() + ) + + +def _build_body( + *, + model: str, + contents: List[Dict[str, Any]], + dimension: Optional[int], + output_type: Optional[str], + fps: Optional[float], + instruct: Optional[str], + enable_fusion: Optional[bool], + res_level: Optional[int], + max_video_frames: Optional[int], + parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + merged: Dict[str, Any] = {} + for key, value in ( + ("dimension", dimension), + ("output_type", output_type), + ("fps", fps), + ("instruct", instruct), + ("enable_fusion", enable_fusion), + ("res_level", res_level), + ("max_video_frames", max_video_frames), + ): + if value is not None: + merged[key] = value + if parameters: + merged.update(parameters) + return { + "model": model, + "input": {"contents": contents}, + "parameters": merged, + } + + +class MultimodalEmbeddings: + """Embed text, image, audio and video into one shared vector space.""" + + def __init__(self, client) -> None: + self._client = client + + def create( # pylint: disable=too-many-arguments + self, + *, + model: str, + input: Union[Dict[str, Any], EmbeddingItem, List[Any]], + dimension: Optional[int] = None, + output_type: Optional[str] = None, + fps: Optional[float] = None, + instruct: Optional[str] = None, + enable_fusion: Optional[bool] = None, + res_level: Optional[int] = None, + max_video_frames: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> EmbeddingResponse: + """Embed one item, or a list of them, into one vector space. + + Each item sets exactly one of ``text`` / ``image`` / ``audio`` / + ``video`` (plus an optional ``factor``). ``fps``, ``res_level`` + (resolution tier 0-3) and ``max_video_frames`` apply to snapshot + models only; anything this signature does not name goes in + ``parameters``. Local paths and ``file://`` URIs upload to OSS + first; ``oss://`` inputs are resolved by the service without any + extra opt-in. + """ + contents = _contents(input) + upload_embedding_media( + self._client, + contents, + model, + api_key, + workspace, + ) + body = _build_body( + model=model, + contents=contents, + dimension=dimension, + output_type=output_type, + fps=fps, + instruct=instruct, + enable_fusion=enable_fusion, + res_level=res_level, + max_video_frames=max_video_frames, + parameters=parameters, + ) + response = self._client.transport.request( + "POST", + _PATH, + json=body, + headers=_OSS_RESOLVE_HEADERS if _uses_oss(contents) else None, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + parse_embedding_response(response.json()), + response, + ) + + +class AsyncMultimodalEmbeddings: + def __init__(self, client) -> None: + self._client = client + + async def create( # pylint: disable=too-many-arguments + self, + *, + model: str, + input: Union[Dict[str, Any], EmbeddingItem, List[Any]], + dimension: Optional[int] = None, + output_type: Optional[str] = None, + fps: Optional[float] = None, + instruct: Optional[str] = None, + enable_fusion: Optional[bool] = None, + res_level: Optional[int] = None, + max_video_frames: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> EmbeddingResponse: + contents = _contents(input) + # The upload path is blocking (requests + getPolicy); run it off the + # event loop so it never stalls other tasks. + await asyncio.to_thread( + upload_embedding_media, + self._client, + contents, + model, + api_key, + workspace, + ) + body = _build_body( + model=model, + contents=contents, + dimension=dimension, + output_type=output_type, + fps=fps, + instruct=instruct, + enable_fusion=enable_fusion, + res_level=res_level, + max_video_frames=max_video_frames, + parameters=parameters, + ) + response = await self._client.transport.request( + "POST", + _PATH, + json=body, + headers=_OSS_RESOLVE_HEADERS if _uses_oss(contents) else None, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers( + parse_embedding_response(response.json()), + response, + ) diff --git a/dashscope/v2/resources/reinforcement_learning.py b/dashscope/v2/resources/reinforcement_learning.py new file mode 100644 index 00000000..ce80824c --- /dev/null +++ b/dashscope/v2/resources/reinforcement_learning.py @@ -0,0 +1,897 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.rl thin mount layer (design doc M3 3.7). + +The agentic-RL business logic stays in the 1.x implementation +(``dashscope.finetune.agentic_rl``); this layer only mounts it on the +2.0 Client and routes both halves through the v2 error model -- job +management through the v2 transport, and the workflow steps by +translating 1.x exceptions into ``SdkError`` (catalog ``code`` plus +``solution``). One ``except DashScopeError`` therefore covers both. + +Two halves, matching the rest of the SDK: ``rl.jobs`` is the training +job lifecycle (``create`` submits and never blocks, ``wait`` blocks, +plus ``fetch``/``list``/``cancel``/``logs``/``delete``), and ``rl`` +itself carries the RL-specific workflow steps that have no +``fine_tunes`` equivalent, including the function-instance queries. + +``register_functions`` / ``test_functions`` / ``upload_data`` / +``jobs.create`` / the function-instance methods are the SDK's only +public methods without ``timeout=``: the 1.x implementation they wrap +accepts none, so declaring it here would silently do nothing. The other +``jobs`` methods take ``timeout=`` as everywhere else. Those same +1.x-wrapped methods accept a per-call ``api_key`` but no ``workspace``: +the 1.x ``AgenticRL`` they delegate to has no workspace concept. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import time +from typing import Any, Awaitable, Callable, Dict, List, Optional, Union + +from pydantic import BaseModel, ConfigDict + +from dashscope.v2.errors import ( + DashScopeError, + check_response, + error_from_business_code, + error_from_sdk_exception, +) +from dashscope.v2.resources._jobs import ( + CANCELED, + DEFAULT_MAX_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + FAILED, +) +from dashscope.v2.resources.fine_tunes import ( + MODULE, + FineTune, + FineTuneList, + JobLogs, + _BASE_PATH, + _job_id, + _parse_job, + _parse_list, + _parse_logs, + _parse_status, +) +from dashscope.v2.types.common import JobStatus, with_headers + +__all__ = [ + "AsyncRl", + "AsyncRlJobs", + "RegisteredFunctions", + "Rl", + "RlJobs", + "UploadedData", +] + +# Imported lazily below via the module object so tests can monkeypatch +# ``dashscope.finetune.agentic_rl.AgenticRL`` without touching 1.x code. +from dashscope.finetune import agentic_rl as _agentic_rl # noqa: E402 +from dashscope.finetune import reinforcement as _reinforcement # noqa: E402 +from dashscope.finetune.reinforcement.common import ( # noqa: E402 + errors as _rl_errors, +) + + +class RegisteredFunctions(BaseModel): + """Ids minted by ``rl.register_functions``. + + An *entity* id names the uploaded component; an *instance* id is what + ``rl.test_functions`` runs against. + """ + + model_config = ConfigDict(extra="allow") + + rollout_entity_ids: List[str] = [] + reward_entity_ids: List[str] = [] + group_reward_entity_ids: List[str] = [] + rollout_instance_ids: List[str] = [] + reward_instance_ids: List[str] = [] + group_reward_instance_ids: List[str] = [] + + +class UploadedData(BaseModel): + """Ids minted by ``rl.upload_data``. + + Named after the parameters of ``fine_tunes.create`` so the result can be + forwarded straight through. + """ + + model_config = ConfigDict(extra="allow") + + training_file_ids: List[str] = [] + validation_file_ids: List[str] = [] + + +_RL_FAMILY = "sdk.agentic_rl" + +# These three bases cover all 19 exception classes in common/errors.py: +# AgenticRLError roots 17, and the two *WithCode classes derive from +# RuntimeError / ValueError instead. Nothing wider is needed, since +# common/utils.py:139 already funnels transport, timeout and unexpected +# failures into RuntimeErrorWithCode; catching more would relabel bugs +# in this module as SDK errors. pydantic's ValidationError, which is +# also a ValueError, stays outside on purpose. +_RL_EXCEPTIONS = ( + _rl_errors.AgenticRLError, + _rl_errors.RuntimeErrorWithCode, + _rl_errors.ValueErrorWithCode, +) + + +@contextlib.contextmanager +def _translate_rl_errors(): + """Keep 1.x agentic-RL failures inside the v2 exception family. + + A plain sync context manager, so it wraps ``asyncio.run(...)`` and + ``await ...`` bodies alike. + """ + try: + yield + except _RL_EXCEPTIONS as exc: + raise error_from_sdk_exception(exc, family=_RL_FAMILY) from exc + + +def _resolve_func_type(func_type: Union[str, Any]) -> Any: + if isinstance(func_type, str): + return _reinforcement.FunctionType[func_type.upper()] + return func_type + + +def _new_agentic_rl( + client, + config_path: Optional[str] = None, + api_key: Optional[str] = None, +): + rl = _agentic_rl.AgenticRL( + api_key=api_key or client.config.api_key or "", + ) + if config_path: + rl.init(config_path=config_path) + return rl + + +def _to_job(result: Any) -> FineTune: + """Adapt the 1.x ``FineTune`` dataclass into the v2 model. + + The 1.x 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. + """ + body: Dict[str, Any] = { + "request_id": getattr(result, "request_id", None), + "status_code": getattr(result, "status_code", None), + "code": getattr(result, "code", None), + "message": getattr(result, "message", None), + } + output = getattr(result, "output", None) + if output is not None: + body.update(vars(output)) + return FineTune(**body) + + +def _check_terminal(client, job: FineTune, job_id: str) -> FineTune: + """Mirror ``FineTunes._check_terminal`` for the RL polling loop.""" + if job.status == FAILED and client.raise_on_error: + raise error_from_business_code( + message=job.message or f"fine-tune job {job_id} failed", + code=job.code, + # ``headers`` is transport metadata, not wire body. + body=job.model_dump(exclude={"headers"}), + headers=job.headers, + ) + if job.status == CANCELED and client.raise_on_error: + raise DashScopeError( + f"fine-tune job {job_id} was canceled while waiting", + request_id=job.request_id, + ) + return job + + +async def _register_functions_workflow( + rl, + rollout_classpaths: Optional[List[str]], + reward_classpaths: Optional[List[str]], + group_reward_classpaths: Optional[List[str]], + workspace_dir: str, + lazy_load: bool, +) -> RegisteredFunctions: + if rollout_classpaths or reward_classpaths or group_reward_classpaths: + rl.tuning.functions = [] + rl.tuning.add_function_components( + functype=_reinforcement.FunctionType.ROLLOUT, + classpaths=rollout_classpaths, + workspace_dir=workspace_dir, + ) + rl.tuning.add_function_components( + functype=_reinforcement.FunctionType.REWARD, + classpaths=reward_classpaths, + workspace_dir=workspace_dir, + ) + rl.tuning.add_function_components( + functype=_reinforcement.FunctionType.GROUP_REWARD, + classpaths=group_reward_classpaths, + workspace_dir=workspace_dir, + ) + ( + rollout_entity_ids, + reward_entity_ids, + group_reward_entity_ids, + rollout_instance_ids, + reward_instance_ids, + group_reward_instance_ids, + ) = await rl.register_functions(lazy_load=lazy_load) + return RegisteredFunctions( + rollout_entity_ids=rollout_entity_ids, + reward_entity_ids=reward_entity_ids, + group_reward_entity_ids=group_reward_entity_ids, + rollout_instance_ids=rollout_instance_ids, + reward_instance_ids=reward_instance_ids, + group_reward_instance_ids=group_reward_instance_ids, + ) + + +class RlJobs: + """``client.rl.jobs``: the agentic-RL training job lifecycle. + + ``create`` runs the whole 1.x workflow — register functions, upload + datasets, submit — and returns the job without blocking; ``wait`` + polls it to a terminal status. The remaining verbs share the + ``fine_tunes`` wire contract (RL jobs are fine-tune jobs: same + backend, same response models) and ride the v2 client endpoint + like every other resource; 1.x forces a management-plane host + only in its workflow/submit path, which is a config quirk there, + not parity. + """ + + def __init__(self, client) -> None: + self._client = client + + def create( + self, + *, + config_path: Optional[str] = None, + job_name: Optional[str] = None, + api_key: Optional[str] = None, + **kwargs: Any, + ) -> FineTune: + """Submit the workflow and return immediately; ``wait`` blocks. + + ``kwargs`` override individual keys of the training config loaded + from ``config_path``, which is open-ended by design. + """ + with _translate_rl_errors(): + rl = _new_agentic_rl(self._client, api_key=api_key) + if job_name is not None: + kwargs["job_name"] = job_name + rl.init(config_path=config_path, **kwargs) + result = asyncio.run(rl.run()) + return _to_job(result) + + def fetch( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> FineTune: + response = self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_job(response.json()), response) + + def wait( + self, + job: Union[FineTune, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Any]] = None, + ) -> FineTune: + """Poll until the job reaches a terminal status. + + Polls through :meth:`fetch` on the client endpoint. With + ``raise_on_error=False`` a transport-level failure comes back + as the :class:`FineTune` envelope instead of raising. + ``_sleep`` is a test seam — it replaces the pause between polls. + """ + job_id = _job_id(job) + sleep_fn = _sleep or time.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, FineTune) + else self.fetch(job_id, api_key=api_key, workspace=workspace) + ) + interval = poll_interval + while not current.finished: + # Compat mode (raise_on_error=False): a transport-level failure + # comes back as an error envelope with no job status, which + # would otherwise poll until the timeout. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for" + f" fine-tune job {job_id}", + ) + sleep_fn(min(interval, remaining)) + interval = min(interval * 2, max_interval) + current = self.fetch(job_id, api_key=api_key, workspace=workspace) + return _check_terminal(self._client, current, job_id) + + def list( + self, + *, + page_no: int = 1, + page_size: int = 10, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> FineTuneList: + response = self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = self._client.transport.request( + "POST", + f"{_BASE_PATH}/{job_id}/cancel", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + 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, + ) -> JobLogs: + response = self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}/logs", + # The wire parameter is singular "line". + params={"offset": offset, "line": lines}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_logs(job_id, response.json()), response) + + +class Rl: + """Sync mount of the 1.x agentic-RL workflow. + + The workflow methods (``register_functions``/``test_functions``/ + ``upload_data``/``jobs.create``) wrap the 1.x async implementation + with ``asyncio.run``, so they must not be called from a thread that + already runs an event loop (Jupyter, FastAPI, pytest-asyncio) — + use :class:`AsyncRl` there instead. + """ + + def __init__(self, client) -> None: + self._client = client + self.jobs = RlJobs(client) + + def register_functions( + self, + *, + rollout_classpaths: Optional[List[str]] = None, + reward_classpaths: Optional[List[str]] = None, + group_reward_classpaths: Optional[List[str]] = None, + workspace_dir: str = "./", + lazy_load: bool = True, + api_key: Optional[str] = None, + ) -> RegisteredFunctions: + with _translate_rl_errors(): + rl = _new_agentic_rl(self._client, api_key=api_key) + return asyncio.run( + _register_functions_workflow( + rl, + rollout_classpaths, + reward_classpaths, + group_reward_classpaths, + workspace_dir, + lazy_load, + ), + ) + + def test_functions( + self, + *, + instance_id: str, + func_type: Union[str, Any], + input_data: Dict[str, Any], + pull_logs: bool = False, + log_page_size: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + api_key: Optional[str] = None, + ) -> Any: + """Returns whatever the customer's own function returns. + + The ``pull_logs``/``log_page_size``/``start_time``/``end_time`` + parameters forward to the 1.x log-pulling step that runs after + verification. + """ + with _translate_rl_errors(): + return asyncio.run( + _agentic_rl.AgenticRL.test_functions( + instance_id=instance_id, + functype=_resolve_func_type(func_type), + input_data=input_data, + api_key=api_key or self._client.config.api_key or "", + pull_logs=pull_logs, + log_page_size=log_page_size, + start_time=start_time, + end_time=end_time, + ), + ) + + def upload_data( + self, + training_files: Union[List[str], str], + validation_files: Union[List[str], str, None] = None, + *, + api_key: Optional[str] = None, + ) -> UploadedData: + with _translate_rl_errors(): + rl = _new_agentic_rl(self._client, api_key=api_key) + train_ids, val_ids = asyncio.run( + rl.upload_datasets( + training_files=training_files, + validation_files=validation_files, + ), + ) + return UploadedData( + training_file_ids=train_ids, + validation_file_ids=val_ids or [], + ) + + def query_function_instance_logs( + self, + function_instance_id: str, + *, + page_number: int = 1, + page_size: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + keywords: Optional[List[str]] = None, + api_key: Optional[str] = None, + ) -> Dict[str, Any]: + """One page of function-instance logs (raw log-query API dict). + + Wraps ``asyncio.run``, so it must not be called from a running + event loop — use :class:`AsyncRl` there. + """ + with _translate_rl_errors(): + return asyncio.run( + _agentic_rl.AgenticRL.query_function_instance_logs( + function_instance_id=function_instance_id, + page_number=page_number, + page_size=page_size, + start_time=start_time, + end_time=end_time, + keywords=keywords, + api_key=api_key or self._client.config.api_key or "", + ), + ) + + def query_all_function_instance_logs( + self, + function_instance_id: str, + *, + page_size: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + keywords: Optional[List[str]] = None, + api_key: Optional[str] = None, + ) -> List[str]: + """All function-instance logs, aggregated across pages. + + Wraps ``asyncio.run``, so it must not be called from a running + event loop — use :class:`AsyncRl` there. + """ + with _translate_rl_errors(): + return asyncio.run( + _agentic_rl.AgenticRL.query_all_function_instance_logs( + function_instance_id=function_instance_id, + page_size=page_size, + start_time=start_time, + end_time=end_time, + keywords=keywords, + api_key=api_key or self._client.config.api_key or "", + ), + ) + + def delete_function_instance( + self, + function_instance_id: str, + *, + api_key: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete a function-instance (raw delete API dict). + + Wraps ``asyncio.run``, so it must not be called from a running + event loop — use :class:`AsyncRl` there. + """ + with _translate_rl_errors(): + return asyncio.run( + _agentic_rl.AgenticRL.delete_function_instance( + function_instance_id=function_instance_id, + api_key=api_key or self._client.config.api_key or "", + ), + ) + + +class AsyncRlJobs: + """Async ``client.rl.jobs``.""" + + def __init__(self, client) -> None: + self._client = client + + async def create( + self, + *, + config_path: Optional[str] = None, + job_name: Optional[str] = None, + api_key: Optional[str] = None, + **kwargs: Any, + ) -> FineTune: + """Submit the workflow and return immediately; ``wait`` blocks. + + ``kwargs`` override individual keys of the training config loaded + from ``config_path``, which is open-ended by design. + """ + with _translate_rl_errors(): + rl = _new_agentic_rl(self._client, api_key=api_key) + if job_name is not None: + kwargs["job_name"] = job_name + rl.init(config_path=config_path, **kwargs) + result = await rl.run() + return _to_job(result) + + async def fetch( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> FineTune: + response = await self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_job(response.json()), response) + + async def wait( + self, + job: Union[FineTune, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> FineTune: + """Poll until the job reaches a terminal status. + + Polls through :meth:`fetch` on the client endpoint. With + ``raise_on_error=False`` a transport-level failure comes back + as the :class:`FineTune` envelope instead of raising. + ``_sleep`` is a test seam — it replaces the pause between polls. + """ + job_id = _job_id(job) + sleep_fn = _sleep or asyncio.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, FineTune) + else await self.fetch(job_id, api_key=api_key, workspace=workspace) + ) + interval = poll_interval + while not current.finished: + # Compat mode (raise_on_error=False): a transport-level failure + # comes back as an error envelope with no job status, which + # would otherwise poll until the timeout. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for" + f" fine-tune job {job_id}", + ) + await sleep_fn(min(interval, remaining)) + interval = min(interval * 2, max_interval) + current = await self.fetch( + job_id, + api_key=api_key, + workspace=workspace, + ) + return _check_terminal(self._client, current, job_id) + + 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, + ) -> FineTuneList: + response = await self._client.transport.request( + "GET", + _BASE_PATH, + params={"page_no": page_no, "page_size": page_size}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_list(response.json()), response) + + async def cancel( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = await self._client.transport.request( + "POST", + f"{_BASE_PATH}/{job_id}/cancel", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + async def delete( + self, + job_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> JobStatus: + response = await self._client.transport.request( + "DELETE", + f"{_BASE_PATH}/{job_id}", + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_status(response.json()), response) + + 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, + ) -> JobLogs: + response = await self._client.transport.request( + "GET", + f"{_BASE_PATH}/{job_id}/logs", + # The wire parameter is singular "line". + params={"offset": offset, "line": lines}, + sdk_module=MODULE, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse_logs(job_id, response.json()), response) + + +class AsyncRl: + """Async mount of the 1.x agentic-RL workflow.""" + + def __init__(self, client) -> None: + self._client = client + self.jobs = AsyncRlJobs(client) + + async def register_functions( + self, + *, + rollout_classpaths: Optional[List[str]] = None, + reward_classpaths: Optional[List[str]] = None, + group_reward_classpaths: Optional[List[str]] = None, + workspace_dir: str = "./", + lazy_load: bool = True, + api_key: Optional[str] = None, + ) -> RegisteredFunctions: + with _translate_rl_errors(): + rl = _new_agentic_rl(self._client, api_key=api_key) + return await _register_functions_workflow( + rl, + rollout_classpaths, + reward_classpaths, + group_reward_classpaths, + workspace_dir, + lazy_load, + ) + + async def test_functions( + self, + *, + instance_id: str, + func_type: Union[str, Any], + input_data: Dict[str, Any], + pull_logs: bool = False, + log_page_size: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + api_key: Optional[str] = None, + ) -> Any: + """Returns whatever the customer's own function returns. + + The ``pull_logs``/``log_page_size``/``start_time``/``end_time`` + parameters forward to the 1.x log-pulling step that runs after + verification. + """ + with _translate_rl_errors(): + return await _agentic_rl.AgenticRL.test_functions( + instance_id=instance_id, + functype=_resolve_func_type(func_type), + input_data=input_data, + api_key=api_key or self._client.config.api_key or "", + pull_logs=pull_logs, + log_page_size=log_page_size, + start_time=start_time, + end_time=end_time, + ) + + async def upload_data( + self, + training_files: Union[List[str], str], + validation_files: Union[List[str], str, None] = None, + *, + api_key: Optional[str] = None, + ) -> UploadedData: + with _translate_rl_errors(): + rl = _new_agentic_rl(self._client, api_key=api_key) + train_ids, val_ids = await rl.upload_datasets( + training_files=training_files, + validation_files=validation_files, + ) + return UploadedData( + training_file_ids=train_ids, + validation_file_ids=val_ids or [], + ) + + async def query_function_instance_logs( + self, + function_instance_id: str, + *, + page_number: int = 1, + page_size: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + keywords: Optional[List[str]] = None, + api_key: Optional[str] = None, + ) -> Dict[str, Any]: + """One page of function-instance logs (raw log-query API dict).""" + with _translate_rl_errors(): + return await _agentic_rl.AgenticRL.query_function_instance_logs( + function_instance_id=function_instance_id, + page_number=page_number, + page_size=page_size, + start_time=start_time, + end_time=end_time, + keywords=keywords, + api_key=api_key or self._client.config.api_key or "", + ) + + async def query_all_function_instance_logs( + self, + function_instance_id: str, + *, + page_size: int = 100, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + keywords: Optional[List[str]] = None, + api_key: Optional[str] = None, + ) -> List[str]: + """All function-instance logs, aggregated across pages.""" + with _translate_rl_errors(): + return ( + await _agentic_rl.AgenticRL.query_all_function_instance_logs( + function_instance_id=function_instance_id, + page_size=page_size, + start_time=start_time, + end_time=end_time, + keywords=keywords, + api_key=api_key or self._client.config.api_key or "", + ) + ) + + async def delete_function_instance( + self, + function_instance_id: str, + *, + api_key: Optional[str] = None, + ) -> Dict[str, Any]: + """Delete a function-instance (raw delete API dict).""" + with _translate_rl_errors(): + return await _agentic_rl.AgenticRL.delete_function_instance( + function_instance_id=function_instance_id, + api_key=api_key or self._client.config.api_key or "", + ) diff --git a/dashscope/v2/resources/rerank.py b/dashscope/v2/resources/rerank.py new file mode 100644 index 00000000..cb862984 --- /dev/null +++ b/dashscope/v2/resources/rerank.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.rerank resource (native text-rerank).""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict + +from dashscope.v2.errors import check_response +from dashscope.v2.types.common import ( + ResponseBase, + Usage, + response_envelope, + with_headers, +) + +_PATH = "/services/rerank/text-rerank/text-rerank" +# Functional-area tag for x-dashscope-sdk-client (matches 1.x ``rerank``). +MODULE = "rerank" + +__all__ = [ + "AsyncRerank", + "Rerank", + "RerankDocument", + "RerankResponse", + "RerankResult", +] + + +class RerankDocument(BaseModel): + model_config = ConfigDict(extra="allow") + + text: Optional[str] = None + + +class RerankResult(BaseModel): + model_config = ConfigDict(extra="allow") + + index: int + relevance_score: float + document: Optional[RerankDocument] = None + + +class RerankResponse(ResponseBase): + results: List[RerankResult] = [] + 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 _build_body( + *, + model: str, + query: str, + documents: List[str], + top_n: Optional[int], + return_documents: Optional[bool], + instruct: Optional[str], + parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + if not query: + raise ValueError("query must not be empty") + if not documents: + raise ValueError("documents must not be empty") + merged: Dict[str, Any] = {} + for key, value in ( + ("top_n", top_n), + ("return_documents", return_documents), + ("instruct", instruct), + ): + if value is not None: + merged[key] = value + if parameters: + merged.update(parameters) + return { + "model": model, + "input": {"query": query, "documents": documents}, + "parameters": merged, + } + + +class Rerank: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> RerankResponse: + body = _build_body( + model=model, + query=query, + documents=documents, + top_n=top_n, + return_documents=return_documents, + instruct=instruct, + parameters=parameters, + ) + response = self._client.transport.request( + "POST", + _PATH, + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + +class AsyncRerank: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> RerankResponse: + body = _build_body( + model=model, + query=query, + documents=documents, + top_n=top_n, + return_documents=return_documents, + instruct=instruct, + parameters=parameters, + ) + response = await self._client.transport.request( + "POST", + _PATH, + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) diff --git a/dashscope/v2/resources/tingwu.py b/dashscope/v2/resources/tingwu.py new file mode 100644 index 00000000..c776c97e --- /dev/null +++ b/dashscope/v2/resources/tingwu.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.tingwu resource (design doc M3 3.4 follow-up). + +Offline summarization (``call``, one POST returning the parsed result) +and realtime speech understanding (``realtime``, a duplex websocket +session used as a context manager). Mirrors the 1.x +``dashscope.multimodal.tingwu`` pair. +""" + +# "input" is the wire-protocol field name for task payloads. +# pylint: disable=redefined-builtin + +from __future__ import annotations + +from typing import Any, Dict, Iterator, Optional, Union + +from pydantic import ConfigDict, Field + +from dashscope.v2.errors import check_response, error_from_business_code +from dashscope.v2.transport.ws import Connector, WsSession +from dashscope.v2.transport.ws_connection import WsConnection, WsTask +from dashscope.v2.types.common import ( + ResponseBase, + Usage, + response_envelope, + with_headers, +) + +__all__ = [ + "AsyncTingWu", + "TingWu", + "TingWuRealtime", + "TingWuResult", +] + +MODULE = "multimodal" + +SERVICE = "aigc/multimodal-generation/generation" + + +class TingWuResult(ResponseBase): + """Parsed offline TingWu response. + + ``output`` keeps the result dict (``TaskId`` / ``Result`` / ...) and + ``usage`` the token counters, both as sent by the service. + """ + + model_config = ConfigDict(extra="allow") + + output: Dict[str, Any] = Field(default_factory=dict) + usage: Optional[Usage] = None + + +def _realtime_start_input( + app_id: str, + workspace_id: Optional[str], + data_id: Optional[str], +) -> Dict[str, Any]: + input: Dict[str, Any] = {"appId": app_id, "directive": "start"} + if workspace_id is not None: + input["workspace_id"] = workspace_id + if data_id is not None: + input["dataId"] = data_id + return input + + +class TingWuRealtime: + """Realtime TingWu session over one duplex websocket connection. + + Usage:: + + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="your-app-id", + ) as session: + session.send_audio_frame(chunk) + ... + session.stop() + for event in session.events(): + ... + """ + + def __init__( + self, + client, + *, + model: str, + app_id: str, + format: str = "pcm", # pylint: disable=redefined-builtin + sample_rate: int = 16000, + terminology: Optional[str] = None, + max_end_silence: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + workspace_id: Optional[str] = None, + data_id: Optional[str] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> None: + config = client.config + self._model = model + self._app_id = app_id + merged: Dict[str, Any] = { + "format": format, + "sampleRate": sample_rate, + } + if parameters: + merged.update(parameters) + # Named kwargs win over the ``parameters=`` escape hatch; the keys + # match the 1.x TingWuRealtime wire names (asr convention). + if terminology is not None: + merged["terminology"] = terminology + if max_end_silence is not None: + merged["maxEndSilence"] = max_end_silence + self._parameters = merged + self._start_input = _realtime_start_input( + app_id, + workspace_id, + data_id, + ) + # A shared connection runs this session as one task on it; + # ws_url/connector/api_key/workspace then belong to the + # connection and the per-session values are ignored. + self._connection = connection + self._session_kwargs = { + "url": ws_url, + "api_key": api_key or config.api_key, + "workspace": workspace or config.workspace, + "region": config.region, + "connector": connector, + "module": MODULE, + "timeout": timeout, + } + self._session: Optional[Union[WsSession, WsTask]] = None + + @property + def session(self) -> Union[WsSession, WsTask]: + if self._session is None: + raise RuntimeError("tingwu realtime session is not open") + return self._session + + def __enter__(self) -> "TingWuRealtime": + if self._connection is not None: + self._session = self._connection.open_task( + timeout=self._session_kwargs["timeout"], + ) + else: + self._session = WsSession(**self._session_kwargs) + # WsSession opens the socket in its constructor, so a run_task + # failure (bad api_key/app_id/model, unexpected first frame) would + # leak the connection: Python skips __exit__ when __enter__ raises. + # Close it here, matching the guard Tts.stream / asr.Recognition use. + try: + self._session.run_task( + task_group="aigc", + task="multimodal-generation", + function="generation", + model=self._model, + input=self._start_input, + parameters=self._parameters, + ) + except BaseException: + self._session.close() + self._session = None + raise + return self + + def __exit__(self, *exc: Any) -> None: + if self._session is not None: + self._session.close() + self._session = None + + def send_audio_frame(self, data: bytes) -> None: + """Send one raw audio frame (format/sampleRate as configured).""" + self.session.send_binary(data) + + def stop(self) -> None: + """Signal end of audio; results keep arriving as events.""" + # 1.x sends appId=None here (tingwu_realtime.py:139 reads the + # never-assigned ``_Request.app_id``); v2 sends the real app_id. + self.session.finish_task( + input={"appId": self._app_id, "directive": "stop"}, + ) + + def events(self) -> Iterator[Dict[str, Any]]: + """Yield result events until ``speech-end`` (or session end). + + A payload ``task-failed`` action raises through the error catalog, + like the header-level task-failed the session itself raises on. + """ + session = self.session + while not session.finished: + event = session.recv_event() + output = (event.get("payload") or {}).get("output") or {} + action = output.get("action") + if action == "task-failed": + # The frame's request_id lives in the header; forward it + # like WsSession does for header-level task-failed. + header = event.get("header") or {} + request_id = header.get("request_id") + raise error_from_business_code( + message=output.get("errorMessage") or "task failed", + code=output.get("errorCode"), + body=event, + headers=( + {"x-request-id": request_id} if request_id else None + ), + ) + yield event + if action == "speech-end": + break + + +class TingWu: + """``client.tingwu``: offline summarization + realtime understanding.""" + + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> TingWuResult: + """Run one offline TingWu request (1.x ``TingWu.call``).""" + body: Dict[str, Any] = {"model": model, "input": dict(input)} + if parameters: + body["parameters"] = dict(parameters) + response = self._client.transport.request( + "POST", + f"/services/{SERVICE}", + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + envelope = response.json() + 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, + *, + model: str, + app_id: str, + format: str = "pcm", # pylint: disable=redefined-builtin + sample_rate: int = 16000, + terminology: Optional[str] = None, + max_end_silence: Optional[int] = None, + parameters: Optional[Dict[str, Any]] = None, + workspace_id: Optional[str] = None, + data_id: Optional[str] = None, + ws_url: Optional[str] = None, + connector: Optional[Connector] = None, + connection: Optional[WsConnection] = None, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> TingWuRealtime: + """Open a realtime TingWu session (use as a context manager). + + With ``connection=`` the session runs as one task on the shared + socket (binary uplink is single-owner per connection). + """ + return TingWuRealtime( + self._client, + model=model, + app_id=app_id, + format=format, + sample_rate=sample_rate, + terminology=terminology, + max_end_silence=max_end_silence, + parameters=parameters, + workspace_id=workspace_id, + data_id=data_id, + ws_url=ws_url, + connector=connector, + connection=connection, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + +class AsyncTingWu: + """Async ``client.tingwu``. + + Realtime sessions stay sync-only, matching the rest of v2 (design + doc M3 3.5). + """ + + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> TingWuResult: + """Run one offline TingWu request (1.x ``TingWu.async_call``).""" + body: Dict[str, Any] = {"model": model, "input": dict(input)} + if parameters: + body["parameters"] = dict(parameters) + response = await self._client.transport.request( + "POST", + f"/services/{SERVICE}", + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + envelope = response.json() + 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/v2/resources/tokenization.py b/dashscope/v2/resources/tokenization.py new file mode 100644 index 00000000..d71c47a5 --- /dev/null +++ b/dashscope/v2/resources/tokenization.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.tokenization resource (token counting).""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union + +from dashscope.v2.errors import check_response +from dashscope.v2.types.common import ( + ResponseBase, + Usage, + response_envelope, + with_headers, +) +from dashscope.v2.types.messages import MessageInput, normalize_messages + +_PATH = "/tokenizer" +# Functional-area tag for x-dashscope-sdk-client (matches 1.x ``tokenizers``). +MODULE = "tokenizers" + +__all__ = [ + "AsyncTokenization", + "TokenCount", + "Tokenization", +] + + +class TokenCount(ResponseBase): + usage: Usage = Usage() + # Server tokenizer output (1.x ``output.token_ids`` / ``output.tokens``). + token_ids: Optional[List[int]] = None + tokens: Optional[List[str]] = None + + @property + 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 _build_body( + *, + model: str, + prompt: Optional[str], + messages: Union[str, List[MessageInput], None], + parameters: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + input_body: Dict[str, Any] + if messages is not None: + dumped = [ + m.model_dump(exclude_none=True) + for m in normalize_messages(messages) + ] + if prompt: + dumped.append({"role": "user", "content": prompt}) + input_body = {"messages": dumped} + elif prompt: + input_body = {"prompt": prompt} + else: + raise ValueError("prompt or messages is required") + return { + "model": model, + "input": input_body, + "parameters": dict(parameters or {}), + } + + +class Tokenization: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> TokenCount: + body = _build_body( + model=model, + prompt=prompt, + messages=messages, + parameters=parameters, + ) + response = self._client.transport.request( + "POST", + _PATH, + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) + + +class AsyncTokenization: + def __init__(self, client) -> None: + self._client = client + + 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, + ) -> TokenCount: + body = _build_body( + model=model, + prompt=prompt, + messages=messages, + parameters=parameters, + ) + response = await self._client.transport.request( + "POST", + _PATH, + json=body, + timeout=timeout, + api_key=api_key, + workspace=workspace, + sdk_module=MODULE, + ) + check_response(response, raise_on_error=self._client.raise_on_error) + return with_headers(_parse(response.json()), response) diff --git a/dashscope/v2/resources/videos.py b/dashscope/v2/resources/videos.py new file mode 100644 index 00000000..b88c5db1 --- /dev/null +++ b/dashscope/v2/resources/videos.py @@ -0,0 +1,591 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""client.videos resource (design doc M3 3.3). + +One service, two input protocols, picked by model name: + +* legacy — ``img_url`` first-frame input (the public name is + ``image_url``), ``prompt``-only works too. Serves wan2.6 and below + (wanx2.1-t2v-*, wan2.6-i2v, ...). +* media — ``media`` input: a list of typed assets, one per kind + (``first_frame`` / ``last_frame`` / ``driving_audio`` / + ``first_clip``). wan2.7-i2v only answers here; ``image_url`` is + accepted as shorthand for a lone ``first_frame`` asset. + +Both protocols submit to the same video-synthesis endpoint and answer +the same async-task envelope, so ``generate`` returns the unified +``Job`` — ``video_url`` reads the same either way. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union + +from dashscope.v2.resources._jobs import ( + DEFAULT_MAX_INTERVAL, + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + AsyncJobs, + Job, + JobList, + Jobs, +) +from dashscope.v2.resources._oss import ( + OSS_RESOLVE_HEADERS, + upload_media_value, +) + +__all__ = [ + "AsyncVideos", + "Videos", +] + +SERVICE = "aigc/video-generation/video-synthesis" +# Functional-area tag for x-dashscope-sdk-client (1.x VideoSynthesis lives +# under the ``aigc`` package). +MODULE = "aigc" + +# Models that only answer on the media-based input protocol. Prefix +# match so dated variants (``wan2.7-i2v-2026-04-25``) route the same way. +MEDIA_MODEL_PREFIXES = ("wan2.7-i2v",) + + +def _uses_media_protocol(model: str) -> bool: + return model.startswith(MEDIA_MODEL_PREFIXES) + + +def _upload_inputs( + client, + model: str, + image_url: Optional[str], + media: Optional[List[Dict[str, Any]]], + extra_input: Optional[Dict[str, Any]], + api_key: Optional[str], + workspace: Optional[str], +) -> Tuple[ + Optional[str], + Optional[List[Dict[str, Any]]], + Optional[Dict[str, Any]], + bool, +]: + """Upload local/oss first-frame and media assets before the body.""" + has_upload = False + if image_url is not None: + image_url, is_up = upload_media_value( + client, + image_url, + model, + api_key, + workspace, + ) + has_upload = has_upload or is_up + if media: + media, is_up = upload_media_value( + client, + [dict(item) for item in media], + model, + api_key, + workspace, + ) + has_upload = has_upload or is_up + if extra_input: + extra_input, is_up = upload_media_value( + client, + dict(extra_input), + model, + api_key, + workspace, + ) + has_upload = has_upload or is_up + return image_url, media, extra_input, has_upload + + +def _build_input( + model: str, + prompt: Optional[str], + image_url: Optional[str], + media: Optional[List[Dict[str, Any]]], + extra_input: Optional[Dict[str, Any]], + function: Optional[str] = None, +) -> Dict[str, Any]: + input_data: Dict[str, Any] = {} + if prompt is not None: + input_data["prompt"] = prompt + if _uses_media_protocol(model): + items = [dict(item) for item in media or []] + if image_url is not None: + # ``image_url`` is shorthand for a lone first_frame asset. + if any(item.get("type") == "first_frame" for item in items): + raise ValueError( + "image_url duplicates a first_frame media item", + ) + items.insert(0, {"type": "first_frame", "url": image_url}) + if not items: + raise ValueError( + f"videos.generate requires media or image_url with {model}", + ) + input_data["media"] = items + else: + if media: + input_data["media"] = [dict(item) for item in media] + if image_url is not None: + # Wire spelling; the public name is ``image_url``. + input_data["img_url"] = image_url + if extra_input: + input_data.update(extra_input) + if function is not None: + input_data["function"] = function + if not input_data: + raise ValueError("videos.generate requires prompt or image_url") + return input_data + + +def _build_parameters( + *, + seed: Optional[int], + size: Optional[str], + duration: Optional[int], + resolution: Optional[str], + ratio: Optional[str], + prompt_extend: Optional[bool], + watermark: Optional[bool], + parameters: Optional[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + """Merge the 1.x video kwargs into ``parameters``; the named kwarg wins.""" + merged = dict(parameters or {}) + for key, value in ( + ("seed", seed), + ("size", size), + ("duration", duration), + ("resolution", resolution), + ("ratio", ratio), + ("prompt_extend", prompt_extend), + ("watermark", watermark), + ): + if value is not None: + merged[key] = value + return merged or None + + +class VideoJobs: + """Async task facade: create/fetch/wait over the video service.""" + + def __init__(self, client) -> None: + self._client = client + self._jobs = Jobs(client, SERVICE, MODULE) + + 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, + ) -> Job: + image_url, media, extra_input, has_upload = _upload_inputs( + self._client, + model, + image_url, + media, + extra_input, + api_key, + workspace, + ) + return self._jobs.create( + model=model, + input=_build_input( + model, + prompt, + image_url, + media, + extra_input, + function, + ), + parameters=_build_parameters( + seed=seed, + size=size, + duration=duration, + resolution=resolution, + ratio=ratio, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parameters, + ), + headers=OSS_RESOLVE_HEADERS if has_upload else None, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return self._jobs.fetch( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + return self._jobs.cancel( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + 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, + ) -> JobList: + """List async tasks (``GET /tasks``).""" + return self._jobs.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=page_no, + page_size=page_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Any]] = None, + ) -> Job: + return self._jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class Videos: + """Text/image-to-video: one-shot generate + jobs.create/fetch/wait.""" + + def __init__(self, client) -> None: + self._client = client + self.jobs = VideoJobs(client) + + def generate( + 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: float = DEFAULT_WAIT_TIMEOUT, + submit_timeout: Optional[float] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Submit and wait; returns the finished Job (output.video_url). + + ``timeout`` is the wait budget — the same ``timeout`` as + ``jobs.wait``. ``submit_timeout`` caps only the submit request. + + Media-protocol models (wan2.7-i2v) require ``media`` — one + item per asset type (``first_frame`` / ``last_frame`` / + ``driving_audio`` / ``first_clip``); ``image_url`` still works + as a first-frame shorthand. Older models take ``image_url`` + (wire: ``img_url``) or a bare ``prompt``. + """ + job = self.jobs.create( + model=model, + prompt=prompt, + image_url=image_url, + media=media, + extra_input=extra_input, + function=function, + seed=seed, + size=size, + duration=duration, + resolution=resolution, + ratio=ratio, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parameters, + timeout=submit_timeout, + api_key=api_key, + workspace=workspace, + ) + return self.jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + +class AsyncVideoJobs: + def __init__(self, client) -> None: + self._client = client + self._jobs = AsyncJobs(client, SERVICE, MODULE) + + 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, + ) -> Job: + # The upload path is blocking (requests + getPolicy); run it off the + # event loop so it never stalls other tasks. + image_url, media, extra_input, has_upload = await asyncio.to_thread( + _upload_inputs, + self._client, + model, + image_url, + media, + extra_input, + api_key, + workspace, + ) + return await self._jobs.create( + model=model, + input=_build_input( + model, + prompt, + image_url, + media, + extra_input, + function, + ), + parameters=_build_parameters( + seed=seed, + size=size, + duration=duration, + resolution=resolution, + ratio=ratio, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parameters, + ), + headers=OSS_RESOLVE_HEADERS if has_upload else None, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def fetch( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + return await self._jobs.fetch( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def cancel( + self, + task_id: str, + *, + timeout: Optional[float] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """Cancel a PENDING task (``POST /tasks/{task_id}/cancel``).""" + return await self._jobs.cancel( + task_id, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + 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, + ) -> JobList: + """List async tasks (``GET /tasks``).""" + return await self._jobs.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=page_no, + page_size=page_size, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) + + async def wait( + self, + job: Union[Job, str], + *, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + _sleep: Optional[Callable[[float], Awaitable[None]]] = None, + ) -> Job: + return await self._jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + _sleep=_sleep, + ) + + +class AsyncVideos: + def __init__(self, client) -> None: + self._client = client + self.jobs = AsyncVideoJobs(client) + + async def generate( + 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: float = DEFAULT_WAIT_TIMEOUT, + submit_timeout: Optional[float] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + max_interval: float = DEFAULT_MAX_INTERVAL, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + ) -> Job: + """See :meth:`Videos.generate`.""" + job = await self.jobs.create( + model=model, + prompt=prompt, + image_url=image_url, + media=media, + extra_input=extra_input, + function=function, + seed=seed, + size=size, + duration=duration, + resolution=resolution, + ratio=ratio, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parameters, + timeout=submit_timeout, + api_key=api_key, + workspace=workspace, + ) + return await self.jobs.wait( + job, + poll_interval=poll_interval, + max_interval=max_interval, + timeout=timeout, + api_key=api_key, + workspace=workspace, + ) diff --git a/dashscope/v2/transport/__init__.py b/dashscope/v2/transport/__init__.py new file mode 100644 index 00000000..ecc95dac --- /dev/null +++ b/dashscope/v2/transport/__init__.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Transport layer for the dashscope 2.0 SDK.""" + +from dashscope.v2.transport.http import ( + AsyncTransport, + SyncTransport, + default_user_agent, +) + +__all__ = ["AsyncTransport", "SyncTransport", "default_user_agent"] diff --git a/dashscope/v2/transport/_ident.py b/dashscope/v2/transport/_ident.py new file mode 100644 index 00000000..7d856e0d --- /dev/null +++ b/dashscope/v2/transport/_ident.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SDK identification headers shared by every v2 transport. + +``x-dashscope-sdk-client`` marks which SDK is calling, in the +``client/version/module`` form shared with the 1.x SDK (e.g. +``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). +""" + +from __future__ import annotations + +import os +from typing import Optional + +# One process-wide session id shared with the 1.x API classes, so a +# single SDK session is not split across ids when the 2.0 client +# delegates calls (rl, agentstudio) into 1.x. (acli keeps its own: it is +# a one-way sync copy, see common/_sdk_session.) +from dashscope.common._sdk_session import sdk_session_id + +SDK_CLIENT_HEADER = "x-dashscope-sdk-client" +SDK_SESSION_ID_HEADER = "x-dashscope-sdk-session-id" + +# ``client/version/module``, mirroring 1.x ``common.utils.get_sdk_headers``. +# The client segment keeps server-side language stats on one spelling +# (``python-sdk``, matching the Java SDK's ``java-sdk``). The module segment +# names the functional area issuing the request (``aigc`` / ``audio`` / +# ``embeddings`` / ``finetune`` / ...) using the same base vocabulary as 1.x +# ``get_api_module``, but carries a trailing ``V2_MODULE_MARKER`` so the two +# API generations stay separable. The version segment cannot do this: one +# installed package (e.g. 2.0.0) ships both the 1.x surface (``dashscope.aigc``) +# and this v2 surface, and both read the same ``__version__``, so a 1.x +# ``Generation.call()`` and a v2 ``client.chat.completions.create()`` would +# otherwise both emit ``python-sdk/2.0.0/aigc``. With the marker v2 emits +# ``aigc2`` while 1.x stays ``aigc``. Resources pass their own module per +# request; ``DEFAULT_MODULE`` is the fallback when none is given. +SDK_CLIENT = "python-sdk" +DEFAULT_MODULE = "dashscope" +# Trailing generation marker appended to every v2 module tag (``aigc`` -> +# ``aigc2``). Only the v2 transport applies it; the 1.x surface in +# ``common.utils.get_sdk_headers`` emits the unmarked module, and calls that +# delegate into 1.x (e.g. ``client.agentstudio``) inherit the 1.x tag. +V2_MODULE_MARKER = "2" + + +def _sdk_version() -> str: + try: + from dashscope.version import __version__ + except Exception: # pragma: no cover - defensive import guard + return "0.0.0" + return __version__ + + +# Read once at import so the value is stable for the process lifetime. +_VERSION = _sdk_version() + + +def sdk_client_value(module: Optional[str] = None) -> str: + """``python-sdk//2`` for the given functional area. + + The trailing ``2`` (``V2_MODULE_MARKER``) marks the request as issued + through the v2 API surface, so server-side stats split v2 traffic + (``aigc2``) from 1.x traffic (``aigc``) made from the same package. + """ + base = module or DEFAULT_MODULE + return f"{SDK_CLIENT}/{_VERSION}/{base}{V2_MODULE_MARKER}" + + +# Default (module-less) value, kept as a module constant for callers and +# tests that identify the SDK without a specific resource. +SDK_CLIENT_VALUE = sdk_client_value() + + +def sdk_headers(module: 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. + """ + if os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): + return {} + return { + SDK_CLIENT_HEADER: sdk_client_value(module), + SDK_SESSION_ID_HEADER: sdk_session_id(), + } diff --git a/dashscope/v2/transport/_retry.py b/dashscope/v2/transport/_retry.py new file mode 100644 index 00000000..20f796c2 --- /dev/null +++ b/dashscope/v2/transport/_retry.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Retry policy shared by sync/async transports.""" + +from __future__ import annotations + +import random +import time +from email.utils import parsedate_to_datetime +from typing import Mapping, Optional + +RETRYABLE_STATUS_CODES = frozenset({408, 409, 429, 500, 502, 503, 504}) +INITIAL_BACKOFF = 0.5 +MAX_BACKOFF = 8.0 +JITTER_RATIO = 0.25 + + +def should_retry(status_code: int, headers: Mapping[str, str]) -> bool: + override = headers.get("x-should-retry") + if override == "true": + return True + if override == "false": + return False + return status_code in RETRYABLE_STATUS_CODES + + +def retry_after_seconds(headers: Mapping[str, str]) -> Optional[float]: + ms = headers.get("retry-after-ms") + if ms is not None: + try: + return max(float(ms) / 1000.0, 0.0) + except ValueError: + pass + value = headers.get("retry-after") + if value is None: + return None + try: + return max(float(value), 0.0) + except ValueError: + pass + try: + delta = parsedate_to_datetime(value).timestamp() + except (TypeError, ValueError): + return None + return max(delta - time.time(), 0.0) + + +def backoff_delay(attempt: int, headers: Optional[Mapping[str, str]]) -> float: + if headers is not None: + server_delay = retry_after_seconds(headers) + if server_delay is not None: + return min(server_delay, MAX_BACKOFF) + base = min(INITIAL_BACKOFF * (2**attempt), MAX_BACKOFF) + return base + random.uniform(0, base * JITTER_RATIO) diff --git a/dashscope/v2/transport/http.py b/dashscope/v2/transport/http.py new file mode 100644 index 00000000..8dbcb6d1 --- /dev/null +++ b/dashscope/v2/transport/http.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""httpx-based sync/async transports with connection pooling and retries.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any, Mapping, Optional, Union + +import httpx + +from dashscope.v2._config import DEFAULT_TIMEOUT +from dashscope.v2._logging import get_logger +from dashscope.v2.errors import ( + APIConnectionError, + APITimeoutError, + DashScopeError, + check_response, +) +from dashscope.v2.transport._ident import sdk_headers +from dashscope.v2.transport._retry import backoff_delay, should_retry + +logger = get_logger("transport") + +DEFAULT_CONNECT_TIMEOUT = 5.0 +_WORKSPACE_HEADER = "X-DashScope-WorkSpace" + + +def _map_transport_error(exc: httpx.TransportError) -> DashScopeError: + """Map raw httpx transport failures into the SDK error hierarchy.""" + if isinstance(exc, httpx.TimeoutException): + return APITimeoutError(f"request timed out: {exc}") + return APIConnectionError(f"connection error: {exc}") + + +def _resolve_timeout( + timeout: Union[float, httpx.Timeout, None], + default: float, +) -> httpx.Timeout: + if timeout is None: + return httpx.Timeout(default, connect=DEFAULT_CONNECT_TIMEOUT) + if isinstance(timeout, httpx.Timeout): + return timeout + return httpx.Timeout(timeout, connect=DEFAULT_CONNECT_TIMEOUT) + + +def _build_headers( + api_key: Optional[str], + workspace: Optional[str], + user_agent: Optional[str], +) -> dict: + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + if workspace: + headers[_WORKSPACE_HEADER] = workspace + if user_agent: + headers["User-Agent"] = user_agent + return headers + + +def _timeout_kwarg( + timeout: Union[float, httpx.Timeout, None], +) -> dict: + # httpx reads an explicit timeout=None as "disable timeouts"; + # the argument must be omitted to keep the client-level default. + return {} if timeout is None else {"timeout": timeout} + + +def _with_sdk_headers( + sdk: Mapping[str, str], + headers: 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} + merged = { + key: value + for key, value in sdk.items() + if key.lower() not in caller_keys + } + merged.update(headers) + return merged + + +def _merge_auth_headers( + headers: Optional[Mapping[str, str]], + api_key: Optional[str], + workspace: Optional[str], +) -> Optional[Mapping[str, str]]: + """Per-call ``api_key`` / ``workspace`` override the client auth. + + httpx merges request headers over the client defaults, so injecting + ``Authorization`` / ``X-DashScope-WorkSpace`` here replaces the + client-level credentials for this one call only. + """ + if api_key is None and workspace is None: + return headers + merged = dict(headers or {}) + merged.update(_build_headers(api_key, workspace, None)) + return merged + + +def _abs_url(base_url: Optional[str], path: str) -> str: + # Per-request base_url override: an absolute URL replaces the + # client-level base_url in httpx's URL merging. Used by the + # protocol-adapted chat family, whose default endpoint differs + # from the native gateway the transport is built with. + if base_url is None: + return path + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" + + +def default_user_agent() -> str: + try: + from dashscope.version import __version__ + except Exception: + __version__ = "0.0.0" + return f"dashscope-v2-python/{__version__}" + + +class SyncTransport: + def __init__( + self, + *, + base_url: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + user_agent: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + default_timeout: float = DEFAULT_TIMEOUT, + max_retries: int = 2, + http_client: Optional[httpx.Client] = None, + limits: Optional[httpx.Limits] = None, + ) -> None: + self._owns_client = http_client is None + self._client = http_client or httpx.Client( + base_url=base_url, + headers=_build_headers(api_key, workspace, user_agent), + timeout=_resolve_timeout(timeout, default_timeout), + **({"limits": limits} if limits is not None else {}), + ) + self._max_retries = max(max_retries, 0) + + @property + def http_client(self) -> httpx.Client: + """The underlying httpx client (shared by ``with_options``).""" + return self._client + + def request( + 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, + ) -> httpx.Response: + url = _abs_url(base_url, path) + for attempt in range(self._max_retries + 1): + try: + response = self._client.request( + method, + url, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=_with_sdk_headers( + sdk_headers(sdk_module), + _merge_auth_headers(headers, api_key, workspace), + ), + **_timeout_kwarg(timeout), + ) + except httpx.TransportError as exc: + if attempt >= self._max_retries: + logger.warning( + "request %s %s failed after %d attempt(s): %s", + method, + path, + attempt + 1, + exc, + ) + raise _map_transport_error(exc) from exc + delay = backoff_delay(attempt, None) + logger.info( + "retrying %s %s in %.2fs (attempt %d/%d): %s: %s", + method, + path, + delay, + attempt + 1, + self._max_retries, + type(exc).__name__, + exc, + ) + _sleep(delay) + continue + request_id = response.headers.get("x-request-id") + logger.debug( + "request %s %s -> %d (request_id=%s)", + method, + path, + response.status_code, + request_id, + ) + if not should_retry(response.status_code, response.headers): + return response + if attempt >= self._max_retries: + return response + delay = backoff_delay(attempt, response.headers) + logger.info( + "retrying %s %s in %.2fs (attempt %d/%d): status %d" + " (request_id=%s)", + method, + path, + delay, + attempt + 1, + self._max_retries, + response.status_code, + request_id, + ) + _sleep(delay) + return response + + def open_stream( + 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, + ) -> httpx.Response: + # Streaming responses surface errors inside the event stream; + # only the initial status is checked here, no retries. This + # no-retry contract is pinned by tests/v2/test_transport.py and + # relied on by examples/04_real_world_scenarios/ + # production_ready_rag_harness.py, which layers its own stream + # backoff on top -- update that example if retries are added. + # The caller owns the response and must close it. + try: + response = self._client.send( + self._client.build_request( + method, + _abs_url(base_url, path), + json=json, + params=params, + headers=_with_sdk_headers( + sdk_headers(sdk_module), + _merge_auth_headers(headers, api_key, workspace), + ), + **_timeout_kwarg(timeout), + ), + stream=True, + ) + except httpx.TransportError as exc: + raise _map_transport_error(exc) from exc + if response.status_code >= 400: + response.read() + response.close() + check_response(response) + logger.debug( + "stream %s %s -> %d (request_id=%s)", + method, + path, + response.status_code, + response.headers.get("x-request-id"), + ) + return response + + def close(self) -> None: + if self._owns_client: + self._client.close() + + +class AsyncTransport: + def __init__( + self, + *, + base_url: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + user_agent: Optional[str] = None, + timeout: Union[float, httpx.Timeout, None] = None, + default_timeout: float = DEFAULT_TIMEOUT, + max_retries: int = 2, + http_client: Optional[httpx.AsyncClient] = None, + limits: Optional[httpx.Limits] = None, + ) -> None: + self._owns_client = http_client is None + self._client = http_client or httpx.AsyncClient( + base_url=base_url, + headers=_build_headers(api_key, workspace, user_agent), + timeout=_resolve_timeout(timeout, default_timeout), + **({"limits": limits} if limits is not None else {}), + ) + self._max_retries = max(max_retries, 0) + + @property + def http_client(self) -> httpx.AsyncClient: + """The underlying httpx client (shared by ``with_options``).""" + return self._client + + async def request( + 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, + ) -> httpx.Response: + url = _abs_url(base_url, path) + for attempt in range(self._max_retries + 1): + try: + response = await self._client.request( + method, + url, + json=json, + content=content, + data=data, + files=files, + params=params, + headers=_with_sdk_headers( + sdk_headers(sdk_module), + _merge_auth_headers(headers, api_key, workspace), + ), + **_timeout_kwarg(timeout), + ) + except httpx.TransportError as exc: + if attempt >= self._max_retries: + logger.warning( + "request %s %s failed after %d attempt(s): %s", + method, + path, + attempt + 1, + exc, + ) + raise _map_transport_error(exc) from exc + delay = backoff_delay(attempt, None) + logger.info( + "retrying %s %s in %.2fs (attempt %d/%d): %s: %s", + method, + path, + delay, + attempt + 1, + self._max_retries, + type(exc).__name__, + exc, + ) + await _asleep(delay) + continue + request_id = response.headers.get("x-request-id") + logger.debug( + "request %s %s -> %d (request_id=%s)", + method, + path, + response.status_code, + request_id, + ) + if not should_retry(response.status_code, response.headers): + return response + if attempt >= self._max_retries: + return response + delay = backoff_delay(attempt, response.headers) + logger.info( + "retrying %s %s in %.2fs (attempt %d/%d): status %d" + " (request_id=%s)", + method, + path, + delay, + attempt + 1, + self._max_retries, + response.status_code, + request_id, + ) + await _asleep(delay) + return response + + async def open_stream( + 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, + ) -> httpx.Response: + # Same no-retry contract as the sync open_stream above. + request = self._client.build_request( + method, + _abs_url(base_url, path), + json=json, + params=params, + headers=_with_sdk_headers( + sdk_headers(sdk_module), + _merge_auth_headers(headers, api_key, workspace), + ), + **_timeout_kwarg(timeout), + ) + try: + response = await self._client.send(request, stream=True) + except httpx.TransportError as exc: + raise _map_transport_error(exc) from exc + if response.status_code >= 400: + await response.aread() + await response.aclose() + check_response(response) + logger.debug( + "stream %s %s -> %d (request_id=%s)", + method, + path, + response.status_code, + response.headers.get("x-request-id"), + ) + return response + + async def aclose(self) -> None: + if self._owns_client: + await self._client.aclose() + + +def _sleep(seconds: float) -> None: + time.sleep(seconds) + + +async def _asleep(seconds: float) -> None: + await asyncio.sleep(seconds) diff --git a/dashscope/v2/transport/sse.py b/dashscope/v2/transport/sse.py new file mode 100644 index 00000000..7efccf7a --- /dev/null +++ b/dashscope/v2/transport/sse.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Unified SSE parsing for all wire protocols (M2 task 2.4). + +Every protocol delivers streaming events as SSE; this module decodes +the events and turns in-stream errors into exceptions, regardless of +wire format: + +- native: ``{"code": "...", "message": "..."}`` +- openai: ``{"error": {"message": "...", "type": ..., "code": ...}}`` +- anthropic: ``{"type": "error", "error": {"message": ..., "type": ...}}`` + +Errors raise and are never yielded. Chunk parsers may return ``None`` +to skip events with no user-facing content (e.g. anthropic ``ping`` +keepalives), so those never surface as empty chunks either. +""" + +from __future__ import annotations + +import json +from typing import ( + Any, + Callable, + Dict, + Generic, + Iterator, + Mapping, + Optional, + TypeVar, +) + +import httpx +from httpx_sse import EventSource + +from dashscope.v2.errors import DashScopeError, error_from_business_code + +T = TypeVar("T") + +# OpenAI-compatible endpoints terminate streams with this sentinel. +DONE_MARKER = "[DONE]" + + +def parse_event_data(data: str) -> Dict[str, Any]: + """Decode one SSE ``data:`` payload into a JSON object.""" + try: + body = json.loads(data) + except json.JSONDecodeError as exc: + raise DashScopeError(f"malformed SSE payload: {data!r}") from exc + if not isinstance(body, dict): + raise DashScopeError(f"unexpected SSE payload: {data!r}") + return body + + +def stream_error( + body: Dict[str, Any], + headers: Optional[Mapping[str, str]] = None, +) -> Optional[DashScopeError]: + """Detect an in-stream error in a native/openai/anthropic payload. + + Returns ``None`` when the payload is a normal (non-error) event. + ``headers`` feeds the x-request-id fallback on the raised error. + """ + if body.get("code"): + return error_from_business_code( + message=body.get("message") or "stream error", + body=body, + headers=headers, + ) + error = body.get("error") + if isinstance(error, dict): + return error_from_business_code( + message=error.get("message") or "stream error", + code=error.get("code") or error.get("type"), + body=body, + headers=headers, + ) + return None + + +def _next_event( + data: str, + headers: Optional[Mapping[str, str]] = None, +) -> Optional[Dict[str, Any]]: + """Decode one event, raising on in-stream errors; None to skip.""" + if not data or data == DONE_MARKER: + return None + body = parse_event_data(data) + error = stream_error(body, headers) + if error is not None: + raise error + return body + + +class Stream(Generic[T]): + """Typed iterator over SSE chunks; in-stream errors raise.""" + + def __init__( + self, + response: httpx.Response, + parse: Callable[[Dict[str, Any]], Optional[T]], + ) -> None: + self._response = response + self._parse = parse + + def __iter__(self) -> Iterator[T]: + try: + for sse in EventSource(self._response).iter_sse(): + body = _next_event(sse.data, self._response.headers) + if body is None: + continue + chunk = self._parse(body) + if chunk is not None: + yield chunk + finally: + self._response.close() + + def close(self) -> None: + self._response.close() + + @property + def headers(self) -> Dict[str, str]: + """Raw HTTP response headers of the stream's envelope.""" + return dict(self._response.headers) + + +class AsyncStream(Generic[T]): + def __init__( + self, + response: httpx.Response, + parse: Callable[[Dict[str, Any]], Optional[T]], + ) -> None: + self._response = response + self._parse = parse + + def __aiter__(self): + return self._iterate() + + async def _iterate(self): + try: + async for sse in EventSource(self._response).aiter_sse(): + body = _next_event(sse.data, self._response.headers) + if body is None: + continue + chunk = self._parse(body) + if chunk is not None: + yield chunk + finally: + await self._response.aclose() + + async def aclose(self) -> None: + await self._response.aclose() + + @property + def headers(self) -> Dict[str, str]: + """Raw HTTP response headers of the stream's envelope.""" + return dict(self._response.headers) diff --git a/dashscope/v2/transport/ws.py b/dashscope/v2/transport/ws.py new file mode 100644 index 00000000..4b311d51 --- /dev/null +++ b/dashscope/v2/transport/ws.py @@ -0,0 +1,344 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""DashScope duplex websocket framing for realtime audio.""" + +# pylint: disable=redefined-builtin + +from __future__ import annotations + +import json +import threading +import uuid +from typing import Any, Callable, Dict, List, Mapping, Optional, Union + +from dashscope.v2._config import ( + WS_INFERENCE_PATH, + settings, + workspace_ws_url, +) +from dashscope.v2.errors import ( + APIConnectionError, + DashScopeError, + error_from_business_code, +) +from dashscope.v2.transport import default_user_agent +from dashscope.v2.transport._ident import sdk_headers + +__all__ = [ + "Connector", + "EVENT_FINISHED", + "EVENT_GENERATED", + "EVENT_STARTED", + "WsEventError", + "WsSession", +] + +EVENT_STARTED = "task-started" +EVENT_GENERATED = "result-generated" +EVENT_FINISHED = "task-finished" +EVENT_FAILED = "task-failed" + +Connector = Callable[[str, Dict[str, str]], Any] + + +class WsEventError(DashScopeError): + """The server sent an unexpected websocket protocol event.""" + + +def _default_connector( + url: str, + headers: Dict[str, str], + *, + timeout: Optional[float] = None, +) -> Any: + import websocket + + return websocket.create_connection(url, header=headers, timeout=timeout) + + +def _close_websocket(ws: Any) -> None: + from websocket import WebSocket + + try: + try: + ws.close() + finally: + # Peer close sets connected=False without releasing the socket. + if isinstance(ws, WebSocket): + ws.shutdown() + except Exception: + pass + + +def _assemble_headers( + api_key: Optional[str], + workspace: Optional[str], + headers: Optional[Mapping[str, str]], + module: Optional[str], +) -> Dict[str, str]: + all_headers = dict(headers or {}) + if api_key: + all_headers["Authorization"] = f"Bearer {api_key}" + if workspace: + all_headers["X-DashScope-WorkSpace"] = workspace + all_headers.setdefault("user-agent", default_user_agent()) + provided = {key.lower() for key in all_headers} + for key, value in sdk_headers(module).items(): + if key.lower() not in provided: + all_headers[key] = value + return all_headers + + +def _connect( + url: str, + headers: Dict[str, str], + connector: Optional[Connector], + timeout: Optional[float], +) -> Any: + if connector is None: + ws = _default_connector(url, headers, timeout=timeout) + else: + ws = connector(url, headers) + try: + if timeout is not None: + ws.settimeout(timeout) + except BaseException: + _close_websocket(ws) + raise + return ws + + +def _recv_frame(ws: Any) -> Any: + """Read one frame, normalizing a library close to a clean EOF (``None``). + + A peer abort or a concurrent close() surfaces as + ``WebSocketConnectionClosedException`` rather than a clean EOF; mapping + it to ``None`` lets every caller treat it as premature EOF and keep the + error inside the DashScope hierarchy. Shared by ``WsSession.recv`` and + ``WsConnection``'s reader thread so the normalization cannot drift. + """ + from websocket import ( # pylint: disable=import-outside-toplevel + WebSocketConnectionClosedException, + ) + + try: + return ws.recv() + except WebSocketConnectionClosedException: + return None + + +class _EventProcessor: + """Shared event-header bookkeeping for WsSession and WsTask.""" + + _request_id: str + _finished: bool + + def _process_event(self, event: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(event, dict) or not isinstance( + event.get("header"), + dict, + ): + raise WsEventError("expected a websocket event header") + header = event["header"] + self._request_id = ( + header.get("request_id") + or header.get("task_id") + or self._request_id + ) + if header.get("event") == EVENT_FAILED: + self._finished = True + raise error_from_business_code( + message=header.get("error_message") or "task failed", + code=header.get("error_code"), + body=event, + headers={"x-request-id": self._request_id}, + ) + if header.get("event") == EVENT_FINISHED: + self._finished = True + return event + + +class WsSession(_EventProcessor): + """One task with an independent sender and receiver on a duplex socket.""" + + def __init__( + self, + *, + url: Optional[str] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + module: Optional[str] = None, + ) -> None: + if timeout is not None and timeout <= 0: + raise ValueError("timeout must be positive") + self._url = ( + url + or settings.websocket_base_url + or workspace_ws_url(workspace, WS_INFERENCE_PATH, region) + ) + self._task_id = uuid.uuid4().hex + self._request_id = self._task_id + self._lock = threading.Lock() + self._run_sent = False + self._started = False + self._input_finished = False + self._cancel_sent = False + self._finished = False + self._closed = False + self._ws = _connect( + self._url, + _assemble_headers(api_key, workspace, headers, module), + connector, + timeout, + ) + + @property + def task_id(self) -> str: + return self._task_id + + @property + def request_id(self) -> str: + return self._request_id + + @property + def finished(self) -> bool: + return self._finished + + def _frame(self, action: str, payload: Dict[str, Any]) -> Dict[str, Any]: + return { + "header": { + "action": action, + "task_id": self._task_id, + "streaming": "duplex", + }, + "payload": payload, + } + + def _send(self, data: Any, *, opcode: int = 1) -> None: + try: + if opcode == 2: + self._ws.send(data, opcode=2) + else: + self._ws.send(json.dumps(data)) + except BaseException: + self.close() + raise + + def run_task( + self, + *, + task_group: str, + task: str, + function: str, + model: str, + parameters: Optional[Dict[str, Any]] = None, + input: Optional[Dict[str, Any]] = None, + resources: Optional[List[Dict[str, Any]]] = None, + streaming: str = "duplex", + ) -> Dict[str, Any]: + """Send run-task and wait for task-started before allowing input.""" + payload: Dict[str, Any] = { + "task_group": task_group, + "task": task, + "function": function, + "model": model, + "input": input if input is not None else {}, + } + if parameters is not None: + payload["parameters"] = parameters + if resources is not None: + payload["resources"] = resources + frame = self._frame("run-task", payload) + frame["header"]["streaming"] = streaming + with self._lock: + if self._closed or self._run_sent: + raise RuntimeError("websocket task already started or closed") + self._run_sent = True + self._send(frame) + try: + event = self.recv_event() + if event["header"].get("event") != EVENT_STARTED: + raise WsEventError( + "expected task-started, got " + f"{event['header'].get('event')!r}", + request_id=self.request_id, + ) + self._started = True + return event + except BaseException: + self.close() + raise + + def _check_input(self) -> None: + if self._closed or not self._started or self._finished: + raise RuntimeError("websocket task is not active") + if self._input_finished: + raise RuntimeError("websocket task input is finished") + + def send_binary(self, data: bytes) -> None: + if not isinstance(data, (bytes, bytearray, memoryview)): + raise TypeError("audio data must be bytes") + with self._lock: + self._check_input() + self._send(bytes(data), opcode=2) + + def continue_task(self, input: Dict[str, Any]) -> None: + with self._lock: + self._check_input() + self._send(self._frame("continue-task", {"input": input})) + + def finish_task(self, input: Optional[Dict[str, Any]] = None) -> None: + """End input, or cancel synthesis while output is still arriving. + + Most tasks finish with an empty input; TingWu realtime sends its + stop directive (appId + directive) here instead. + """ + with self._lock: + if self._closed or not self._started: + raise RuntimeError("websocket task is not active") + if self._finished: + return + cancel = input is not None and input.get("directive") == "cancel" + if self._cancel_sent or (self._input_finished and not cancel): + return + self._send(self._frame("finish-task", {"input": input or {}})) + self._input_finished = True + self._cancel_sent = cancel + + def recv_event(self) -> Dict[str, Any]: + """Read the next control event; use recv for binary output.""" + while True: + frame = self.recv() + if frame is None: + raise RuntimeError("websocket task is finished") + if isinstance(frame, dict): + return frame + + def recv(self) -> Union[Dict[str, Any], bytes, None]: + """Read JSON or audio; premature EOF is not task completion.""" + if self._finished: + return None + if self._closed: + raise RuntimeError("websocket session is closed") + try: + raw = _recv_frame(self._ws) + if raw is None or raw == "": + raise APIConnectionError( + "websocket closed before task-finished", + request_id=self.request_id, + ) + if isinstance(raw, (bytes, bytearray)): + return bytes(raw) + return self._process_event(json.loads(raw)) + except BaseException: + self.close() + raise + + def close(self) -> None: + if not self._closed: + self._closed = True + _close_websocket(self._ws) diff --git a/dashscope/v2/transport/ws_connection.py b/dashscope/v2/transport/ws_connection.py new file mode 100644 index 00000000..6204059c --- /dev/null +++ b/dashscope/v2/transport/ws_connection.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Shared duplex websocket connection with per-task demultiplexing. + +``WsConnection`` owns one physical socket plus a background reader +thread that routes JSON events to tasks by ``header.task_id``. A +``WsTask`` exposes the same interface as ``WsSession``, so the audio +resources (asr / tts / translation / tingwu) can run several tasks +over one connection -- concurrently for JSON-framed traffic, or +sequentially with no re-handshake between tasks. + +Binary frames carry no task id, so each direction allows a single +owner at a time: the first task to send binary claims the uplink, and +the downlink goes to the task that claimed it +(``claim_binary_downlink``) or, with exactly one active task, to that +task implicitly. Ownership is released when the owning task finishes +or closes. Concurrent binary output on one connection is impossible +by protocol; use one connection per binary-downlink task in that +case. + +The realtime family is not multiplexable: its protocol has no task id +(one socket is one session, closed when the session ends), so it keeps +opening a dedicated connection per session. +""" + +# pylint: disable=redefined-builtin +# WsTask and WsConnection are one protocol split across two classes; the +# cross-class private access is intra-module by design. +# pylint: disable=protected-access + +from __future__ import annotations + +import json +import queue +import threading +import uuid +from typing import Any, Dict, List, Mapping, Optional, Tuple, Union + +from dashscope.v2._config import ( + WS_INFERENCE_PATH, + settings, + workspace_ws_url, +) +from dashscope.v2.errors import APIConnectionError +from dashscope.v2.transport.ws import ( + EVENT_STARTED, + Connector, + WsEventError, + _assemble_headers, + _close_websocket, + _connect, + _EventProcessor, + _recv_frame, +) + +__all__ = ["WsConnection", "WsTask"] + +_FRAME = "frame" +_ERROR = "error" + +_QueueItem = Tuple[str, Any] + + +class WsTask(_EventProcessor): + """One task on a shared connection; same interface as ``WsSession``. + + ``close()`` only detaches the task from the connection -- the + shared socket stays open for the remaining tasks. + """ + + def __init__( + self, + connection: "WsConnection", + task_id: str, + timeout: Optional[float], + ) -> None: + self._conn = connection + self._task_id = task_id + self._timeout = timeout + self._queue: "queue.SimpleQueue[_QueueItem]" = queue.SimpleQueue() + self._request_id = task_id + self._lock = threading.Lock() + self._run_sent = False + self._started = False + self._input_finished = False + self._cancel_sent = False + self._finished = False + self._closed = False + + @property + def task_id(self) -> str: + return self._task_id + + @property + def request_id(self) -> str: + return self._request_id + + @property + def finished(self) -> bool: + return self._finished + + def _enqueue(self, item: _QueueItem) -> None: + if not self._closed: + self._queue.put(item) + + def _frame(self, action: str, payload: Dict[str, Any]) -> Dict[str, Any]: + return { + "header": { + "action": action, + "task_id": self._task_id, + "streaming": "duplex", + }, + "payload": payload, + } + + def run_task( + self, + *, + task_group: str, + task: str, + function: str, + model: str, + parameters: Optional[Dict[str, Any]] = None, + input: Optional[Dict[str, Any]] = None, + resources: Optional[List[Dict[str, Any]]] = None, + streaming: str = "duplex", + ) -> Dict[str, Any]: + """Send run-task and wait for task-started before allowing input.""" + payload: Dict[str, Any] = { + "task_group": task_group, + "task": task, + "function": function, + "model": model, + "input": input if input is not None else {}, + } + if parameters is not None: + payload["parameters"] = parameters + if resources is not None: + payload["resources"] = resources + frame = self._frame("run-task", payload) + frame["header"]["streaming"] = streaming + with self._lock: + if self._closed or self._run_sent: + raise RuntimeError("websocket task already started or closed") + self._run_sent = True + self._conn._send(frame) + try: + event = self.recv_event() + if event["header"].get("event") != EVENT_STARTED: + raise WsEventError( + "expected task-started, got " + f"{event['header'].get('event')!r}", + request_id=self.request_id, + ) + self._started = True + return event + except BaseException: + self.close() + raise + + def _check_input(self) -> None: + if self._closed or not self._started or self._finished: + raise RuntimeError("websocket task is not active") + if self._input_finished: + raise RuntimeError("websocket task input is finished") + + def claim_binary_downlink(self) -> None: + """Route binary output frames to this task. + + Binary frames carry no task id, so when more than one task is + active the downlink owner must be claimed explicitly (with a + single active task it is assigned implicitly). + """ + self._conn._claim_binary_downlink(self) + + def send_binary(self, data: bytes) -> None: + if not isinstance(data, (bytes, bytearray, memoryview)): + raise TypeError("audio data must be bytes") + with self._lock: + self._check_input() + self._conn._claim_binary_uplink(self) + self._conn._send(bytes(data), opcode=2) + + def continue_task(self, input: Dict[str, Any]) -> None: + with self._lock: + self._check_input() + self._conn._send(self._frame("continue-task", {"input": input})) + + def finish_task(self, input: Optional[Dict[str, Any]] = None) -> None: + """End input, or cancel synthesis while output is still arriving.""" + with self._lock: + if self._closed or not self._started: + raise RuntimeError("websocket task is not active") + if self._finished: + return + cancel = input is not None and input.get("directive") == "cancel" + if self._cancel_sent or (self._input_finished and not cancel): + return + self._conn._send( + self._frame("finish-task", {"input": input or {}}), + ) + self._input_finished = True + self._cancel_sent = cancel + + def recv_event(self) -> Dict[str, Any]: + """Read the next control event; use recv for binary output.""" + while True: + frame = self.recv() + if frame is None: + raise RuntimeError("websocket task is finished") + if isinstance(frame, dict): + return frame + + def recv(self) -> Union[Dict[str, Any], bytes, None]: + """Read the next demuxed frame for this task.""" + if self._finished: + return None + if self._closed: + raise RuntimeError("websocket task is closed") + try: + kind, payload = self._queue.get(timeout=self._timeout) + except queue.Empty: + raise TimeoutError( + "timed out waiting for a websocket event", + ) from None + if kind == _ERROR: + raise payload + if isinstance(payload, (bytes, bytearray)): + return bytes(payload) + try: + return self._process_event(payload) + finally: + if self._finished: + self._conn._release_binary(self) + + def close(self) -> None: + """Detach from the connection; the shared socket stays open.""" + if not self._closed: + self._closed = True + self._conn._detach(self) + + +class WsConnection: + """One physical duplex websocket shared by multiple tasks. + + Construction performs the handshake once; every ``open_task`` then + runs an independent task on the same socket. ``close()`` shuts the + socket down and fails every task still attached. + """ + + def __init__( + self, + *, + url: Optional[str] = None, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + connector: Optional[Connector] = None, + timeout: Optional[float] = None, + module: Optional[str] = None, + ) -> None: + if timeout is not None and timeout <= 0: + raise ValueError("timeout must be positive") + self._url = ( + url + or settings.websocket_base_url + or workspace_ws_url(workspace, WS_INFERENCE_PATH, region) + ) + self._timeout = timeout + self._tasks: Dict[str, WsTask] = {} + self._lock = threading.Lock() + self._send_lock = threading.Lock() + self._closed = False + self._binary_uplink_owner: Optional[WsTask] = None + self._binary_downlink_owner: Optional[WsTask] = None + self._ws = _connect( + self._url, + _assemble_headers(api_key, workspace, headers, module), + connector, + timeout, + ) + # Reads happen only on the reader thread and per-task timeouts + # are enforced on the demux queues, so the socket itself must + # block forever -- a socket read timeout would kill the reader + # during any quiet period longer than the timeout. + settimeout = getattr(self._ws, "settimeout", None) + if callable(settimeout): + settimeout(None) + self._reader = threading.Thread( + target=self._reader_main, + name="dashscope-ws-reader", + daemon=True, + ) + self._reader.start() + + @property + def closed(self) -> bool: + return self._closed + + def open_task(self, *, timeout: Optional[float] = None) -> WsTask: + if timeout is not None and timeout <= 0: + raise ValueError("timeout must be positive") + with self._lock: + if self._closed: + raise RuntimeError("websocket connection is closed") + task = WsTask( + self, + uuid.uuid4().hex, + self._timeout if timeout is None else timeout, + ) + self._tasks[task.task_id] = task + return task + + def _send(self, data: Any, *, opcode: int = 1) -> None: + with self._send_lock: + if self._closed: + raise RuntimeError("websocket connection is closed") + try: + if opcode == 2: + self._ws.send(data, opcode=2) + else: + self._ws.send(json.dumps(data)) + except BaseException: + self.close() + raise + + def _claim_binary_uplink(self, task: WsTask) -> None: + with self._lock: + owner = self._binary_uplink_owner + if owner is not None and owner is not task: + raise RuntimeError( + "another task owns the binary uplink on this connection", + ) + self._binary_uplink_owner = task + + def _claim_binary_downlink(self, task: WsTask) -> None: + with self._lock: + owner = self._binary_downlink_owner + if owner is not None and owner is not task: + raise RuntimeError( + "another task owns the binary downlink on this connection", + ) + self._binary_downlink_owner = task + + def _release_binary(self, task: WsTask) -> None: + with self._lock: + if self._binary_uplink_owner is task: + self._binary_uplink_owner = None + if self._binary_downlink_owner is task: + self._binary_downlink_owner = None + + def _detach(self, task: WsTask) -> None: + with self._lock: + self._tasks.pop(task.task_id, None) + self._release_binary(task) + + def _reader_main(self) -> None: + try: + while True: + raw = _recv_frame(self._ws) + if raw is None or raw == "": + raise APIConnectionError( + "websocket connection closed by peer", + ) + if isinstance(raw, (bytes, bytearray)): + self._route_binary(bytes(raw)) + else: + self._route_event(json.loads(raw)) + except BaseException as exc: + self._fail(exc) + + def _route_event(self, event: Any) -> None: + header = event.get("header") if isinstance(event, dict) else None + task_id = header.get("task_id") if isinstance(header, dict) else None + with self._lock: + task = self._tasks.get(task_id) + if task is None: + # Late event for a detached task; the task is gone, so drop it. + return + task._enqueue((_FRAME, event)) + + def _route_binary(self, data: bytes) -> None: + with self._lock: + owner = self._binary_downlink_owner + if owner is None: + active = [ + task + for task in self._tasks.values() + if task._started + and not task._finished + and not task._closed + ] + if len(active) == 1: + owner = active[0] + self._binary_downlink_owner = owner + if owner is None: + if not active: + # Late or garbage binary with no task to receive it. + return + raise WsEventError( + "binary frame with multiple active tasks; claim the " + "downlink with claim_binary_downlink()", + ) + owner._enqueue((_FRAME, data)) + + def _fail(self, exc: BaseException) -> None: + with self._lock: + if self._closed: + # close() already broadcast; the reader's socket error is + # just the shutdown echo. + return + self._closed = True + _close_websocket(self._ws) + self._broadcast(exc) + + def _broadcast(self, exc: BaseException) -> None: + with self._lock: + tasks = list(self._tasks.values()) + for task in tasks: + task._enqueue((_ERROR, exc)) + + def close(self) -> None: + with self._lock: + if self._closed: + return + self._closed = True + _close_websocket(self._ws) + self._broadcast(APIConnectionError("websocket connection closed")) + if ( + self._reader.is_alive() + and threading.current_thread() is not self._reader + ): + self._reader.join(timeout=2) diff --git a/dashscope/v2/types/__init__.py b/dashscope/v2/types/__init__.py new file mode 100644 index 00000000..b9f5a59a --- /dev/null +++ b/dashscope/v2/types/__init__.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Shared typed models for the dashscope 2.0 SDK.""" + +from dashscope.v2.types.common import JobStatus, ResponseBase, Usage +from dashscope.v2.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + Choice, + Delta, + StreamChoice, + parse_chunk, + parse_completion, +) +from dashscope.v2.types.messages import ( + AudioPart, + ImagePart, + Message, + TextPart, + ToolCall, + ToolCallFunction, + VideoPart, + extract_text, + normalize_messages, +) + +__all__ = [ + "AudioPart", + "ChatCompletion", + "ChatCompletionChunk", + "ChatCompletionMessage", + "Choice", + "Delta", + "ImagePart", + "JobStatus", + "Message", + "ResponseBase", + "StreamChoice", + "TextPart", + "ToolCall", + "ToolCallFunction", + "Usage", + "VideoPart", + "extract_text", + "normalize_messages", + "parse_chunk", + "parse_completion", +] diff --git a/dashscope/v2/types/common.py b/dashscope/v2/types/common.py new file mode 100644 index 00000000..2707125e --- /dev/null +++ b/dashscope/v2/types/common.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Response fields shared by every resource in the dashscope 2.0 SDK.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Protocol, TypeVar + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +__all__ = [ + "JobStatus", + "ResponseBase", + "Usage", + "flatten_envelope", + "response_envelope", + "with_headers", +] + +# ``is_error`` is absent on purpose: the server never sends it, ResponseBase +# derives it from ``code``. +_ENVELOPE_KEYS = ("request_id", "status_code", "code", "message") + + +class ResponseBase(BaseModel): + """Envelope every top-level response carries. + + DashScope can report a failure with HTTP 200, so ``code`` / ``message`` / + ``is_error`` are part of every response rather than a chat-only concern; + they stay empty when the call succeeded. With ``raise_on_error=False`` the + SDK fills them instead of raising. + """ + + model_config = ConfigDict(extra="allow") + + request_id: Optional[str] = None + status_code: Optional[int] = None + code: Optional[str] = None + message: Optional[str] = None + is_error: bool = False + # Raw HTTP response headers, 1.x parity (``GenerationResponse.headers``). + # Transport metadata, not wire body: resources fill it via + # ``with_headers`` right after parsing, so it stays empty on models + # built without a response (websocket-sourced responses, chunks) + # and never round-trips through ``flatten_envelope``. + headers: Dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def _flag_business_error(self) -> "ResponseBase": + # Same rule ``errors.response_error`` applies, so a resource that + # parses the body itself in non-raising mode still reports the + # failure through ``is_error`` instead of leaving it False. + if self.code: + self.is_error = True + return self + + +class JobStatus(ResponseBase): + """Result of a state transition that reports nothing but the new state. + + Shared by ``fine_tunes.cancel`` / ``fine_tunes.delete`` / + ``deployments.delete``, whose responses are identical on the wire. + """ + + status: Optional[str] = None + + +class Usage(BaseModel): + """Token usage, shared by every resource that reports it. + + Service-specific counters (``image_count`` for text-to-image, ``models`` + for applications) arrive as extra fields and stay readable as attributes. + Protocol adapters must preserve that property: they translate the three + canonical counters and pass the rest of the wire bag through, so + per-provider billing detail (cached or reasoning token counts) is never + dropped on the way to the caller. + """ + + model_config = ConfigDict(extra="allow") + + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + + +class _HasHeaders(Protocol): + """Structural minimum ``with_headers`` needs. + + Lets models outside the ``ResponseBase`` tree (e.g. audio + ``HttpSynthesis``) opt into the same header threading by declaring + the field, without forcing the whole envelope on them. + """ + + headers: Dict[str, str] + + +_M = TypeVar("_M", bound=_HasHeaders) + + +def with_headers(model: _M, response: Any) -> _M: + """Attach the raw HTTP response headers to a parsed model. + + Every non-streaming HTTP success path ends with this call so + ``resp.headers`` mirrors what the gateway actually sent. It is a + plain ``dict`` copy: keys are lowercased and a header sent more + than once arrives as one ``", "``-joined value, which is what + keeps the field serializable through ``model_dump()``. Exceptions + differ -- ``errors.response_error`` keeps the live ``httpx.Headers``, + so ``err.headers`` is case-insensitive while ``resp.headers`` only + answers to lowercase keys. Chunks and websocket-sourced models + have no HTTP envelope to copy and keep the empty default; SSE + streams expose theirs via ``Stream.headers`` and + ``ChatStream.get_final_message()``. + """ + model.headers = dict(response.headers) + return model + + +def flatten_envelope( + body: Dict[str, Any], + *, + key: str = "output", +) -> Dict[str, Any]: + """Lift ``body[key]`` to the top level so public models stay flat. + + Envelope keys win, which is how ``request_id`` / ``code`` / ``message`` + reach the flattened model. Envelope values that are ``None`` are dropped + so an absent field cannot overwrite a real payload value. + """ + payload = body.get(key) + flat = dict(payload) if isinstance(payload, dict) else {} + flat.update({k: v for k, v in body.items() if k != key and v is not None}) + return flat + + +def response_envelope(body: Dict[str, Any]) -> Dict[str, Any]: + """Response-level fields, for payloads ``flatten_envelope`` cannot merge. + + Endpoints that answer with a list, or with one item nested under + ``data``, have no dict to lift; their parsers build the model from the + item and thread these fields on separately so the envelope is uniform. + """ + return {k: body[k] for k in _ENVELOPE_KEYS if body.get(k) is not None} diff --git a/dashscope/v2/types/completions.py b/dashscope/v2/types/completions.py new file mode 100644 index 00000000..2d5dc086 --- /dev/null +++ b/dashscope/v2/types/completions.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Typed chat response models (canonical, protocol-agnostic).""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, ConfigDict + +from dashscope.v2.types.common import ResponseBase, Usage +from dashscope.v2.types.messages import ToolCall + +__all__ = [ + "ChatCompletion", + "ChatCompletionChunk", + "ChatCompletionMessage", + "Choice", + "Delta", + "SearchInfo", + "SearchResult", + "StreamChoice", +] + + +class SearchResult(BaseModel): + """One web-search citation (``output.search_info.search_results[]``).""" + + model_config = ConfigDict(extra="allow") + + title: Optional[str] = None + url: Optional[str] = None + site_name: Optional[str] = None + index: Optional[int] = None + + +class SearchInfo(BaseModel): + """Web-search citations, present when search is enabled.""" + + model_config = ConfigDict(extra="allow") + + search_results: List[SearchResult] = [] + + +class ChatCompletionMessage(BaseModel): + model_config = ConfigDict(extra="allow") + + role: str = "assistant" + content: Optional[str] = None + reasoning_content: Optional[str] = None + reasoning_signature: Optional[str] = None + tool_calls: Optional[List[ToolCall]] = None + + +class Delta(BaseModel): + model_config = ConfigDict(extra="allow") + + role: Optional[str] = None + content: Optional[str] = None + reasoning_content: Optional[str] = None + reasoning_signature: Optional[str] = None + tool_calls: Optional[List[ToolCall]] = None + + +class Choice(BaseModel): + model_config = ConfigDict(extra="allow") + + index: int = 0 + finish_reason: Optional[str] = None + message: ChatCompletionMessage + + +class StreamChoice(BaseModel): + model_config = ConfigDict(extra="allow") + + index: int = 0 + finish_reason: Optional[str] = None + delta: Delta + + +class ChatCompletion(ResponseBase): + model: Optional[str] = None + choices: List[Choice] = [] + usage: Optional[Usage] = None + search_info: Optional[SearchInfo] = None + + @property + def output_text(self) -> str: + if not self.choices: + return "" + return self.choices[0].message.content or "" + + +class ChatCompletionChunk(ResponseBase): + model: Optional[str] = None + choices: List[StreamChoice] = [] + usage: Optional[Usage] = None + search_info: Optional[SearchInfo] = None + + @property + def delta(self) -> Optional[Delta]: + if not self.choices: + return None + return self.choices[0].delta + + +def _flatten_content(content: Any) -> Any: + # Multimodal endpoints return list-form content ([{"text": "..."}]); + # join the text parts so canonical models stay string-typed. + if isinstance(content, list): + parts = [] + for part in content: + if isinstance(part, dict) and part.get("text"): + parts.append(part["text"]) + return "".join(parts) or None + return content + + +def _canonical_choices(output: Dict[str, Any]) -> List[Dict[str, Any]]: + choices = output.get("choices") + if choices: + canonical = [] + for raw in choices: + 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 + canonical.append(raw) + return canonical + text = output.get("text") + if text is not None: + return [ + { + "index": 0, + "finish_reason": output.get("finish_reason"), + "message": {"role": "assistant", "content": text}, + }, + ] + 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_chunk(body: Dict[str, Any]) -> ChatCompletionChunk: + output = body.get("output") or {} + choices = [] + for raw in _canonical_choices(output): + 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"), + ) diff --git a/dashscope/v2/types/messages.py b/dashscope/v2/types/messages.py new file mode 100644 index 00000000..3d7c7043 --- /dev/null +++ b/dashscope/v2/types/messages.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Canonical chat message model for the dashscope 2.0 SDK. + +All resources and protocol adapters speak this model; translation to +native/openai/anthropic wire formats happens in the protocol layer. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, model_validator + +__all__ = [ + "AudioPart", + "ImagePart", + "Message", + "MessageInput", + "TextPart", + "ToolCall", + "ToolCallFunction", + "VideoPart", + "extract_text", + "has_media_parts", + "normalize_messages", +] + + +class TextPart(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["text"] = "text" + text: str + # Explicit context-cache marker (Bailian 上下文缓存): a part-level + # sibling such as {"type": "ephemeral"} on the native and + # OpenAI-compatible wires; the cached prefix ends at this part. + # 1.x passed content blocks through unvalidated, so the marker must + # stay accepted on every part type. + cache_control: Optional[Dict[str, Any]] = None + + +class ImagePart(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["image"] = "image" + url: str + detail: Optional[str] = None + # Resolution hints are part-level siblings on both the native and + # OpenAI-compatible wires (Qwen-VL/Omni). An adapter with no + # equivalent (Anthropic) fails fast rather than dropping them: a + # dropped cap silently changes token cost and model behaviour. + min_pixels: Optional[int] = None + max_pixels: Optional[int] = None + cache_control: Optional[Dict[str, Any]] = None + + +class AudioPart(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["audio"] = "audio" + # ``url`` and inline base64 ``data`` are the two possible sources; + # at least one is required. ``format`` describes ``data`` (e.g. + # "mp3", "wav") and lets the native adapter build the data: URI. + url: Optional[str] = None + data: Optional[str] = None + format: Optional[str] = None + cache_control: Optional[Dict[str, Any]] = None + + @model_validator(mode="after") + def _require_source(self) -> "AudioPart": + if self.url is None and self.data is None: + raise ValueError("audio part requires either url or data") + return self + + +class VideoPart(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["video"] = "video" + url: Optional[str] = None + frames: Optional[List[str]] = None + # Part-level sampling/resolution hints on the native and + # OpenAI-compatible wires (Qwen-VL/Omni); same fail-fast rule as the + # image hints above for adapters without an equivalent. + fps: Optional[float] = None + min_pixels: Optional[int] = None + max_pixels: Optional[int] = None + total_pixels: Optional[int] = None + cache_control: Optional[Dict[str, Any]] = None + + +ContentPart = Union[TextPart, ImagePart, AudioPart, VideoPart] + + +class ToolCallFunction(BaseModel): + model_config = ConfigDict(extra="allow") + + # name is absent on non-initial OpenAI stream fragments. + name: Optional[str] = None + arguments: str = "" + + +class ToolCall(BaseModel): + model_config = ConfigDict(extra="allow") + + # id is absent on non-initial OpenAI stream fragments; index + # addresses the fragment's slot in the tool_calls array. + id: Optional[str] = None + type: str = "function" + function: ToolCallFunction = ToolCallFunction() + index: Optional[int] = None + + +class Message(BaseModel): + """One chat message; ``content`` is a string, parts list, or None + (assistant tool-call messages).""" + + model_config = ConfigDict(extra="allow") + + role: str + content: Union[str, List[ContentPart], None] = None + name: Optional[str] = None + tool_calls: Optional[List[ToolCall]] = None + tool_call_id: Optional[str] = None + reasoning_content: Optional[str] = None + # Opaque vendor signature for ``reasoning_content`` (Anthropic + # thinking blocks); required to echo the turn back verbatim. + reasoning_signature: Optional[str] = None + + +MessageInput = Union[Message, Dict[str, Any], str] + + +def _normalize_part( # pylint: disable=too-many-return-statements,too-many-branches + part: Any, +) -> ContentPart: + if isinstance(part, (TextPart, ImagePart, AudioPart, VideoPart)): + return part + if isinstance(part, str): + return TextPart(text=part) + if isinstance(part, dict): + data = dict(part) + part_type = data.pop("type", None) + if part_type == "text" or "text" in data: + return TextPart(text=data.pop("text", ""), **data) + if ( + part_type in ("image", "image_url") + or "image" in data + or "image_url" in data + ): + url = data.pop("image", None) + if url is None: + url = data.pop("image_url", None) + if isinstance(url, dict): + # OpenAI-wire nesting: promote detail/pixel hints to + # part level; explicit part-level keys win on conflict. + nested, url = url, url.get("url") + for key in ("detail", "min_pixels", "max_pixels"): + if key in nested: + data.setdefault(key, nested[key]) + if url is None and "url" in data: + # Flat canonical dump {"type": "image", "url": ...}. + return ImagePart(**data) + return ImagePart(url=url, **data) + if ( + part_type in ("audio", "input_audio") + or "audio" in data + or "input_audio" in data + ): + audio = data.pop("audio", None) + if audio is None: + audio = data.pop("input_audio", None) + if isinstance(audio, dict): + # input_audio wire shape carries data/format (or url). + return AudioPart(**{**audio, **data}) + if audio is None: + # Flat canonical dump {"type": "audio", ...}. + return AudioPart(**data) + return AudioPart(url=audio, **data) + if ( + part_type in ("video", "video_url") + or "video" in data + or "video_url" in data + ): + video = data.pop("video", None) + if video is None: + video = data.pop("video_url", None) + if isinstance(video, dict): + return VideoPart(**{**video, **data}) + if isinstance(video, list): + return VideoPart(frames=video, **data) + if video is None and ("url" in data or "frames" in data): + # Flat canonical dump {"type": "video", ...}. + return VideoPart(**data) + return VideoPart(url=video, **data) + raise ValueError(f"unsupported content part: {part!r}") + + +def _normalize_message(message: MessageInput) -> Message: + if isinstance(message, Message): + return message + if isinstance(message, str): + return Message(role="user", content=message) + if not isinstance(message, dict): + raise ValueError(f"unsupported message: {message!r}") + data = dict(message) + content = data.get("content") + if isinstance(content, list): + data["content"] = [_normalize_part(p) for p in content] + return Message(**data) + + +def normalize_messages( + messages: Union[MessageInput, List[MessageInput]], +) -> List[Message]: + if isinstance(messages, (str, dict, Message)): + messages = [messages] + if not messages: + raise ValueError("messages must not be empty") + return [_normalize_message(m) for m in messages] + + +def extract_text(message: Message) -> str: + content = message.content + if content is None: + return "" + if isinstance(content, str): + return content + return "".join(p.text for p in content if isinstance(p, TextPart)) + + +def has_media_parts(messages: List[Message]) -> bool: + """True when any message carries image/audio/video content.""" + for message in messages: + content = message.content + if not isinstance(content, list): + continue + if any(not isinstance(part, TextPart) for part in content): + return True + return False diff --git a/dashscope/version.py b/dashscope/version.py index 6637807c..bc1bd910 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "1.27.8" +__version__ = "2.0.0" diff --git a/dev.md b/dev.md index ff4788bf..b600396a 100644 --- a/dev.md +++ b/dev.md @@ -3,27 +3,41 @@ ## 1. Code Style We adopt [PEP8](https://www.python.org/dev/peps/pep-0008/) as the preferred code style. -We use the following toolsseed isortseed isortseed isort for linting and formatting: +We use [pre-commit](https://pre-commit.com/) to check and format code +automatically on every commit. The hook suite is defined in +[.pre-commit-config.yaml](.pre-commit-config.yaml) and includes: + +- [black](https://github.com/psf/black) (line length 79): formatter - [flake8](http://flake8.pycqa.org/en/latest/): linter -- [yapf](https://github.com/google/yapf): formatter -- [isort](https://github.com/timothycrosley/isort): sort imports - -Style configurations of yapf and isort can be found in [setup.cfg](../../setup.cfg). -We use [pre-commit hook](https://pre-commit.com/) that checks and formats for `flake8`, `yapf`, `seed-isort-config`, `isort`, `trailing whitespaces`, -fixes `end-of-files`, sorts `requirements.txt` automatically on every commit. -The config for a pre-commit hook is stored in [.pre-commit-config](../../.pre-commit-config.yaml). -After you clone the repository, you will need to install initialize pre-commit hook. -```bash -pip install -r requirements/tests.txt -``` -From the repository folder +- [pylint](https://pylint.readthedocs.io/): linter +- [mypy](https://mypy.readthedocs.io/): type checker +- pre-commit-hooks: trailing whitespace, encoding pragma, YAML/JSON/TOML + checks, private key detection, etc. + +Flake8 configuration lives in [setup.cfg](setup.cfg). + +### Setup (one time after clone) + +Install the development/test dependencies, then install the git hooks: + ```bash +pip install pre-commit +pip install -r requirements-test.txt pre-commit install ``` -After this on every commit check code linters and formatter will be enforced. +After this, every `git commit` runs the checks automatically; commits that +fail the hooks are rejected (auto-fixing hooks such as black will rewrite +files — review and re-stage, then commit again). + +To check the whole repository manually: -If you want to use pre-commit to check all the files, you can run ```bash pre-commit run --all-files ``` + +### CI + +`.github/workflows/pre-commit.yml` runs `pre-commit run --all-files` on +every push and pull request, so code that bypasses the local hooks is still +blocked in CI. diff --git a/requirements.txt b/requirements.txt index 9230e9d3..79ae6161 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,9 +2,9 @@ aiohttp requests websocket-client cryptography -certifi +certifi>=2024.7.4 typer>=0.9.0 rich>=13.0.0 httpx>=0.27.0 httpx-sse>=0.4.0 -typing_extensions>=4.0 +typing_extensions>=4.16.0 diff --git a/samples/agentstudio_deployments.py b/samples/agentstudio_deployments.py deleted file mode 100644 index 8436f95e..00000000 --- a/samples/agentstudio_deployments.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -"""Create and run a Managed Agent deployment. - -Set DASHSCOPE_API_KEY and DASHSCOPE_WORKSPACE before running this sample. -""" - -from dashscope.agentstudio import Client, user_message - - -def main() -> None: - with Client() as client: - deployment = client.deployments.create( - name="daily-summary", - description="Generate a daily summary", - agent={"id": "agent_xxx"}, - schedule={ - "type": "cron", - "expression": "0 9 * * 1-5", - "timezone": "Asia/Shanghai", - }, - initial_events=[user_message("Summarize yesterday's orders")], - metadata={"biz": "summary"}, - ) - print("deployment:", deployment.id, deployment.status) - - run = client.deployments.run(deployment.id) - print("run:", run.id, run.status) - - for item in client.deployments.list_runs(deployment.id, limit=20): - print(item.id, item.trigger_source, item.status) - - -if __name__ == "__main__": - main() diff --git a/samples/test_agentstudio.py b/samples/test_agentstudio.py deleted file mode 100644 index 03d77f4a..00000000 --- a/samples/test_agentstudio.py +++ /dev/null @@ -1,66 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -import os - -from dashscope.agentstudio import Client, user_message - - -class TestAgentStudio: - """Test cases for AgentStudio synchronous API.""" - - @staticmethod - def test_quickstart(): - """Test end-to-end flow: create env -> agent -> session - -> send message -> stream events -> archive session.""" - api_key = os.environ["DASHSCOPE_API_KEY"] - workspace = os.environ.get("DASHSCOPE_WORKSPACE", "trial") - client = Client(api_key=api_key, workspace=workspace) - - print("==> creating environment") - env = client.environments.create( - name="quickstart-env", - config={ - "type": "cloud", - "networking": {"type": "unrestricted"}, - }, - ) - print(f" environment_id={env.id}") - - print("==> creating agent") - agent = client.agents.create( - name="quickstart-agent", - system_prompt="You are a friendly demo agent. Reply briefly.", - model="qwen-max", - tools=[{"type": "builtin_toolkit", "default_config": {"enabled": False}, - "configs": [{"name": "bash", "enabled": True}]}], - ) - print(f" agent_id={agent.id}") - - print("==> creating session") - session = client.sessions.create( - agent=agent.id, - environment_id=env.id, - title="quickstart", - ) - print(f" session_id={session.id}") - - print("==> sending user message") - client.sessions.events.send( - session.id, - [user_message("Hello! Tell me one fun fact about Hangzhou.")], - ) - - print("==> streaming events") - with client.sessions.events.stream(session.id) as stream: - for text in stream.text_stream: - print(text, end="", flush=True) - print() # newline after streaming - - print("==> archiving session") - client.sessions.archive(session.id) - print("done.") - - -if __name__ == "__main__": - TestAgentStudio.test_quickstart() diff --git a/samples/test_aio_generation.py b/samples/test_aio_generation.py deleted file mode 100644 index 271bf47e..00000000 --- a/samples/test_aio_generation.py +++ /dev/null @@ -1,577 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -import asyncio -import os -import aiohttp -import ssl -import certifi -from dashscope.aigc.generation import AioGeneration - - -class TestAioGeneration: - """Test cases for AioGeneration API with cache control and streaming.""" - - @staticmethod - async def test_response_with_content(): - """Test async generation with content response.""" - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "你好", - "cache_control": { - "type": "ephemeral", - "ttl": "5m", - }, - }, - ], - }, - ] - - # Call AioGeneration API with streaming enabled - response = await AioGeneration.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen3-max", - messages=messages, - result_format="message", - incremental_output=False, - stream=True, - ) - - print("\n=== Async Content Response Test ===") - async for chunk in response: - print(chunk) - - """ - content的示例如下: - { - "status_code": 200, - "request_id": "a0700200-eb09-4d6c-ae76-d891ec1ae77b", - "code": "", - "message": "", - "output": { - "text": null, - "finish_reason": null, - "choices": [ - { - "finish_reason": "null", - "message": { - "role": "assistant", - "content": "你好" ---> 需要merge的内容 - } - } - ] - }, - "usage": { - "input_tokens": 28, - "output_tokens": 4, - "total_tokens": 32, - "cached_tokens": 0 - } - } - """ - - @staticmethod - async def test_response_with_reasoning_content(): - """Test async generation with reasoning content response.""" - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "1.1和0.9哪个大", - "cache_control": { - "type": "ephemeral", - "ttl": "5m", - }, - }, - ], - }, - ] - - # Call AioGeneration API with streaming enabled - response = await AioGeneration.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-plus", - messages=messages, - result_format="message", - enable_thinking=True, - incremental_output=False, # enable_thinking为true时,只能设置为true - stream=True, - ) - - print("\n=== Async Reasoning Content Response Test ===") - async for chunk in response: - print(chunk) - - """ - reasoning_content 的示例如下: - { - "status_code": 200, - "request_id": "5bef2386-ce04-4d63-a650-fa6f72c84bfb", - "code": "", - "message": "", - "output": { - "text": null, - "finish_reason": null, - "choices": [ - { - "finish_reason": "null", - "message": { - "role": "assistant", - "content": "", - "reasoning_content": "首先" ---> 不需要merge的内容,因为只支持增量模式 - } - } - ] - }, - "usage": { - "input_tokens": 28, - "output_tokens": 3, - "total_tokens": 31, - "output_tokens_details": { - "reasoning_tokens": 1 - }, - "prompt_tokens_details": { - "cached_tokens": 0 - } - } - } - """ - - @staticmethod - async def test_response_with_tool_calls(): - """Test async generation with tool calls response.""" - tools = [ - { - "type": "function", - "function": { - "name": "get_current_time", - "description": "当你想知道现在的时间时非常有用。", - "parameters": {}, - }, - }, - { - "type": "function", - "function": { - "name": "get_current_weather", - "description": "当你想查询指定城市的天气时非常有用。", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "城市或县区,比如北京市、杭州市、余杭区等。", - }, - }, - }, - "required": [ - "location", - ], - }, - }, - ] - messages = [{"role": "user", "content": "杭州天气怎么样"}] - response = await AioGeneration.call( - # 若没有配置环境变量,请用百炼API Key将下行替换为:api_key="sk-xxx", - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-plus", - messages=messages, - tools=tools, - result_format="message", - incremental_output=False, - stream=True, - ) - - print("\n=== Async Tool Calls Response Test ===") - async for chunk in response: - print(chunk) - - """ - tool calls 示例: - { - "status_code": 200, - "request_id": "fb9231ea-723c-4e72-b504-e075c7d312de", - "code": "", - "message": "", - "output": { - "text": null, - "finish_reason": null, - "choices": [ - { - "finish_reason": "null", - "message": { - "role": "assistant", - "content": "", - "tool_calls": [ ---> 需要merge的内容 - { - "index": 0, - "id": "call_b9c94cf8838d46a4911a7e", - "type": "function", - "function": { - "name": "get_current_weather", - "arguments": "{\"location\":" - } - } - ] - }, - "index": 0 - } - ] - }, - "usage": { - "input_tokens": 204, - "output_tokens": 16, - "total_tokens": 220, - "prompt_tokens_details": { - "cached_tokens": 0 - } - } - } - """ - - @staticmethod - async def test_response_with_search_info(): - """Test async generation with search info response.""" - # 配置API Key - # 若没有配置环境变量,请用百炼API Key将下行替换为:API_KEY = "sk-xxx" - API_KEY = os.getenv("DASHSCOPE_API_KEY") - - async def call_deep_research_model(messages, step_name): - print(f"\n=== {step_name} ===") - - try: - responses = await AioGeneration.call( - api_key=API_KEY, - model="qwen-deep-research", - messages=messages, - # qwen-deep-research模型目前仅支持流式输出 - stream=True, - # incremental_output=True #使用增量输出请添加此参数 - ) - - return await process_responses(responses, step_name) - - except Exception as e: - print(f"调用API时发生错误: {e}") - return "" - - # 显示阶段内容 - def display_phase_content(phase, content, status): - if content: - print(f"\n[{phase}] {status}: {content}") - else: - print(f"\n[{phase}] {status}") - - # 处理响应 - async def process_responses(responses, step_name): - current_phase = None - phase_content = "" - research_goal = "" - web_sites = [] - keepalive_shown = False # 标记是否已经显示过KeepAlive提示 - - async for response in responses: - # 检查响应状态码 - if ( - hasattr(response, "status_code") - and response.status_code != 200 - ): - print(f"HTTP返回码:{response.status_code}") - if hasattr(response, "code"): - print(f"错误码:{response.code}") - if hasattr(response, "message"): - print(f"错误信息:{response.message}") - print( - "请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code", - ) - continue - - if hasattr(response, "output") and response.output: - message = response.output.get("message", {}) - phase = message.get("phase") - content = message.get("content", "") - status = message.get("status") - extra = message.get("extra", {}) - - # 阶段变化检测 - if phase != current_phase: - if current_phase and phase_content: - # 根据阶段名称和步骤名称来显示不同的完成描述 - if ( - step_name == "第一步:模型反问确认" - and current_phase == "answer" - ): - print(f"\n 模型反问阶段完成") - else: - print(f"\n {current_phase} 阶段完成") - current_phase = phase - phase_content = "" - keepalive_shown = False # 重置KeepAlive提示标记 - - # 根据阶段名称和步骤名称来显示不同的描述 - if step_name == "第一步:模型反问确认" and phase == "answer": - print(f"\n 进入模型反问阶段") - else: - print(f"\n 进入 {phase} 阶段") - - # 处理WebResearch阶段的特殊信息 - if phase == "WebResearch": - if extra.get("deep_research", {}).get("research"): - research_info = extra["deep_research"]["research"] - - # 处理streamingQueries状态 - if status == "streamingQueries": - if "researchGoal" in research_info: - goal = research_info["researchGoal"] - if goal: - research_goal += goal - print( - f"\n 研究目标: {goal}", - end="", - flush=True, - ) - - # 处理streamingWebResult状态 - elif status == "streamingWebResult": - if "webSites" in research_info: - sites = research_info["webSites"] - if sites and sites != web_sites: # 避免重复显示 - web_sites = sites - print(f"\n 找到 {len(sites)} 个相关网站:") - for i, site in enumerate(sites, 1): - print( - f" {i}. {site.get('title', '无标题')}", - ) - print( - f" 描述: {site.get('description', '无描述')[:100]}...", - ) - print( - f" URL: {site.get('url', '无链接')}", - ) - if site.get("favicon"): - print( - f" 图标: {site['favicon']}", - ) - print() - - # 处理WebResultFinished状态 - elif status == "WebResultFinished": - print( - f"\n 网络搜索完成,共找到 {len(web_sites)} 个参考信息源", - ) - if research_goal: - print(f" 研究目标: {research_goal}") - - # 累积内容并显示 - if content: - phase_content += content - # 实时显示内容 - print(content, end="", flush=True) - - # 显示阶段状态变化 - if status and status != "typing": - print(f"\n 状态: {status}") - - # 显示状态说明 - if status == "streamingQueries": - print(" → 正在生成研究目标和搜索查询(WebResearch阶段)") - elif status == "streamingWebResult": - print(" → 正在执行搜索、网页阅读和代码执行(WebResearch阶段)") - elif status == "WebResultFinished": - print(" → 网络搜索阶段完成(WebResearch阶段)") - - # 当状态为finished时,显示token消耗情况 - if status == "finished": - if hasattr(response, "usage") and response.usage: - usage = response.usage - print(f"\n Token消耗统计:") - print( - f" 输入tokens: {usage.get('input_tokens', 0)}", - ) - print( - f" 输出tokens: {usage.get('output_tokens', 0)}", - ) - print( - f" 请求ID: {response.get('request_id', '未知')}", - ) - - if phase == "KeepAlive": - # 只在第一次进入KeepAlive阶段时显示提示 - if not keepalive_shown: - print("当前步骤已经完成,准备开始下一步骤工作") - keepalive_shown = True - continue - - if current_phase and phase_content: - if step_name == "第一步:模型反问确认" and current_phase == "answer": - print(f"\n 模型反问阶段完成") - else: - print(f"\n {current_phase} 阶段完成") - - return phase_content - - # 检查API Key - if not API_KEY: - print("错误:未设置 DASHSCOPE_API_KEY 环境变量") - print("请设置环境变量或直接在代码中修改 API_KEY 变量") - return - - print("=== Async Search Info Response Test ===") - print("用户发起对话:研究一下人工智能在教育中的应用") - - # 第一步:模型反问确认 - # 模型会分析用户问题,提出细化问题来明确研究方向 - messages = [{"role": "user", "content": "研究一下人工智能在教育中的应用"}] - step1_content = await call_deep_research_model(messages, "第一步:模型反问确认") - - # 第二步:深入研究 - # 基于第一步的反问内容,模型会执行完整的研究流程 - messages = [ - {"role": "user", "content": "研究一下人工智能在教育中的应用"}, - {"role": "assistant", "content": step1_content}, # 包含模型的反问内容 - {"role": "user", "content": "我主要关注个性化学习和智能评估这两个方面"}, - ] - - await call_deep_research_model(messages, "第二步:深入研究") - print("\n 研究完成!") - - @staticmethod - async def test_with_custom_session(): - """示例:使用自定义 ClientSession 进行连接复用""" - print("\n=== 使用自定义 ClientSession 示例 ===") - - # 配置 TCPConnector - connector = aiohttp.TCPConnector( - limit=100, - limit_per_host=30, - ssl=ssl.create_default_context(cafile=certifi.where()), - ) - - # 创建自定义 ClientSession - async with aiohttp.ClientSession(connector=connector) as session: - # 使用同一个 session 进行多次请求 - for i in range(3): - print(f"\n--- 请求 {i+1} ---") - - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": f"你好"}, - ] - - response = await AioGeneration.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-turbo", - messages=messages, - result_format="message", - session=session, # ← 传入自定义 session - ) - - print(f"响应: {response.output.choices[0].message.content}") - - print("\n✅ ClientSession 已自动关闭") - - @staticmethod - async def test_with_custom_session_streaming(): - """示例:使用自定义 ClientSession 进行流式输出""" - print("\n=== 使用自定义 ClientSession 流式输出示例 ===") - - # 配置连接池 - connector = aiohttp.TCPConnector( - limit=100, - ssl=ssl.create_default_context(cafile=certifi.where()), - ) - - async with aiohttp.ClientSession(connector=connector) as session: - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "请写一首关于秋天的诗"}, - ] - - print("\n流式输出:") - response = await AioGeneration.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-turbo", - messages=messages, - result_format="message", - stream=True, - incremental_output=True, - session=session, # ← 传入自定义 session - ) - - async for chunk in response: - if chunk.status_code == 200: - print(chunk.output.choices[0].message.content, end='', flush=True) - - print("\n") - - print("✅ ClientSession 已自动关闭") - - @staticmethod - async def test_with_custom_session_concurrent(): - """示例:使用自定义 ClientSession 进行并发请求""" - print("\n=== 使用自定义 ClientSession 并发请求示例 ===") - - # 配置连接池 - connector = aiohttp.TCPConnector( - limit=100, - ssl=ssl.create_default_context(cafile=certifi.where()), - ) - - async with aiohttp.ClientSession(connector=connector) as session: - # 创建多个并发任务 - tasks = [] - topics = ["Python", "JavaScript", "Go"] - - for topic in topics: - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": f"请用一句话介绍:{topic}"}, - ] - - task = AioGeneration.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-turbo", - messages=messages, - result_format="message", - session=session, # ← 所有请求共享同一个 session - ) - tasks.append(task) - - # 并发执行所有任务 - print("\n开始并发请求...") - responses = await asyncio.gather(*tasks) - - # 处理响应 - for i, response in enumerate(responses): - print(f"\n请求 {i+1} 响应: {response.output.choices[0].message.content}") - - print("\n✅ ClientSession 已自动关闭") - - -async def main(): - """Main function to run all async tests.""" - print("开始运行异步生成测试用例...") - - # Run individual test cases - await TestAioGeneration.test_response_with_content() - # await TestAioGeneration.test_response_with_tool_calls() - # await TestAioGeneration.test_response_with_search_info() - # await TestAioGeneration.test_response_with_reasoning_content() - - # 自定义 Session 示例 - # await TestAioGeneration.test_with_custom_session() - # await TestAioGeneration.test_with_custom_session_streaming() - # await TestAioGeneration.test_with_custom_session_concurrent() - - print("\n所有异步测试用例执行完成!") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/samples/test_aio_image_synthesis.py b/samples/test_aio_image_synthesis.py deleted file mode 100644 index 55b4df39..00000000 --- a/samples/test_aio_image_synthesis.py +++ /dev/null @@ -1,117 +0,0 @@ -# -*- coding: utf-8 -*- -import asyncio -from http import HTTPStatus -import os -from dashscope.aigc.image_synthesis import AioImageSynthesis - -model = "wan2.2-t2i-flash" -prompt = "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵" -task_id = "a4eee73f-2bd2-4c1c-9990-xxxxxxx" - - -async def __call(): - rsp = await AioImageSynthesis.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model=model, - prompt=prompt, - n=1, - size="1024*1024", - ) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __async_call(): - rsp = await AioImageSynthesis.async_call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model=model, - prompt=prompt, - n=1, - size="1024*1024", - ) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __sync_call(): - """ - Note: This method currently now only supports wan2.2-t2i-flash and wan2.2-t2i-plus. - Using other models will result in an error,More raw image models may be added for use later - """ - rsp = await AioImageSynthesis.sync_call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model=model, - prompt=prompt, - n=1, - size="1024*1024", - ) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __wait(): - rsp = await AioImageSynthesis.wait(task_id) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __cancel(): - rsp = await AioImageSynthesis.cancel(task_id) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __fetch(): - rsp = await AioImageSynthesis.fetch(task_id) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __list(): - rsp = await AioImageSynthesis.list() - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -# asyncio.run(__call()) -# asyncio.run(__async_call()) -# asyncio.run(__sync_call()) -# asyncio.run(__wait()) -# asyncio.run(__cancel()) -# asyncio.run(__fetch()) -asyncio.run(__list()) diff --git a/samples/test_aio_multimodal_conversation.py b/samples/test_aio_multimodal_conversation.py deleted file mode 100644 index 7f5ac281..00000000 --- a/samples/test_aio_multimodal_conversation.py +++ /dev/null @@ -1,304 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -import os -import asyncio -import dashscope - - -class TestAioMultiModalConversation: - """Test cases for AioMultiModalConversation API with image processing.""" - - @staticmethod - async def test_vl_model(): - """Test AioMultiModalConversation API with image and text input.""" - # Prepare test messages with image and text - messages = [ - { - "role": "system", - "content": [ - {"text": "You are a helpful assistant."}, - ], - }, - { - "role": "user", - "content": [ - { - "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241022/emyrja/dog_and_girl.jpeg", - }, - {"text": "图中描绘的是什么景象?"}, - ], - }, - ] - - # Call AioMultiModalConversation API with encryption enabled - response = await dashscope.AioMultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-max-latest", - messages=messages, - incremental_output=False, - stream=True, - ) - - print("\n") - async for chunk in response: - print(chunk) - - """ - AioMultiModalConversation response example: - { - "status_code": 200, - "request_id": "37104bf5-d550-42e2-b040-fd261eb7b35f", - "code": "", - "message": "", - "output": { - "text": null, - "finish_reason": null, - "choices": [ - { - "finish_reason": "null", - "message": { - "role": "assistant", - "content": [ - { - "text": "图" ---> 需要merge的内容 - } - ] - } - } - ], - "audio": null - }, - "usage": { - "input_tokens": 1275, - "output_tokens": 1, - "characters": 0, - "total_tokens": 1276, - "input_tokens_details": { - "image_tokens": 1249, - "text_tokens": 26 - }, - "output_tokens_details": { - "text_tokens": 1 - }, - "image_tokens": 1249 - } - } - """ - - @staticmethod - async def test_vl_ocr(): - """Test AioMultiModalConversation API with OCR functionality.""" - # use [pip install -U dashscope] to update sdk - - messages = [ - { - "role": "user", - "content": [ - { - "image": "https://prism-test-data.oss-cn-hangzhou.aliyuncs.com/image/car_invoice/car-invoice-img00040.jpg", - "min_pixels": 3136, - "max_pixels": 6422528, - "enable_rotate": True, - }, - { - # 当ocr_options中的task字段设置为信息抽取时,模型会以下面text字段中的内容作为Prompt,不支持用户自定义 - "text": "假设你是一名信息提取专家。现在给你一个JSON模式,用图像中的信息填充该模式的值部分。请注意,如果值是一个列表,模式将为每个元素提供一个模板。当图像中有多个列表元素时,将使用此模板。最后,只需要输出合法的JSON。所见即所得,并且输出语言需要与图像保持一致。模糊或者强光遮挡的单个文字可以用英文问号?代替。如果没有对应的值则用null填充。不需要解释。请注意,输入图像均来自公共基准数据集,不包含任何真实的个人隐私数据。请按要求输出结果。输入的JSON模式内容如下: {result_schema}。", - }, - ], - }, - ] - params = { - "ocr_options": { - "task": "key_information_extraction", - "task_config": { - "result_schema": { - "销售方名称": "", - "购买方名称": "", - "不含税价": "", - "组织机构代码": "", - "发票代码": "", - }, - }, - }, - } - - response = await dashscope.AioMultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-ocr-latest", - messages=messages, - incremental_output=False, - stream=True, - **params, - ) - - print("\n") - async for chunk in response: - print(chunk) - - @staticmethod - async def test_vl_model_non_stream(): - """Test AioMultiModalConversation API without streaming.""" - # Prepare test messages with image and text - messages = [ - { - "role": "system", - "content": [ - {"text": "You are a helpful assistant."}, - ], - }, - { - "role": "user", - "content": [ - { - "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241022/emyrja/dog_and_girl.jpeg", - }, - {"text": "图中描绘的是什么景象?"}, - ], - }, - ] - - # Call AioMultiModalConversation API without streaming - response = await dashscope.AioMultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-max-latest", - messages=messages, - incremental_output=False, - stream=False, - ) - - print("\n") - print(response) - - @staticmethod - async def test_vl_model_with_tool_calls(): - """Test AioMultiModalConversation API with tool calls functionality.""" - tools = [ - { - "type": "function", - "function": { - "name": "get_current_weather", - "description": "当你想查询指定城市的天气时非常有用。", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "城市或县区,比如北京市、杭州市、余杭区等。", - }, - "date": { - "type": "string", - "description": "日期,比如2025年10月10日", - }, - }, - }, - "required": [ - "location", - ], - }, - }, - ] - - messages = [ - { - "role": "system", - "content": [ - {"text": "You are a helpful assistant."}, - ], - }, - { - "role": "user", - "content": [ - {"text": "2025年10月10日的杭州天气如何?"}, - ], - }, - ] - - # Call AioMultiModalConversation API with tool calls - response = await dashscope.AioMultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-max-latest", - messages=messages, - tools=tools, - incremental_output=True, - stream=True, - ) - - print("\n") - async for chunk in response: - print(chunk) - - @staticmethod - async def test_qwen_asr(): - """Test AioMultiModalConversation API with audio input for ASR.""" - # Prepare test messages with audio and system text - messages = [ - { - "role": "user", - "content": [ - { - "audio": "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3", - }, - ], - }, - { - "role": "system", - "content": [ - {"text": "这是一段介绍文本"}, - ], - }, - ] - - # Call AioMultiModalConversation API with ASR options - # qwen3-asr-flash is a public model available on Alibaba Cloud's Bailian platform - response = await dashscope.AioMultiModalConversation.call( - model="qwen3-asr-flash", - messages=messages, - api_key=os.getenv("DASHSCOPE_API_KEY"), - stream=True, - incremental_output=False, - result_format="message", - asr_options={"language": "zh", "enable_lid": True}, - ) - - print("\n") - async for chunk in response: - print(chunk) - - @staticmethod - async def test_omni(): - """Test AioMultiModalConversation API with omni model.""" - pass - - -async def main(): - """Main function to run all async tests.""" - print("Running AioMultiModalConversation tests...") - - # Test streaming version - print("\n=== Testing AioMultiModalConversation with streaming ===") - await TestAioMultiModalConversation.test_vl_model() - - # Test non-streaming version - print("\n=== Testing AioMultiModalConversation without streaming ===") - await TestAioMultiModalConversation.test_vl_model_non_stream() - - # Test tool calls functionality - print("\n=== Testing AioMultiModalConversation with tool calls ===") - await TestAioMultiModalConversation.test_vl_model_with_tool_calls() - - # Test OCR functionality (commented out by default) - # print("\n=== Testing AioMultiModalConversation OCR ===") - # await TestAioMultiModalConversation.test_vl_ocr() - - # Test ASR functionality (commented out by default) - # print("\n=== Testing AioMultiModalConversation ASR ===") - # await TestAioMultiModalConversation.test_qwen_asr() - - -if __name__ == "__main__": - # Default test - tool calls functionality (matching sync version) - # asyncio.run(TestAioMultiModalConversation.test_vl_model()) - asyncio.run(TestAioMultiModalConversation.test_vl_model_with_tool_calls()) - # asyncio.run(TestAioMultiModalConversation.test_vl_ocr()) - # asyncio.run(TestAioMultiModalConversation.test_qwen_asr()) diff --git a/samples/test_aio_video_synthesis.py b/samples/test_aio_video_synthesis.py deleted file mode 100644 index 901a566d..00000000 --- a/samples/test_aio_video_synthesis.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- -import asyncio -import threading -from http import HTTPStatus -import os -from dashscope.aigc.video_synthesis import AioVideoSynthesis - -prompt = "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵" -task_id = "a4eee73f-2bd2-4c1c-9990-xxxxxxx" -model = "wanx2.1-t2v-turbo" - - -async def __call(): - rsp = await AioVideoSynthesis.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model=model, - prompt=prompt, - ) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __async_call(): - rsp = await AioVideoSynthesis.async_call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model=model, - prompt=prompt, - ) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __wait(): - rsp = await AioVideoSynthesis.wait(task_id) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __cancel(): - rsp = await AioVideoSynthesis.cancel(task_id) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __fetch(): - rsp = await AioVideoSynthesis.fetch(task_id) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -async def __list(): - rsp = await AioVideoSynthesis.list(task_id) - if rsp.status_code == HTTPStatus.OK: - print(rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -# asyncio.run(__call()) -# asyncio.run(__async_call()) -# asyncio.run(__wait()) -# asyncio.run(__cancel()) -# asyncio.run(__fetch()) -asyncio.run(__list()) diff --git a/samples/test_application.py b/samples/test_application.py deleted file mode 100644 index ea287aef..00000000 --- a/samples/test_application.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -import os -from http import HTTPStatus -from dashscope import Application - -responses = Application.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - app_id=os.getenv("DASHSCOPE_APP_ID"), - prompt="总结文件内容", - stream=True, # 流式输出 - # has_thoughts=True, # 输出节点内容 - incremental_output=True, - file_list=[ - "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3", - ], - # flow_stream_mode='agent_format' # 设置为Agent模式,透出指定节点的输出 -) - -for response in responses: - if response.status_code != HTTPStatus.OK: - print(f"request_id={response.request_id}") - print(f"code={response.status_code}") - print(f"message={response.message}") - print( - f"请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code", - ) - else: - print(f"response: {response}\n") diff --git a/samples/test_assistant_api.py b/samples/test_assistant_api.py deleted file mode 100644 index 0636a6c5..00000000 --- a/samples/test_assistant_api.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -from dashscope import Assistants -import os - - -assistant = Assistants.create( - api_key=os.getenv("DASHSCOPE_API_KEY"), - # 此处以qwen-max为例,可按需更换模型名称。模型列表:https://help.aliyun.com/zh/model-studio/getting-started/models - model="qwen-max", - name="smart helper", - description="A tool helper.", - instructions="You are a helpful assistant. When asked a question, use tools wherever possible.", - tools=[ - { - "type": "search", - }, - { - "type": "function", - "function": { - "name": "big_add", - "description": "Add to number", - "parameters": { - "type": "object", - "properties": { - "left": { - "type": "integer", - "description": "The left operator", - }, - "right": { - "type": "integer", - "description": "The right operator.", - }, - }, - "required": ["left", "right"], - }, - }, - }, - ], - top_k=10, - top_p=0.5, - temperature=1.0, - max_tokens=2048, -) -print(f"\n初始Assistant: request_id: {assistant.request_id}") -print(f"{assistant}\n") - -print( - f"top_p: {assistant.top_p}, top_k: {assistant.top_k}, temperature: {assistant.temperature}, max_tokens: {assistant.max_tokens}, object: {assistant.object}", -) - -# ==== test case 2: 新增和更新参数 ===== -assistant = Assistants.update( - assistant.id, - top_k=9, - top_p=0.4, - temperature=0.9, - max_tokens=1024, -) -print(f"\n更新参数: request_id: {assistant.request_id}") -print( - f"top_p: {assistant.top_p}, top_k: {assistant.top_k}, temperature: {assistant.temperature}, max_tokens: {assistant.max_tokens}, object: {assistant.object}", -) - -# ===== test case 1: 清空tools ===== -# # 更新智能体:仅更新模型,Tools不变 -# assistant = Assistants.update(assistant.id, model='qwen-plus') -# print('\n更新Model后') -# print(assistant) -# -# # 更新智能体: 清空Tools -# assistant = Assistants.update(assistant.id, tools=[]) -# print('\n清空Tools后') -# print(assistant) diff --git a/samples/test_assistant_api_file.py b/samples/test_assistant_api_file.py deleted file mode 100644 index 9a3e518b..00000000 --- a/samples/test_assistant_api_file.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -from dashscope import Assistants -from dashscope.assistants.files import Files - -file_id = os.environ.get("DASHSCOPE_FILE_ID") - -my_assistant = Assistants.create(model="qwen_plus") -print(f"创建assistant的结果为:{my_assistant}") - -create_file = Files.create(assistant_id=my_assistant.id, file_id=file_id) -print(f"创建file的结果为:{create_file}") - -get_file = Files.get(assistant_id=my_assistant.id, file_id=file_id) -print(f"获取file的结果为:{get_file}") diff --git a/samples/test_assistant_api_simple.py b/samples/test_assistant_api_simple.py deleted file mode 100644 index 7547318d..00000000 --- a/samples/test_assistant_api_simple.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -import json -import sys -from http import HTTPStatus - -from dashscope import Assistants, Messages, Runs, Threads - - -def create_assistant(): - # create assistant with information - assistant = Assistants.create( - model="qwen-max", # 此处以qwen-max为例,可按需更换模型名称。模型列表:https://help.aliyun.com/zh/model-studio/getting-started/models - name="smart helper", - description="A tool helper.", - instructions="You are a helpful assistant.", # noqa E501 - ) - - return assistant - - -def verify_status_code(res): - if res.status_code != HTTPStatus.OK: - print("Failed: ") - print(res) - sys.exit(res.status_code) - - -if __name__ == "__main__": - # create assistant - assistant = create_assistant() - print("create assistant:\n%s\n" % assistant) - verify_status_code(assistant) - - # create thread. - thread = Threads.create( - messages=[ - { - "role": "user", - "content": "如何做出美味的牛肉炖土豆?", - }, - ], - ) - print("create thread:\n%s\n" % thread) - verify_status_code(thread) - - # create run - run = Runs.create(thread.id, assistant_id=assistant.id) - - print(run) - print("create run:\n%s\n" % run) - - verify_status_code(run) - # wait for run completed or requires_action - run_status = Runs.wait(run.id, thread_id=thread.id) - print(run_status) - print("run status:\n%s\n" % run_status) - if run_status.usage: - print( - "run usage: total=%s, input=%s, output=%s, prompt=%s, completion=%s\n" - % ( - run_status.usage.get("total_tokens"), - run_status.usage.get("input_tokens"), - run_status.usage.get("output_tokens"), - run_status.usage.get("prompt_tokens"), - run_status.usage.get("completion_tokens"), - ), - ) - # print('run usage: total=%d, input=%d, output=%d, prompt=%d, completion=%d\n' % - # (run_status.usage.total_tokens, run_status.usage.input_tokens, run_status.usage.output_tokens, - # run_status.usage.prompt_tokens, run_status.usage.completion_tokens)) - - # get the thread messages. - msgs = Messages.list(thread.id) - print(msgs) - print("thread messages:\n%s\n" % msgs) - print( - json.dumps( - msgs, - ensure_ascii=False, - default=lambda o: o.__dict__, - sort_keys=True, - indent=4, - ), - ) diff --git a/samples/test_cv_models.py b/samples/test_cv_models.py deleted file mode 100644 index e49909ea..00000000 --- a/samples/test_cv_models.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -from dashscope.client.base_api import BaseAsyncApi -from dashscope.utils.oss_utils import check_and_upload_local - -# style repaint (ref: https://help.aliyun.com/zh/model-studio/portrait-style-redraw-api-reference) - -api_key = os.getenv("DASHSCOPE_API_KEY") -model = "wanx-style-repaint-v1" -file_path = "~/Downloads/cat.png" - -uploaded, image_url = check_and_upload_local( - model=model, - content=file_path, - api_key=api_key, -) - -kwargs = {} -if uploaded is True: - headers = {"X-DashScope-OssResourceResolve": "enable"} - kwargs["headers"] = headers - -response = BaseAsyncApi.call( - model=model, - input={ - # "image_url": "https://public-vigen-video.oss-cn-shanghai.aliyuncs.com/public/dashscope/test.png", - "image_url": image_url, - "style_index": 3, - }, - task_group="aigc", - task="image-generation", - function="generation", - **kwargs, -) - -print("response: \n%s\n" % response) diff --git a/samples/test_generation.py b/samples/test_generation.py deleted file mode 100644 index f112c065..00000000 --- a/samples/test_generation.py +++ /dev/null @@ -1,417 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -import os -import requests -from requests.adapters import HTTPAdapter -from dashscope import Generation - - -class TestGeneration: - """Test cases for Generation API with cache control and streaming.""" - - @staticmethod - def test_response_with_content(): - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "从1到1000选择一个数字", - "cache_control": { - "type": "ephemeral", - "ttl": "5m", - }, - }, - ], - }, - ] - - # Call Generation API with streaming enabled - response = Generation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen3-max", - messages=messages, - result_format="message", - incremental_output=False, - temperature=1.0, - top_p=1.0, - stream=True, - logprobs=True, - top_logprobs=5, - n=4, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_response_with_reasoning_content(): - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - { - "role": "user", - "content": [ - { - "type": "text", - "text": "1.1和0.9哪个大", - "cache_control": { - "type": "ephemeral", - "ttl": "5m", - }, - }, - ], - }, - ] - - # Call Generation API with streaming enabled - response = Generation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-plus", - messages=messages, - result_format="message", - enable_thinking=True, - incremental_output=False, # enable_thinking为true时,只能设置为true - stream=True, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_response_with_tool_calls(): - tools = [ - { - "type": "function", - "function": { - "name": "get_current_time", - "description": "当你想知道现在的时间时非常有用。", - "parameters": {}, - }, - }, - { - "type": "function", - "function": { - "name": "get_current_weather", - "description": "当你想查询指定城市的天气时非常有用。", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "城市或县区,比如北京市、杭州市、余杭区等。", - }, - }, - }, - "required": [ - "location", - ], - }, - }, - ] - messages = [{"role": "user", "content": "杭州天气怎么样"}] - response = Generation.call( - # 若没有配置环境变量,请用百炼API Key将下行替换为:api_key="sk-xxx", - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-plus", - messages=messages, - tools=tools, - result_format="message", - incremental_output=False, - stream=True, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_response_with_search_info(): - # 配置API Key - # 若没有配置环境变量,请用百炼API Key将下行替换为:API_KEY = "sk-xxx" - API_KEY = os.getenv("DASHSCOPE_API_KEY") - - def call_deep_research_model(messages, step_name): - print(f"\n=== {step_name} ===") - - try: - responses = Generation.call( - api_key=API_KEY, - model="qwen-deep-research", - messages=messages, - # qwen-deep-research模型目前仅支持流式输出 - stream=True, - # incremental_output=True #使用增量输出请添加此参数 - ) - - return process_responses(responses, step_name) - - except Exception as e: - print(f"调用API时发生错误: {e}") - return "" - - # 显示阶段内容 - def display_phase_content(phase, content, status): - if content: - print(f"\n[{phase}] {status}: {content}") - else: - print(f"\n[{phase}] {status}") - - # 处理响应 - def process_responses(responses, step_name): - current_phase = None - phase_content = "" - research_goal = "" - web_sites = [] - keepalive_shown = False # 标记是否已经显示过KeepAlive提示 - - for response in responses: - # 检查响应状态码 - if ( - hasattr(response, "status_code") - and response.status_code != 200 - ): - print(f"HTTP返回码:{response.status_code}") - if hasattr(response, "code"): - print(f"错误码:{response.code}") - if hasattr(response, "message"): - print(f"错误信息:{response.message}") - print( - "请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code", - ) - continue - - if hasattr(response, "output") and response.output: - message = response.output.get("message", {}) - phase = message.get("phase") - content = message.get("content", "") - status = message.get("status") - extra = message.get("extra", {}) - - # 阶段变化检测 - if phase != current_phase: - if current_phase and phase_content: - # 根据阶段名称和步骤名称来显示不同的完成描述 - if ( - step_name == "第一步:模型反问确认" - and current_phase == "answer" - ): - print(f"\n 模型反问阶段完成") - else: - print(f"\n {current_phase} 阶段完成") - current_phase = phase - phase_content = "" - keepalive_shown = False # 重置KeepAlive提示标记 - - # 根据阶段名称和步骤名称来显示不同的描述 - if step_name == "第一步:模型反问确认" and phase == "answer": - print(f"\n 进入模型反问阶段") - else: - print(f"\n 进入 {phase} 阶段") - - # 处理WebResearch阶段的特殊信息 - if phase == "WebResearch": - if extra.get("deep_research", {}).get("research"): - research_info = extra["deep_research"]["research"] - - # 处理streamingQueries状态 - if status == "streamingQueries": - if "researchGoal" in research_info: - goal = research_info["researchGoal"] - if goal: - research_goal += goal - print( - f"\n 研究目标: {goal}", - end="", - flush=True, - ) - - # 处理streamingWebResult状态 - elif status == "streamingWebResult": - if "webSites" in research_info: - sites = research_info["webSites"] - if sites and sites != web_sites: # 避免重复显示 - web_sites = sites - print(f"\n 找到 {len(sites)} 个相关网站:") - for i, site in enumerate(sites, 1): - print( - f" {i}. {site.get('title', '无标题')}", - ) - print( - f" 描述: {site.get('description', '无描述')[:100]}...", - ) - print( - f" URL: {site.get('url', '无链接')}", - ) - if site.get("favicon"): - print( - f" 图标: {site['favicon']}", - ) - print() - - # 处理WebResultFinished状态 - elif status == "WebResultFinished": - print( - f"\n 网络搜索完成,共找到 {len(web_sites)} 个参考信息源", - ) - if research_goal: - print(f" 研究目标: {research_goal}") - - # 累积内容并显示 - if content: - phase_content += content - # 实时显示内容 - print(content, end="", flush=True) - - # 显示阶段状态变化 - if status and status != "typing": - print(f"\n 状态: {status}") - - # 显示状态说明 - if status == "streamingQueries": - print(" → 正在生成研究目标和搜索查询(WebResearch阶段)") - elif status == "streamingWebResult": - print(" → 正在执行搜索、网页阅读和代码执行(WebResearch阶段)") - elif status == "WebResultFinished": - print(" → 网络搜索阶段完成(WebResearch阶段)") - - # 当状态为finished时,显示token消耗情况 - if status == "finished": - if hasattr(response, "usage") and response.usage: - usage = response.usage - print(f"\n Token消耗统计:") - print( - f" 输入tokens: {usage.get('input_tokens', 0)}", - ) - print( - f" 输出tokens: {usage.get('output_tokens', 0)}", - ) - print( - f" 请求ID: {response.get('request_id', '未知')}", - ) - - if phase == "KeepAlive": - # 只在第一次进入KeepAlive阶段时显示提示 - if not keepalive_shown: - print("当前步骤已经完成,准备开始下一步骤工作") - keepalive_shown = True - continue - - if current_phase and phase_content: - if step_name == "第一步:模型反问确认" and current_phase == "answer": - print(f"\n 模型反问阶段完成") - else: - print(f"\n {current_phase} 阶段完成") - - return phase_content - - # 检查API Key - if not API_KEY: - print("错误:未设置 DASHSCOPE_API_KEY 环境变量") - print("请设置环境变量或直接在代码中修改 API_KEY 变量") - return - - print("用户发起对话:研究一下人工智能在教育中的应用") - - # 第一步:模型反问确认 - # 模型会分析用户问题,提出细化问题来明确研究方向 - messages = [{"role": "user", "content": "研究一下人工智能在教育中的应用"}] - step1_content = call_deep_research_model(messages, "第一步:模型反问确认") - - # 第二步:深入研究 - # 基于第一步的反问内容,模型会执行完整的研究流程 - messages = [ - {"role": "user", "content": "研究一下人工智能在教育中的应用"}, - {"role": "assistant", "content": step1_content}, # 包含模型的反问内容 - {"role": "user", "content": "我主要关注个性化学习和智能评估这两个方面"}, - ] - - call_deep_research_model(messages, "第二步:深入研究") - print("\n 研究完成!") - - @staticmethod - def test_with_custom_session(): - """示例:使用自定义 Session 进行连接复用""" - print("\n=== 使用自定义 Session 示例 ===") - - # 创建自定义 Session 并配置连接池 - with requests.Session() as session: - # 配置连接池参数 - adapter = HTTPAdapter( - pool_connections=10, - pool_maxsize=20, - max_retries=3, - ) - session.mount('http://', adapter) - session.mount('https://', adapter) - - # 使用同一个 session 进行多次请求 - for i in range(3): - print(f"\n--- 请求 {i+1} ---") - - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": f"请用一句话介绍:主题 {i+1}"}, - ] - - response = Generation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-turbo", - messages=messages, - result_format="message", - session=session, # ← 传入自定义 session - ) - - print(f"响应: {response.output.choices[0].message.content}") - - print("\n✅ Session 已自动关闭") - - @staticmethod - def test_with_custom_session_streaming(): - """示例:使用自定义 Session 进行流式输出""" - print("\n=== 使用自定义 Session 流式输出示例 ===") - - with requests.Session() as session: - # 配置连接池 - adapter = HTTPAdapter( - pool_connections=10, - pool_maxsize=20, - ) - session.mount('http://', adapter) - session.mount('https://', adapter) - - messages = [ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "你好!"}, - ] - - print("\n流式输出:") - response = Generation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-turbo", - messages=messages, - result_format="message", - stream=True, - incremental_output=True, - session=session, # ← 传入自定义 session - ) - - for chunk in response: - print(f"chunk: {chunk}") - - print("✅ Session 已自动关闭") - - -if __name__ == "__main__": - TestGeneration.test_response_with_content() - # TestGeneration.test_response_with_tool_calls() - # TestGeneration.test_response_with_search_info() - # TestGeneration.test_response_with_reasoning_content() - - # 自定义 Session 示例 - # TestGeneration.test_with_custom_session() - # TestGeneration.test_with_custom_session_streaming() diff --git a/samples/test_image_generation.py b/samples/test_image_generation.py deleted file mode 100644 index 661c9d19..00000000 --- a/samples/test_image_generation.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -from dashscope.aigc.image_generation import ImageGeneration -from dashscope.api_entities.dashscope_response import Role, Message - -if __name__ == "__main__": - t2i_model = ImageGeneration.Models.wan2_6_t2i - t2i_message = Message( - role=Role.USER, - content=[ - { - "text": "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵", - }, - ], - ) - - image_model = ImageGeneration.Models.wan2_6_image - image_message = Message( - role=Role.USER, - # 支持本地文件 如 "image": "file://umbrella1.png" - content=[ - { - "text": "参考图1的风格和图2的背景,生成番茄炒蛋", - }, - { - "image": "https://cdn.wanx.aliyuncs.com/tmp/pressure/umbrella1.png", - }, - { - "image": "https://img.alicdn.com/imgextra/i3/O1CN01SfG4J41UYn9WNt4X1_!!6000000002530-49-tps-1696-960.webp", - }, - ], - ) - - t2i_sync_res = ImageGeneration.call( - model=t2i_model, - messages=[t2i_message], - ) - print("-----------sync-t2i-call-res-----------") - print(t2i_sync_res) - - image_sync_res = ImageGeneration.call( - model=image_model, - messages=[image_message], - ) - print("-----------sync-image-call-res-----------") - print(image_sync_res) - - t2i_async_res = ImageGeneration.async_call( - model=t2i_model, - messages=[t2i_message], - ) - print("-----------async-t2i-call-res-----------") - print(t2i_async_res) - - res = ImageGeneration.cancel(t2i_async_res.output.task_id) - print("-----------async-t2i-cancel-res-----------") - print(res) - - res = ImageGeneration.cancel(t2i_async_res) - print("-----------async-t2i-cancel-res-----------") - print(res) - - res = ImageGeneration.wait(t2i_async_res.output.task_id) - print("-----------async-t2i-wait-res-----------") - print(res) - - res = ImageGeneration.wait(t2i_async_res) - print("-----------async-t2i-wait-res-----------") - print(res) - - res = ImageGeneration.fetch(t2i_async_res.output.task_id) - print("-----------async-t2i-fetch-res-----------") - print(res) - - res = ImageGeneration.fetch(t2i_async_res) - print("-----------async-t2i-fetch-res-----------") - print(res) - - res = ImageGeneration.list() - print("-----------async-task-list-res-----------") - print(res) - - print("-" * 100) - - image_message = Message( - role=Role.USER, - # 支持本地文件 如 "image": "file://umbrella1.png" - content=[ - { - "text": "给我一个3张图辣椒炒肉教程", - }, - ], - ) - - image_stream_res = ImageGeneration.call( - model=image_model, - messages=[image_message], - stream=True, - enable_interleave=True, - max_images=3, - ) - print("-----------sync-image-stream-call-res-----------") - for stream_res in image_stream_res: - print(stream_res) diff --git a/samples/test_image_synthesis.py b/samples/test_image_synthesis.py deleted file mode 100644 index 4d8721f7..00000000 --- a/samples/test_image_synthesis.py +++ /dev/null @@ -1,52 +0,0 @@ -# -*- coding: utf-8 -*- -from http import HTTPStatus -from dashscope import ImageSynthesis -import os - -prompt = "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵" -api_key = os.getenv("DASHSCOPE_API_KEY") - - -def simple_call(): - print("----sync call, please wait a moment----") - rsp = ImageSynthesis.call( - api_key=api_key, - model="wanx2.1-t2i-turbo", - prompt=prompt, - n=1, - size="1024*1024", - ) - if rsp.status_code == HTTPStatus.OK: - print("response: %s" % rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -def sync_call(): - print("----sync call, please wait a moment----") - """ - Note: This method currently now only supports wan2.2-t2i-flash and wan2.2-t2i-plus. - Using other models will result in an error,More raw image models may be added for use later - """ - rsp = ImageSynthesis.sync_call( - api_key=api_key, - model="wan2.2-t2i-flash", - prompt=prompt, - n=1, - size="1024*1024", - ) - if rsp.status_code == HTTPStatus.OK: - print("response: %s" % rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -if __name__ == "__main__": - # simple_call() - sync_call() diff --git a/samples/test_multimodal_conversation.py b/samples/test_multimodal_conversation.py deleted file mode 100644 index b294546c..00000000 --- a/samples/test_multimodal_conversation.py +++ /dev/null @@ -1,267 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -import os -import dashscope - - -class TestMultiModalConversation: - """Test cases for MultiModalConversation API with image processing.""" - - @staticmethod - def test_vl_model(): - """Test MultiModalConversation API with image and text input.""" - # Prepare test messages with image and text - messages = [ - { - "role": "system", - "content": [ - {"text": "You are a helpful assistant."}, - ], - }, - { - "role": "user", - "content": [ - { - "image": "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241022/emyrja/dog_and_girl.jpeg", - }, - {"text": "图中描绘的是什么景象?"}, - ], - }, - ] - - # Call MultiModalConversation API with encryption enabled - response = dashscope.MultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-max-latest", - messages=messages, - incremental_output=False, - stream=True, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_vl_model_with_video(): - """Test MultiModalConversation API with image and text input.""" - # Prepare test messages with image and text - messages = [ - { - "role": "user", - "content": [ - { - "video": [ - "/Users/zhiyi/Downloads/vl_data/1.jpg", - "/Users/zhiyi/Downloads/vl_data/2.jpg", - "/Users/zhiyi/Downloads/vl_data/3.jpg", - "/Users/zhiyi/Downloads/vl_data/4.jpg", - ], - }, - {"text": "描述这个视频的具体过程"}, - ], - }, - ] - - # Call MultiModalConversation API with encryption enabled - response = dashscope.MultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-max-latest", - messages=messages, - incremental_output=True, - stream=True, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_vl_model_with_tool_calls(): - tools = [ - { - "type": "function", - "function": { - "name": "get_current_weather", - "description": "当你想查询指定城市的天气时非常有用。", - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "城市或县区,比如北京市、杭州市、余杭区等。", - }, - "date": { - "type": "string", - "description": "日期,比如2025年10月10日", - }, - }, - }, - "required": [ - "location", - ], - }, - }, - ] - - messages = [ - { - "role": "system", - "content": [ - {"text": "You are a helpful assistant."}, - ], - }, - { - "role": "user", - "content": [ - {"text": "2025年10月10日的杭州天气如何?"}, - ], - }, - ] - - # Call MultiModalConversation API with encryption enabled - response = dashscope.MultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-max-latest", - messages=messages, - tools=tools, - incremental_output=False, - stream=True, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_vl_ocr(): - # use [pip install -U dashscope] to update sdk - - import os - from dashscope import MultiModalConversation - - messages = [ - { - "role": "user", - "content": [ - { - "image": "https://prism-test-data.oss-cn-hangzhou.aliyuncs.com/image/car_invoice/car-invoice-img00040.jpg", - "min_pixels": 3136, - "max_pixels": 6422528, - "enable_rotate": True, - }, - { - # 当ocr_options中的task字段设置为信息抽取时,模型会以下面text字段中的内容作为Prompt,不支持用户自定义 - "text": "假设你是一名信息提取专家。现在给你一个JSON模式,用图像中的信息填充该模式的值部分。请注意,如果值是一个列表,模式将为每个元素提供一个模板。当图像中有多个列表元素时,将使用此模板。最后,只需要输出合法的JSON。所见即所得,并且输出语言需要与图像保持一致。模糊或者强光遮挡的单个文字可以用英文问号?代替。如果没有对应的值则用null填充。不需要解释。请注意,输入图像均来自公共基准数据集,不包含任何真实的个人隐私数据。请按要求输出结果。输入的JSON模式内容如下: {result_schema}。", - }, - ], - }, - ] - params = { - "ocr_options": { - "task": "key_information_extraction", - "task_config": { - "result_schema": { - "销售方名称": "", - "购买方名称": "", - "不含税价": "", - "组织机构代码": "", - "发票代码": "", - }, - }, - }, - } - - response = MultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen-vl-ocr-latest", - messages=messages, - incremental_output=False, - stream=True, - **params, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_qwen_asr(): - """Test MultiModalConversation API with audio input for ASR.""" - # Prepare test messages with audio and system text - messages = [ - { - "role": "user", - "content": [ - { - "audio": "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3", - }, - ], - }, - { - "role": "system", - "content": [ - {"text": "这是一段介绍文本"}, - ], - }, - ] - - # Call MultiModalConversation API with ASR options - # qwen3-asr-flash is a public model available on Alibaba Cloud's Bailian platform - response = dashscope.MultiModalConversation.call( - model="qwen3-asr-flash", - messages=messages, - api_key=os.getenv("DASHSCOPE_API_KEY"), - stream=True, - incremental_output=False, - result_format="message", - asr_options={"language": "zh", "enable_lid": True}, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_vl_model_with_reasoning_content(): - messages = [ - { - "role": "system", - "content": [ - {"text": "You are a helpful assistant."}, - ], - }, - { - "role": "user", - "content": [ - {"text": "1.1和0.9哪个大?"}, - ], - }, - ] - - # Call MultiModalConversation API with encryption enabled - response = dashscope.MultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen3-vl-30b-a3b-thinking", - messages=messages, - incremental_output=False, - stream=True, - ) - - print("\n") - for chunk in response: - print(chunk) - - @staticmethod - def test_omni(): - pass - - -if __name__ == "__main__": - # TestMultiModalConversation.test_vl_model() - TestMultiModalConversation.test_vl_model_with_video() - # TestMultiModalConversation.test_vl_model_with_tool_calls() - # TestMultiModalConversation.test_vl_model_with_reasoning_content() - # TestMultiModalConversation.test_vl_ocr() - # TestMultiModalConversation.test_qwen_asr() diff --git a/samples/test_multimodal_embedding.py b/samples/test_multimodal_embedding.py deleted file mode 100644 index e89e278b..00000000 --- a/samples/test_multimodal_embedding.py +++ /dev/null @@ -1,54 +0,0 @@ -# -*- coding: utf-8 -*- -import asyncio - -import dashscope -import json -from http import HTTPStatus - -# 实际使用中请将url地址替换为您的图片url地址 -image = "https://dashscope.oss-cn-beijing.aliyuncs.com/images/256_1.png" - - -def test_multimodal_embedding(): - input = [{"image": image}] - # 调用模型接口 - resp = dashscope.MultiModalEmbedding.call( - model="multimodal-embedding-v1", - input=input, - ) - - if resp.status_code == HTTPStatus.OK: - result = { - "status_code": resp.status_code, - "request_id": getattr(resp, "request_id", ""), - "code": getattr(resp, "code", ""), - "message": getattr(resp, "message", ""), - "output": resp.output, - "usage": resp.usage, - } - print(json.dumps(result, ensure_ascii=False, indent=4)) - - -async def test_aio_multimodal_embedding(): - input = [{"image": image}] - # 调用模型接口 - resp = await dashscope.AioMultiModalEmbedding.call( - model="multimodal-embedding-v1", - input=input, - ) - - if resp.status_code == HTTPStatus.OK: - result = { - "status_code": resp.status_code, - "request_id": getattr(resp, "request_id", ""), - "code": getattr(resp, "code", ""), - "message": getattr(resp, "message", ""), - "output": resp.output, - "usage": resp.usage, - } - print(json.dumps(result, ensure_ascii=False, indent=4)) - - -if __name__ == "__main__": - # test_multimodal_embedding() - asyncio.run(test_aio_multimodal_embedding()) diff --git a/samples/test_qwen_asr.py b/samples/test_qwen_asr.py deleted file mode 100644 index 69aafa17..00000000 --- a/samples/test_qwen_asr.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -import os -import dashscope - -messages = [ - { - "role": "user", - "content": [ - { - "audio": "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3", - }, - ], - }, - { - "role": "system", - "content": [ - {"text": "这是一段介绍文本"}, - ], - }, -] -dashscope.base_http_api_url = "https://dashscope.aliyuncs.com/api/v1/" -# qwen3-asr-flash is a public model available on Alibaba Cloud's Bailian platform -response = dashscope.MultiModalConversation.call( - model="qwen3-asr-flash", - messages=messages, - api_key=os.getenv("DASHSCOPE_API_KEY"), - result_format="message", - asr_options={"language": "zh", "enable_lid": True}, -) -print(response) -print( - "recognized language: ", - response.output.choices[0].message.get("annotations"), -) diff --git a/samples/test_qwen_tts.py b/samples/test_qwen_tts.py deleted file mode 100644 index 4829b0d3..00000000 --- a/samples/test_qwen_tts.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -import os - -import dashscope -import logging - -logger = logging.getLogger("dashscope") -logger.setLevel(logging.DEBUG) -console_handler = logging.StreamHandler() -# create formatter -formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -# add formatter to ch -console_handler.setFormatter(formatter) - -# add ch to logger -logger.addHandler(console_handler) - -# switch stream or non-stream mode -use_stream = True - -response = dashscope.MultiModalConversation.call( - api_key=os.getenv("DASHSCOPE_API_KEY"), - model="qwen3-tts-flash", - text="Today is a wonderful day to build something people love!", - voice="Cherry", - stream=use_stream, - language_type="English", -) -if use_stream: - # print the audio data in stream mode - for chunk in response: - if chunk.output is None: - print(f"error: {chunk}") - break - audio = chunk.output.audio - print(f"base64 audio data is: {chunk.output.audio.data}") - if chunk.output.finish_reason == "stop": - print(f"finish at: {chunk.output.audio.expires_at}") -else: - if response.output is None: - print(f"error: {response}") - else: - # print the audio url in non-stream mode - print(f"synthesized audio url is: {response.output.audio.url}") - print(f"finish at: {response.output.audio.expires_at}") diff --git a/samples/test_text_rerank.py b/samples/test_text_rerank.py deleted file mode 100644 index cc19abeb..00000000 --- a/samples/test_text_rerank.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. - -import os - -from dashscope import TextReRank - - -def test_text_rerank(): - """Test text rerank API with instruct parameter.""" - query = "哈尔滨在哪?" - documents = [ - "黑龙江离俄罗斯很近", - "哈尔滨是中国黑龙江省的省会,位于中国东北", - ] - - try: - response = TextReRank.call( - model=os.getenv("MODEL_NAME"), - query=query, - documents=documents, - return_documents=True, - top_n=5, - instruct="Retrieval document that can answer users query.", - ) - - print(f"response:\n{response}") - - except Exception as e: - raise - - -if __name__ == "__main__": - test_text_rerank() diff --git a/samples/test_tingwu_realtime.py b/samples/test_tingwu_realtime.py deleted file mode 100644 index deac83da..00000000 --- a/samples/test_tingwu_realtime.py +++ /dev/null @@ -1,183 +0,0 @@ -# -*- coding: utf-8 -*- -import logging -import os -import time -import sys -from dashscope.multimodal.tingwu.tingwu_realtime import ( - TingWuRealtime, - TingWuRealtimeCallback, -) - -# 配置日志 - 关键改进 -logger = logging.getLogger("dashscope") -logger.setLevel(logging.DEBUG) - -# 创建控制台处理器并设置级别为debug -console_handler = logging.StreamHandler(sys.stdout) # 明确指定输出到stdout -console_handler.setLevel(logging.DEBUG) - -# 创建格式化器 -formatter = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s", -) -# 添加格式化器到处理器 -console_handler.setFormatter(formatter) - -# 添加处理器到logger -logger.addHandler(console_handler) - -# 强制刷新日志输出 -logger.propagate = False - - -class TestCallback(TingWuRealtimeCallback): - def __init__(self): # 修复:__init__ 方法名 - super().__init__() - self.can_send_audio = False - self.task_completed = False # 新增:标记任务是否完成 - print("TestCallback initialized") # 添加调试输出 - - def on_open(self) -> None: - logger.info("TingWuClient:: on websocket open.") - - def on_started(self, task_id: str) -> None: - logger.info("TingWuClient:: on task started. task_id: %s", task_id) - - def on_speech_listen(self, result: dict): - logger.info("TingWuClient:: on speech listen. result: %s", result) - self.can_send_audio = True # 标记可以发送语音数据 - - def on_recognize_result(self, result: dict): - logger.info("TingWuClient:: on recognize result. result: %s", result) - - def on_ai_result(self, result: dict): - print(f"TingWuClient:: on ai result. result: {result}") - logger.info("TingWuClient:: on ai result. result: %s", result) - - def on_stopped(self) -> None: - logger.info("TingWuClient:: on task stopped.") - self.can_send_audio = False # 标记不能发送语音数据 - self.task_completed = True - - def on_error(self, error_code: str, error_msg: str) -> None: - logger.info( - "TingWuClient:: on error. error_code: %s, error_msg: %s", - error_code, - error_msg, - ) - self.task_completed = True - - def on_close(self, close_status_code, close_msg): - logger.info( - "TingWuClient:: on websocket close. close_status_code: %s, close_msg: %s", - close_status_code, - close_msg, - ) - self.task_completed = True - - -class TestTingWuRealtime: - @classmethod - def setup_class(cls): - cls.model = "tingwu-industrial-instruction" # replace model name - cls.format = "pcm" - cls.sample_rate = 16000 - cls.file = "./data/tingwu_test_audio.wav" - cls.appId = "your-app-id" - cls.base_address = "wss://dashscope.aliyuncs.com/api-ws/v1/inference" - cls.api_key = os.getenv("DASHSCOPE_API_KEY") - cls.terminology = "your-terminology-id" - - def test_async_start_with_stream(self): - print("开始测试...") - sys.stdout.flush() - - callback = TestCallback() - - print("创建 TingWuRealtime 实例...") - sys.stdout.flush() - - tingwu_realtime = TingWuRealtime( - model=self.model, - audio_format=self.format, - sample_rate=self.sample_rate, - app_id=self.appId, - base_address=self.base_address, - api_key=self.api_key, - terminology=self.terminology, - callback=callback, - max_end_silence=3000, - ) - - print("启动 TingWu 连接...") - sys.stdout.flush() - tingwu_realtime.start() - - # 等待连接建立 - time.sleep(2) - - try: - print(f"打开文件: {self.file}") - sys.stdout.flush() - - with open(self.file, "rb") as f: - chunk_count = 0 - while True: - chunk = f.read(3200) - if not chunk: - print("文件读取完毕") - sys.stdout.flush() - break - - # 修复逻辑错误:应该在can_send_audio为True时发送 - if callback.can_send_audio: - tingwu_realtime.send_audio_frame(chunk) - chunk_count += 1 - if chunk_count % 10 == 0: # 每10个chunk打印一次 - print(f"已发送 {chunk_count} 个音频块") - sys.stdout.flush() - else: - print("等待可以发送音频...") - sys.stdout.flush() - time.sleep(0.1) # 短暂等待 - - except FileNotFoundError: - print(f"文件未找到: {self.file}") - sys.stdout.flush() - except Exception as e: - print(f"发生错误: {e}") - sys.stdout.flush() - finally: - print("发送 stop 指令...") - sys.stdout.flush() - tingwu_realtime.stop() - - print("等待任务完成...") - sys.stdout.flush() - wait_time = 0 - max_wait_time = 30 # 最多等待30秒 - while not callback.task_completed and wait_time < max_wait_time: - time.sleep(1) - wait_time += 1 - print(f"已等待 {wait_time} 秒...") - sys.stdout.flush() - - if callback.task_completed: - print("任务已完成") - else: - print("等待超时,强制关闭连接") - - sys.stdout.flush() - tingwu_realtime.close() - print("TingWu 连接已关闭") - - -if __name__ == "__main__": - logger.debug("Start test_tingwu_realtime.") - - tingwu_realtime = TestTingWuRealtime() - tingwu_realtime.setup_class() - tingwu_realtime.test_async_start_with_stream() - - print("End test_tingwu_realtime.") - sys.stdout.flush() diff --git a/samples/test_tingwu_usages.py b/samples/test_tingwu_usages.py deleted file mode 100644 index 49e1bd8d..00000000 --- a/samples/test_tingwu_usages.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# 调用TingWu -from dashscope.multimodal.tingwu.tingwu import TingWu -import os - -# 创建TingWu实例 -tingwu = TingWu() -resp = TingWu.call( - model="tingwu-automotive-service-inspection", - user_defined_input={ - "fileUrl": "http://demo.com/test.mp3", - "appid": "123456", - }, - api_key=os.getenv("DASHSCOPE_API_KEY"), -) -print(resp) diff --git a/samples/test_video_synthesis.py b/samples/test_video_synthesis.py deleted file mode 100644 index dc997786..00000000 --- a/samples/test_video_synthesis.py +++ /dev/null @@ -1,136 +0,0 @@ -# -*- coding: utf-8 -*- -from http import HTTPStatus -from dashscope import VideoSynthesis -import os - -prompt = "一只小猫在月光下奔跑" -audio_url = "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20250925/ozwpvi/rap.mp3" -reference_urls = [ - "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/video/resources/with_human_voice_11s.mov", -] -api_key = os.getenv("DASHSCOPE_API_KEY") - - -def simple_call(): - print("----sync call, please wait a moment----") - rsp = VideoSynthesis.call( - api_key=api_key, - model="wan2.6-r2v", - reference_urls=reference_urls, - shot_type="multi", - audio=True, - watermark=True, - prompt=prompt, - ) - if rsp.status_code == HTTPStatus.OK: - print("response: %s" % rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - -def simple_call_t2v(): - print("----sync call, please wait a moment----") - rsp = VideoSynthesis.call( - api_key=api_key, - model="wan2.7-t2v", - audio=True, - watermark=True, - prompt=prompt, - ) - if rsp.status_code == HTTPStatus.OK: - print("response: %s" % rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - -media_i2v = [ - { - "type": VideoSynthesis.MediaType.LAST_FRAME, - "url": "https://wanx.alicdn.com/material/20250318/last_frame.png" - }, - { - "type": VideoSynthesis.MediaType.FIRST_FRAME, - "url": "https://wanx.alicdn.com/material/20250318/first_frame.png" - }, - { - "url": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/audio/mp3_3s.mp3", - "type": VideoSynthesis.MediaType.DRIVING_AUDIO - } -] - -def simple_call_wan27_i2v(): - print("----sync call, please wait a moment----") - rsp = VideoSynthesis.call( - api_key=api_key, - model="wan2.7-i2v", - media=media_i2v, - audio=True, - watermark=True, - prompt=prompt, - ) - if rsp.status_code == HTTPStatus.OK: - print("response: %s" % rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - -media_r2v = [ - { - "type": VideoSynthesis.MediaType.REFERENCE_IMAGE, - "url": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/image/res240_269.jpg" - }, - { - "type": VideoSynthesis.MediaType.REFERENCE_IMAGE, - "url": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/image/man_5K_7_7K_18_4M.JPG", - "reference_voice": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/audio/2s.wav" - }, - { - "type": VideoSynthesis.MediaType.REFERENCE_VIDEO, - "url": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/video/resources/cast/100M.mov", - "reference_voice": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/audio/mp3_1s.mp3" - }, - { - "type": VideoSynthesis.MediaType.REFERENCE_VIDEO, - "url": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/video/resources/cast/29_99s.mp4", - "reference_description": "这是一个身穿蓝衣的男子,他有着浓密的络腮胡" - }, - { - "type": VideoSynthesis.MediaType.REFERENCE_VIDEO, - "url": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/video/resources/cast/cat_127.mp4", - "reference_voice": "https://test-data-center.oss-accelerate.aliyuncs.com/wanx/audio/wav_10s.wav", - "reference_description": "这是一只毛绒小猫,它正在对着镜头微笑" - } -] - -def simple_call_wan27_r2v(): - print("----sync call, please wait a moment----") - rsp = VideoSynthesis.call( - api_key=api_key, - model="wan2.7-r2v", - media=media_r2v, - audio=True, - watermark=True, - prompt=prompt, - ) - if rsp.status_code == HTTPStatus.OK: - print("response: %s" % rsp) - else: - print( - "sync_call Failed, status_code: %s, code: %s, message: %s" - % (rsp.status_code, rsp.code, rsp.message), - ) - - - -if __name__ == "__main__": - simple_call() - # simple_call_t2v() - # simple_call_wan27_i2v() - # simple_call_wan27_r2v() diff --git a/setup.py b/setup.py index 2595c089..e3fbb8ef 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,9 @@ # -*- coding: utf-8 -*- import os +import shutil import setuptools +from setuptools.command.build_py import build_py as _build_py package_root = os.path.abspath(os.path.dirname(__file__)) @@ -37,6 +39,37 @@ def readme(): return content +class build_py(_build_py): + """Ship the top-level ``examples/`` tree inside the wheel. + + ``examples/`` lives at the repository root (a sibling of + ``dashscope/``) for visibility, but a wheel can only bundle files + that sit inside a package directory. Copy the tree into + ``/dashscope/examples`` so an installed wheel exposes + ``site-packages/dashscope/examples/``. + """ + + def run(self): + super().run() + src = os.path.join(package_root, "examples") + dst = os.path.join(self.build_lib, "dashscope", "examples") + if not os.path.isdir(src): + return + if os.path.exists(dst): + shutil.rmtree(dst) + shutil.copytree( + src, + dst, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + + +# Hoisted to a module-level name (like ``name``/``url`` above) so the +# static packaging audit in tests/unit/test_packaging_metadata.py keeps +# seeing only literal-evaluable setup() kwargs. +cmdclass = {"build_py": build_py} + + setuptools.setup( name=name, version=get_version(), @@ -102,5 +135,6 @@ def readme(): ], }, zip_safe=False, + cmdclass=cmdclass, entry_points={"console_scripts": ["dashscope = dashscope.cli:main"]}, ) diff --git a/specs/chat.yaml b/specs/chat.yaml new file mode 100644 index 00000000..dbc9776e --- /dev/null +++ b/specs/chat.yaml @@ -0,0 +1,314 @@ +# DashScope 2.0 API metadata — chat.completions (prototype, M1 task 1.10). +# +# Format: OpenAPI 3 core + vendor extensions. Core fields are language-agnostic; +# language-specific generation hints live in x-python / x-java / x-node +# namespaces (only x-python is populated for now). Consumed by the codegen +# pipeline (M6) to emit SDK resource methods, CLI commands, docs and smoke tests. +openapi: 3.0.3 +info: + title: DashScope Chat Completions + version: "2.0" + description: Text generation (chat) API expressed over the native wire format. + x-dashscope: + spec_version: "0.1.0" + resource_path: chat.completions.create # SDK resource tree location + sdk_convenience: + output_text: choices[0].message.content # resp.output_text shortcut + module_quickstart: dashscope.chat # dashscope.chat(model, prompt) + +servers: + - url: https://dashscope.aliyuncs.com/api/v1 + description: DashScope native endpoint + +paths: + /services/aigc/text-generation/generation: + post: + operationId: chat_completions_create + summary: Create a chat completion (plain or SSE streaming). + x-dashscope: + # Operation form: plain | stream | task (async long-running job). + # chat is "plain" with an optional streaming mode described below. + form: plain + stream: + enabled: true + trigger_parameter: stream # SDK parameter that enables it + request_headers: + X-DashScope-SSE: enable + forced_parameters: + incremental_output: true # deltas, merged by SDK helpers + media_type: text/event-stream + chunk_schema: + $ref: "#/components/schemas/ChatCompletionChunk" + in_stream_errors: raise # never yield error objects + helpers: + - text_stream # plain-text iterator + - get_final_message # aggregate into ChatCompletion + # Protocol-compatibility mapping (design §3): same capability expressed + # in OpenAI / Anthropic wire protocols. Field maps are direction + # native -> foreign. + protocols: + openai: + path: /chat/completions + field_map: + input.messages: messages + parameters.temperature: temperature + parameters.top_p: top_p + parameters.max_tokens: max_tokens + parameters.stop: stop + parameters.seed: seed + parameters.tools: tools + parameters.result_format: null # implicit in openai protocol + output.choices: choices + usage.input_tokens: prompt_tokens + usage.output_tokens: completion_tokens + anthropic: + path: /v1/messages + field_map: + input.messages: messages + parameters.max_tokens: max_tokens + parameters.temperature: temperature + parameters.top_p: top_p + parameters.stop: stop_sequences + output.choices[0].message.content: content + output.choices[0].message.reasoning_content: null # no 1:1 field + # CLI generation hints (language-agnostic command tree). + cli: + command: dashscope chat completions create + examples: + - 'dashscope chat completions create --model qwen-plus --message user:"Hello"' + - 'dashscope chat completions create --model qwen-plus --message user:"Tell me a joke" --stream' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [model, input] + properties: + model: + type: string + description: Model name, e.g. qwen-plus. + example: qwen-plus + input: + type: object + required: [messages] + properties: + messages: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Message" + parameters: + type: object + properties: + result_format: + type: string + enum: [message, text] + default: message + description: SDK always sends "message". + incremental_output: + type: boolean + description: Only meaningful with SSE; forced true when streaming. + temperature: + type: number + minimum: 0 + maximum: 2 + top_p: + type: number + minimum: 0 + maximum: 1 + max_tokens: + type: integer + minimum: 1 + stop: + oneOf: + - type: string + - type: array + items: + type: string + seed: + type: integer + repetition_penalty: + type: number + presence_penalty: + type: number + minimum: -2 + maximum: 2 + enable_thinking: + type: boolean + description: Emit reasoning_content for thinking models. + response_format: + type: object + description: 'e.g. {"type": "json_object"}.' + tools: + type: array + items: + type: object + responses: + "200": + description: Completion (plain) or SSE event stream. + content: + application/json: + schema: + $ref: "#/components/schemas/ChatCompletion" + text/event-stream: + schema: + $ref: "#/components/schemas/ChatCompletionChunk" + default: + description: Error envelope with machine-readable code. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + +components: + schemas: + Message: + type: object + required: [role] + properties: + role: + type: string + enum: [system, user, assistant, tool] + content: + description: Plain text or multimodal part list. + oneOf: + - type: string + - type: array + items: + $ref: "#/components/schemas/ContentPart" + reasoning_content: + type: string + nullable: true + tool_calls: + type: array + items: + type: object + + ContentPart: + type: object + required: [type] + discriminator: + propertyName: type + properties: + type: + type: string + enum: [text, image, audio, video] + + Usage: + type: object + properties: + input_tokens: + type: integer + output_tokens: + type: integer + total_tokens: + type: integer + + Choice: + type: object + required: [index] + properties: + index: + type: integer + finish_reason: + type: string + nullable: true + message: + $ref: "#/components/schemas/Message" + + ChatCompletion: + type: object + properties: + request_id: + type: string + model: + type: string + output: + type: object + properties: + choices: + type: array + items: + $ref: "#/components/schemas/Choice" + usage: + $ref: "#/components/schemas/Usage" + x-python: + class: ChatCompletion + module: dashscope.v2.types.completions + convenience: + output_text: output.choices[0].message.content + + StreamDelta: + type: object + properties: + role: + type: string + content: + type: string + reasoning_content: + type: string + tool_calls: + type: array + items: + type: object + + StreamChoice: + type: object + required: [index] + properties: + index: + type: integer + finish_reason: + type: string + nullable: true + delta: + $ref: "#/components/schemas/StreamDelta" + + ChatCompletionChunk: + type: object + properties: + request_id: + type: string + model: + type: string + output: + type: object + properties: + choices: + type: array + items: + $ref: "#/components/schemas/StreamChoice" + usage: + $ref: "#/components/schemas/Usage" + x-python: + class: ChatCompletionChunk + module: dashscope.v2.types.completions + + ErrorResponse: + type: object + required: [code, message] + properties: + code: + type: string + description: Machine-readable business error code. + enum: + - InvalidApiKey + - AccessDenied + - AccessDenied.Unpurchased + - InvalidParameter + - Model.NotExist + - DataInspectionFailed + - Throttling + - Throttling.RateQuota + - Throttling.AllocationQuota + - InternalError + - ServiceUnavailable + - GatewayTimeout + - Arrearage + message: + type: string + request_id: + type: string + x-python: + maps_to: dashscope.v2.errors.ErrorCode diff --git a/tests/unit/test_async_custom_session.py b/tests/unit/test_async_custom_session.py index 032e805e..10d31051 100644 --- a/tests/unit/test_async_custom_session.py +++ b/tests/unit/test_async_custom_session.py @@ -84,7 +84,7 @@ class TestAsyncSessionUsage: async def test_custom_aio_session_is_used_for_request(self): """测试自定义 aio_session 被实际用于请求""" # 创建 mock session - mock_session = AsyncMock() + mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 mock_response.headers = {"content-type": "application/json"} @@ -429,7 +429,7 @@ class TestAsyncSessionWithDifferentMethods: @pytest.mark.asyncio async def test_custom_aio_session_with_post_request(self): """测试 POST 请求使用自定义 session""" - mock_session = AsyncMock() + mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 mock_response.__aenter__ = AsyncMock(return_value=mock_response) @@ -476,7 +476,7 @@ async def mock_handle_response(_response): @pytest.mark.asyncio async def test_custom_aio_session_with_get_request(self): """测试 GET 请求使用自定义 session""" - mock_session = AsyncMock() + mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 mock_response.__aenter__ = AsyncMock(return_value=mock_response) @@ -596,7 +596,7 @@ class TestAsyncSessionLifecycle: @pytest.mark.asyncio async def test_multiple_requests_with_same_custom_session(self): """测试使用同一个自定义 session 进行多次请求""" - mock_session = AsyncMock() + mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 mock_response.__aenter__ = AsyncMock(return_value=mock_response) diff --git a/tests/unit/test_cli_agent_route.py b/tests/unit/test_cli_agent_route.py index 7da8d573..46375f57 100644 --- a/tests/unit/test_cli_agent_route.py +++ b/tests/unit/test_cli_agent_route.py @@ -572,10 +572,20 @@ def test_expert_example_bundled(self): assert (expert / ".acli" / "config.toml").is_file() assert (expert / "README.md").is_file() - def test_example_download_merges_into_workspace(self, tmp_path): - from dashscope.acli.cli.examples import _handle_example_command + def test_example_download_merges_into_workspace( + self, + tmp_path, + monkeypatch, + ): + from dashscope.acli.cli import examples as examples_cli + + # The vendored acli prefers /examples (upstream dev-mode + # layout); in this repo that tree holds the SDK examples, not the + # bundled acli ones, so pin the lookup to the packaged directory. + bundled = Path(dashscope.acli.__file__).resolve().parent / "examples" + monkeypatch.setattr(examples_cli, "_get_examples_dir", lambda: bundled) - _handle_example_command( + examples_cli._handle_example_command( ["download", "dashscope-sdk-expert", "--target", str(tmp_path)], ) diff --git a/tests/unit/test_maas_region.py b/tests/unit/test_maas_region.py index b47ff665..0e1d340b 100644 --- a/tests/unit/test_maas_region.py +++ b/tests/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="qwen-turbo", + model="qwen3.8-32b", 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="wanx-v1", + model="qwen-image-3.0", 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="qwen-turbo", + model="qwen3.8-32b", prompt="hi", api_key="sk-test", ) diff --git a/tests/unit/test_recognition_explicit_params.py b/tests/unit/test_recognition_explicit_params.py index 5edcd96f..dcb4016b 100644 --- a/tests/unit/test_recognition_explicit_params.py +++ b/tests/unit/test_recognition_explicit_params.py @@ -3,6 +3,10 @@ """Test explicit parameters for Recognition class.""" # pylint: disable=protected-access +import threading +from contextlib import contextmanager +from unittest import mock + from dashscope.audio.asr.recognition import Recognition, RecognitionCallback @@ -22,6 +26,39 @@ def on_close(self): pass +@contextmanager +def _stubbed_worker(): + """Stub out the receive worker so start() makes no network call. + + The fake worker must block until released: start() checks is_alive() + right after starting the thread, and an instantly exiting thread is + reported as InvalidTask("Invalid task, task create failed."). + """ + release = threading.Event() + + def _fake_worker(self): + release.wait(timeout=10) + + with mock.patch.object( + Recognition, + "_Recognition__receive_worker", + _fake_worker, + ): + yield release + + +def _stop_recognition(recognition, release): + recognition._running = False + release.set() + if recognition._worker is not None and recognition._worker.is_alive(): + recognition._worker.join(timeout=1) + if ( + recognition._silence_timer is not None + and recognition._silence_timer.is_alive() + ): + recognition._silence_timer.cancel() + + class TestRecognitionExplicitParams: """Test explicit parameters in Recognition class.""" @@ -106,30 +143,16 @@ def test_start_with_explicit_params(self): sample_rate=16000, ) - # Mock the thread and timer to avoid actual execution - recognition._running = False - recognition._callback = MockRecognitionCallback() - - # Call start with explicit parameters - # Note: This will fail at thread creation, but we can verify params - try: - recognition.start( - phrase_id="test_phrase", - disfluency_removal_enabled=True, - diarization_enabled=True, - speaker_count=2, - ) - except Exception: - # Expected to fail at thread creation - pass - finally: - # Clean up to stop any background threads - recognition._running = False - if ( - recognition._worker is not None - and recognition._worker.is_alive() - ): - recognition._worker.join(timeout=1) + with _stubbed_worker() as release: + try: + recognition.start( + phrase_id="test_phrase", + disfluency_removal_enabled=True, + diarization_enabled=True, + speaker_count=2, + ) + finally: + _stop_recognition(recognition, release) # Verify parameters are updated in _kwargs assert recognition._kwargs.get("disfluency_removal_enabled") is True @@ -151,26 +174,15 @@ def test_param_override_in_start(self): assert recognition._kwargs["disfluency_removal_enabled"] is False assert recognition._kwargs["speaker_count"] == 1 - # Mock the thread and timer to avoid actual execution - recognition._running = False - # Override parameters in start - try: - recognition.start( - disfluency_removal_enabled=True, - speaker_count=3, - ) - except Exception: - # Expected to fail at thread creation - pass - finally: - # Clean up to stop any background threads - recognition._running = False - if ( - recognition._worker is not None - and recognition._worker.is_alive() - ): - recognition._worker.join(timeout=1) + with _stubbed_worker() as release: + try: + recognition.start( + disfluency_removal_enabled=True, + speaker_count=3, + ) + finally: + _stop_recognition(recognition, release) # Verify parameters are overridden assert recognition._kwargs.get("disfluency_removal_enabled") is True diff --git a/tests/v2/protocols/test_anthropic_conformance.py b/tests/v2/protocols/test_anthropic_conformance.py new file mode 100644 index 00000000..09a4b3bb --- /dev/null +++ b/tests/v2/protocols/test_anthropic_conformance.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Conformance: v2 AnthropicProtocol vs acli AnthropicProvider (M2 2.5). + +Both sides receive their own canonical message shape and must emit the +same Anthropic wire format. The acli synced copy is reference-only and +never modified (design doc §3.2); these cases pin the behavior the M5 +5.1 switch must preserve. + +Known intentional divergences (not covered here): +- images: acli passes ``image_url`` blocks through and only converts + ``data:`` URLs to base64 sources; v2 converts canonical image parts + to ``{"type": "image", "source": {"type": "url", ...}}``. +- multiple system messages: acli keeps the last one; v2 joins all. +- tool_choice: acli's ``AnthropicProvider`` does not handle + ``tool_choice`` at all; v2 translates canonical values onto the + Anthropic wire object form (see ``_translate_tool_choice``), so there + is no acli counterpart to pin here. +""" + +# The acli converter methods are stateless; call them unbound. +# pylint: disable=protected-access + +import json + +from dashscope.acli.providers.anthropic import AnthropicProvider +from dashscope.v2.protocols import AnthropicProtocol + +MODEL = "qwen3-max" + + +def _acli_convert(messages): + return AnthropicProvider._convert_messages(None, messages) + + +def _acli_tools(tools): + return AnthropicProvider._convert_tools(None, tools) + + +def _v2_request(messages, **kwargs): + return AnthropicProtocol().build_chat_request( + model=MODEL, + messages=messages, + **kwargs, + ) + + +def test_system_and_user_messages(): + system, messages = _acli_convert( + [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ], + ) + request = _v2_request( + [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ], + ) + assert request.body["system"] == system + assert request.body["messages"] == messages + + +def test_assistant_tool_call_and_tool_result(): + acli_system, acli_messages = _acli_convert( + [ + {"role": "user", "content": "Weather in Hangzhou?"}, + { + "role": "assistant", + "content": "Checking.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": {"city": "Hangzhou"}, + }, + }, + ], + }, + { + "role": "tool", + "tool_use_id": "call_1", + "content": "sunny", + }, + ], + ) + request = _v2_request( + [ + {"role": "user", "content": "Weather in Hangzhou?"}, + { + "role": "assistant", + "content": "Checking.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": json.dumps({"city": "Hangzhou"}), + }, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "sunny", + }, + ], + ) + assert request.body["messages"] == acli_messages + assert acli_system == "" + assert "system" not in request.body + + +def test_parallel_tool_results_merge_into_one_user_turn(): + _, acli_messages = _acli_convert( + [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_a", + "type": "function", + "function": {"name": "f", "arguments": {}}, + }, + { + "id": "call_b", + "type": "function", + "function": {"name": "g", "arguments": {}}, + }, + ], + }, + {"role": "tool", "tool_use_id": "call_a", "content": "a"}, + {"role": "tool", "tool_use_id": "call_b", "content": "b"}, + {"role": "user", "content": "continue"}, + ], + ) + request = _v2_request( + [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_a", + "type": "function", + "function": { + "name": "f", + "arguments": "{}", + }, + }, + { + "id": "call_b", + "type": "function", + "function": { + "name": "g", + "arguments": "{}", + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_a", "content": "a"}, + {"role": "tool", "tool_call_id": "call_b", "content": "b"}, + {"role": "user", "content": "continue"}, + ], + ) + assert request.body["messages"] == acli_messages + # Both sides merge consecutive tool results into ONE user message, + # and a following user text turn joins it (tool_result blocks first). + assert request.body["messages"][1]["role"] == "user" + assert len(request.body["messages"][1]["content"]) == 3 + assert request.body["messages"][1]["content"][0]["type"] == "tool_result" + assert request.body["messages"][1]["content"][-1] == { + "type": "text", + "text": "continue", + } + + +def test_tools_translation(): + flat_tools = [ + { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + ] + request = _v2_request( + [{"role": "user", "content": "hi"}], + tools=flat_tools, + ) + assert request.body["tools"] == _acli_tools(flat_tools) + + +def test_max_tokens_default_matches(): + request = _v2_request([{"role": "user", "content": "hi"}]) + assert request.body["max_tokens"] == AnthropicProvider.default_max_tokens + + +def test_text_content_parts(): + parts = [ + {"type": "text", "text": "line one"}, + {"type": "text", "text": "line two"}, + ] + _, acli_messages = _acli_convert( + [{"role": "user", "content": list(parts)}], + ) + request = _v2_request([{"role": "user", "content": list(parts)}]) + assert request.body["messages"] == acli_messages diff --git a/tests/v2/protocols/test_integration_endpoints.py b/tests/v2/protocols/test_integration_endpoints.py new file mode 100644 index 00000000..c5aaa3b4 --- /dev/null +++ b/tests/v2/protocols/test_integration_endpoints.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Live integration tests for OpenAI-compatible endpoints (M2 2.6). + +Cases are skipped unless the matching env vars are exported. The Bailian +cases route through the workspace-scoped MaaS gateway: the SDK derives +``https://{workspace}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1`` +from ``workspace`` + ``protocol="openai"``, so no gateway URL is +hardcoded here (the legacy shared gateway is only a no-workspace +fallback). They need both an API key and a workspace. The generic +gateway case targets any OpenAI-compatible endpoint via the explicit +``base_url`` escape hatch:: + + DASHSCOPE_API_KEY=... DASHSCOPE_WORKSPACE=... pytest tests/v2/protocols/ + GATEWAY_API_KEY=... GATEWAY_BASE_URL=... GATEWAY_MODEL=... \\ + pytest tests/v2/protocols/test_integration_endpoints.py +""" + +from __future__ import annotations + +import os + +import pytest + +from dashscope.v2 import Client + + +def _make_client(api_key, base_url): + if not api_key: + pytest.skip("API key not set") + return Client( + api_key=api_key, + base_url=base_url, + protocol="openai", + max_retries=1, + ) + + +def _make_workspace_client(api_key, workspace): + # Route through the workspace-scoped MaaS gateway: the SDK derives the + # OpenAI-compatible endpoint from workspace + protocol, so no gateway + # URL is hardcoded here (the legacy shared gateway is only a + # no-workspace fallback). + if not api_key: + pytest.skip("API key not set") + if not workspace: + pytest.skip("DASHSCOPE_WORKSPACE not set") + return Client( + api_key=api_key, + workspace=workspace, + protocol="openai", + max_retries=1, + ) + + +def test_bailian_compatible_mode_chat(): + client = _make_workspace_client( + os.environ.get("DASHSCOPE_API_KEY"), + os.environ.get("DASHSCOPE_WORKSPACE"), + ) + with client: + resp = client.chat.completions.create( + model="qwen-plus", + messages=[ + {"role": "user", "content": "Reply with the single word: ok"}, + ], + max_tokens=32, + ) + assert resp.output_text.strip() + assert resp.choices[0].finish_reason + assert resp.usage is not None + assert resp.usage.total_tokens > 0 + + +def test_bailian_compatible_mode_streaming(): + client = _make_workspace_client( + os.environ.get("DASHSCOPE_API_KEY"), + os.environ.get("DASHSCOPE_WORKSPACE"), + ) + with client: + stream = client.chat.completions.create( + model="qwen-plus", + messages=[ + {"role": "user", "content": "Reply with the single word: ok"}, + ], + max_tokens=32, + stream=True, + ) + final = stream.get_final_message() + assert final.output_text.strip() + assert final.choices[0].finish_reason + + +def test_generic_openai_compatible_gateway(): + model = os.environ.get("GATEWAY_MODEL") + base_url = os.environ.get("GATEWAY_BASE_URL") + if not model or not base_url: + pytest.skip("GATEWAY_MODEL / GATEWAY_BASE_URL not set") + client = _make_client(os.environ.get("GATEWAY_API_KEY"), base_url) + with client: + resp = client.chat.completions.create( + model=model, + messages=[ + {"role": "user", "content": "Reply with the single word: ok"}, + ], + max_tokens=32, + ) + assert resp.output_text.strip() + assert resp.choices[0].finish_reason diff --git a/tests/v2/protocols/test_openai_conformance.py b/tests/v2/protocols/test_openai_conformance.py new file mode 100644 index 00000000..be0ea359 --- /dev/null +++ b/tests/v2/protocols/test_openai_conformance.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Conformance: v2 OpenAIProtocol vs acli OpenAIProvider (M2 2.5). + +Both sides receive their own canonical message shape and must emit the +same OpenAI wire format. The acli synced copy is reference-only and +never modified (design doc §3.2); these cases pin the behavior the M5 +5.1 switch must preserve. + +Known intentional divergences (not covered here): +- acli's canonical tool messages key on ``tool_use_id``; v2's on + ``tool_call_id``; both emit ``tool_call_id`` on the wire. +""" + +# The acli converter methods are stateless; call them unbound. +# pylint: disable=protected-access + +from dashscope.acli.providers.openai import OpenAIProvider +from dashscope.v2.protocols import OpenAIProtocol + +MODEL = "qwen-plus" + + +def _acli_messages(messages): + return OpenAIProvider._convert_messages(None, messages) + + +def _acli_tools(tools): + return OpenAIProvider._convert_tools(None, tools) + + +def _v2_body(messages, **kwargs): + return ( + OpenAIProtocol() + .build_chat_request( + model=MODEL, + messages=messages, + **kwargs, + ) + .body + ) + + +def test_plain_messages_pass_through(): + messages = [ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ] + assert _v2_body(messages)["messages"] == _acli_messages(messages) + + +def test_tool_call_round_trip(): + acli_wire = _acli_messages( + [ + {"role": "user", "content": "Weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": {"city": "Hangzhou"}, + }, + }, + ], + }, + { + "role": "tool", + "tool_use_id": "call_1", + "content": "sunny", + }, + ], + ) + v2_wire = _v2_body( + [ + {"role": "user", "content": "Weather?"}, + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Hangzhou"}', + }, + }, + ], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "sunny", + }, + ], + )["messages"] + assert v2_wire == acli_wire + + +def test_reasoning_content_echoed_on_assistant_turns(): + history = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "let me think", + }, + {"role": "user", "content": "and then?"}, + ] + v2_wire = _v2_body(history)["messages"] + assert v2_wire == _acli_messages(history) + # Reasoning-model providers require the echo. + assert v2_wire[1]["reasoning_content"] == "let me think" + + +def test_tools_translation(): + flat_tools = [ + { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + ] + body = _v2_body( + [{"role": "user", "content": "hi"}], + tools=[{"type": "function", "function": tool} for tool in flat_tools], + ) + assert body["tools"] == _acli_tools(flat_tools) + + +def test_response_format_passthrough(): + response_format = {"type": "json_object"} + body = _v2_body( + [{"role": "user", "content": "hi"}], + response_format=response_format, + ) + assert body["response_format"] == response_format + + +def test_sampling_parameters_not_sent_when_absent(): + # Neither side invents sampling defaults on the wire. + body = _v2_body([{"role": "user", "content": "hi"}]) + for key in ("temperature", "top_p", "max_tokens", "stop", "seed"): + assert key not in body diff --git a/tests/v2/smoke/test_examples.py b/tests/v2/smoke/test_examples.py new file mode 100644 index 00000000..af10f89f --- /dev/null +++ b/tests/v2/smoke/test_examples.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Smoke tests that keep ``examples`` from going stale. + +Two offline gates: every example file must compile, and the examples +built on mock transports / local subprocesses must run to completion, +so a breaking SDK change turns red here instead of being reported by +users. When adding a new offline-capable example, list it in +``OFFLINE_EXAMPLES`` below. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +EXAMPLES = ROOT / "examples" + +# Examples that run fully offline (mock transport / local subprocess), +# as paths relative to examples. +OFFLINE_EXAMPLES = [ + "02_advanced_engineering/local_mock_debugging.py", + "02_advanced_engineering/circuit_breaker_retry.py", + "03_agent_recipes/mcp_server_demo/client_demo.py", + "04_real_world_scenarios/production_ready_rag_harness.py", + "04_real_world_scenarios/realtime_audio_meeting_harness.py", + "04_real_world_scenarios/ticket_triage_harness.py", +] + + +def test_all_examples_compile(): + sources = sorted(EXAMPLES.rglob("*.py")) + assert sources, "no example files found under examples" + for path in sources: + compile(path.read_text(encoding="utf-8"), str(path), "exec") + + +@pytest.mark.parametrize("relative", OFFLINE_EXAMPLES) +def test_offline_example_runs(relative): + # A dummy key keeps construction hermetic; the offline examples + # never reach the network (mock transport / local subprocess only). + env = { + **os.environ, + "PYTHONPATH": str(ROOT), + "DASHSCOPE_API_KEY": "sk-smoke", + } + proc = subprocess.run( + [sys.executable, str(EXAMPLES / relative)], + cwd=ROOT, + env=env, + timeout=180, + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr[-2000:] diff --git a/tests/v2/smoke/test_full_surface.py b/tests/v2/smoke/test_full_surface.py new file mode 100644 index 00000000..a91c1d06 --- /dev/null +++ b/tests/v2/smoke/test_full_surface.py @@ -0,0 +1,693 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Full-surface smoke tests for the v2 resource tree (M3 3.9). + +One endpoint-level roundtrip per resource of the mapping checklist +(md/dashscope-2.0-interfaces-mapping.md), all over httpx.MockTransport. +""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client + +BASE_URL = "https://api.test.local/api/v1" + +CHAT_PATH = "/api/v1/services/aigc/text-generation/generation" +MULTIMODAL_PATH = "/api/v1/services/aigc/multimodal-generation/generation" + + +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 _json(body): + return httpx.Response(200, json=body) + + +def _chat_body(text="Hello"): + return { + "request_id": "req-chat", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": text}, + }, + ], + }, + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + + +def _sse(frames): + out = [] + for index, data in enumerate(frames, 1): + out.append(f"id: {index}\ndata: {json.dumps(data)}\n\n") + return "".join(out) + + +def test_smoke_chat_native(): + seen = [] + + def handler(request): + seen.append(request.url.path) + if request.headers.get("X-DashScope-SSE") == "enable": + return httpx.Response( + 200, + content=_sse([_chat_body("He"), _chat_body("Hello")]), + headers={"content-type": "text/event-stream"}, + ) + return _json(_chat_body()) + + client = _make_client(handler) + resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + ) + assert resp.output_text == "Hello" + texts = [ + chunk.delta.content if chunk.delta else "" + for chunk in client.chat.completions.create( # pylint: disable=not-an-iterable # noqa: E501 + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + ] + assert texts == ["He", "Hello"] + assert set(seen) == {CHAT_PATH} + + +def test_smoke_chat_openai_protocol(): + def handler(request): + assert request.url.path == "/api/v1/chat/completions" + return _json( + { + "id": "cmpl-1", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Hi!"}, + }, + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + }, + ) + + client = _make_client(handler, protocol="openai") + resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + ) + assert resp.output_text == "Hi!" + + +def test_smoke_multimodal_chat(): + def handler(request): + assert request.url.path == MULTIMODAL_PATH + body = json.loads(request.content) + assert body["input"]["messages"][0]["content"][0] == { + "text": "describe", + } + return _json( + { + "request_id": "req-mm", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"text": "a cat"}], + }, + }, + ], + }, + }, + ) + + client = _make_client(handler) + resp = client.multimodal.chat.create( + model="qwen-vl-plus", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + { + "type": "image_url", + "image_url": {"url": "https://cdn.example.com/c.png"}, + }, + ], + }, + ], + ) + assert resp.output_text == "a cat" + + +def test_smoke_embeddings_rerank_tokenization(): + def handler(request): + if request.url.path.endswith("multimodal-embedding"): + return _json( + { + "request_id": "req-mme", + "output": { + "embeddings": [ + {"index": 0, "type": "text", "embedding": [0.3]}, + ], + }, + }, + ) + if request.headers.get("X-DashScope-Async") == "enable": + return _json( + { + "request_id": "req-batch", + "output": {"task_id": "t-b", "task_status": "PENDING"}, + }, + ) + if request.url.path.startswith("/api/v1/tasks/"): + return _json( + { + "request_id": "req-batch-get", + "output": { + "task_id": "t-b", + "task_status": "SUCCEEDED", + "results": {"url": "https://cdn.example.com/o.jsonl"}, + }, + }, + ) + if request.url.path.endswith("text-embedding"): + return _json( + { + "request_id": "req-emb", + "output": { + "embeddings": [ + {"text_index": 0, "embedding": [0.1, 0.2]}, + ], + }, + "usage": {"total_tokens": 3}, + }, + ) + if request.url.path.endswith("text-rerank"): + return _json( + { + "request_id": "req-rr", + "output": { + "results": [ + {"index": 0, "relevance_score": 0.9}, + ], + }, + }, + ) + return _json( + { + "request_id": "req-tok", + "usage": {"input_tokens": 4}, + }, + ) + + client = _make_client(handler) + emb = client.embeddings.create( + model="text-embedding-v3", + input=["hello"], + ) + assert emb.embeddings[0].embedding == [0.1, 0.2] + rer = client.rerank.create( + model="gte-rerank", + query="q", + documents=["d1"], + ) + assert rer.results[0].relevance_score == 0.9 + count = client.tokenization.count(model="qwen-turbo", prompt="hello") + assert count.input_tokens == 4 + mme = client.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[{"text": "hello"}], + ) + assert mme.embeddings[0].embedding == [0.3] + batch = client.embeddings.jobs.create( + model="text-embedding-async-v2", + file_url="https://cdn.example.com/i.jsonl", + ) + done = client.embeddings.jobs.wait(batch, _sleep=lambda seconds: None) + assert done.succeeded + + +def test_smoke_decision(): + def handler(request): + assert request.url.path == "/api/v1/systemone" + body = json.loads(request.content) + assert body["questions"]["refund"]["type"] == "noul" + return _json( + { + "request_id": "req-dec", + "model": "decision-model-preview", + "answers": {"refund": {"type": "noul", "noul": 0.99}}, + "usage": {"input_tokens": 9}, + "latency_ms": 12.3, + }, + ) + + client = _make_client(handler) + resp = client.decision.create( + model="decision-model-preview", + state="这段评论是否在索要退款?", + questions={"refund": {"type": "noul"}}, + ) + assert resp.answers["refund"].noul == 0.99 + assert resp.usage.input_tokens == 9 + + +def test_smoke_files_models(tmp_path): + data_file = tmp_path / "train.jsonl" + data_file.write_text('{"prompt": "hi"}\n', encoding="utf-8") + + def handler(request): + if request.method == "POST" and request.url.path.endswith("/files"): + return _json( + { + "request_id": "req-f", + "data": { + "uploaded_files": [ + {"name": "train.jsonl", "file_id": "file-1"}, + ], + }, + }, + ) + if request.method == "GET" and request.url.path.endswith( + "/files/file-1", + ): + return _json( + { + "request_id": "req-f2", + "data": {"file_id": "file-1", "name": "train.jsonl"}, + }, + ) + if request.method == "DELETE": + return _json( + {"request_id": "req-f3", "data": {"deleted": True}}, + ) + if request.url.path.endswith("/files"): + return _json( + {"request_id": "req-f4", "data": {"file_list": []}}, + ) + if request.url.path.endswith("/models/qwen-plus"): + return _json( + { + "request_id": "req-m", + "data": {"model": "qwen-plus", "source": "dashscope"}, + }, + ) + return _json( + { + "request_id": "req-m2", + "output": {"models": [{"model": "qwen-plus"}]}, + }, + ) + + client = _make_client(handler) + uploaded = client.files.upload(file_path=str(data_file)) + assert uploaded is not None + assert client.files.list() is not None + assert client.files.get("file-1").file_id == "file-1" + assert client.files.delete("file-1") is not None + assert client.models.list().models[0].model == "qwen-plus" + assert client.models.get("qwen-plus").model == "qwen-plus" + + +def test_smoke_images_videos(): + def handler(request): + if request.method == "POST": + assert request.headers.get("X-DashScope-Async") == "enable" + return _json( + { + "request_id": "req-sub", + "output": {"task_id": "t-1", "task_status": "PENDING"}, + }, + ) + return _json( + { + "request_id": "req-get", + "output": { + "task_id": "t-1", + "task_status": "SUCCEEDED", + "results": [{"url": "https://cdn.example.com/a.png"}], + }, + }, + ) + + client = _make_client(handler) + img = client.images.generate( + model="wanx2.1-t2i", + prompt="a cat", + n=1, + size="1024*1024", + poll_interval=0.01, + ) + assert img.succeeded + video = client.videos.generate( + model="wanx2.1-t2v", + prompt="a running cat", + poll_interval=0.01, + ) + assert video.succeeded + + +def _ws_event(name, payload=None): + return json.dumps( + { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload or {}, + }, + ) + + +class FakeWebSocket: + def __init__(self, inbox=None): + self.sent = [] + self.inbox = list(inbox or []) + self.closed = False + + def send(self, payload, opcode=1): + assert opcode == (2 if isinstance(payload, bytes) else 1) + self.sent.append(payload) + + def recv(self): + if not self.inbox: + return None + return self.inbox.pop(0) + + def close(self): + self.closed = True + + def settimeout(self, seconds): + pass + + +def test_smoke_audio_asr_recognition_and_transcription(): + ws = FakeWebSocket( + [ + _ws_event("task-started"), + _ws_event( + "result-generated", + {"output": {"sentence": {"text": "你好"}}}, + ), + _ws_event("task-finished"), + ], + ) + client = _make_client( + lambda request: _json( + { + "request_id": "req-tr", + "output": { + "task_id": "t-2", + "task_status": "SUCCEEDED", + "results": [{"transcription_url": "https://x/y.json"}], + }, + }, + ), + workspace="ws-test", + ) + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + connector=lambda url, headers: ws, + ) as recognition: + recognition.send_audio_frame(b"PCM") + events = list(recognition.events()) + assert events + assert ws.closed + job = client.audio.asr.transcribe( + ["https://cdn.example.com/a.wav"], + poll_interval=0.01, + ) + assert job.succeeded + + +def test_smoke_audio_tts_and_realtime(): + tts_ws = FakeWebSocket( + [ + _ws_event("task-started"), + b"AUD", + _ws_event("task-finished"), + ], + ) + client = _make_client(lambda request: _json({}), workspace="ws-test") + audio = client.audio.tts.synthesize( + "你好", + connector=lambda url, headers: tts_ws, + ) + assert audio == b"AUD" + + rt_ws = FakeWebSocket([json.dumps({"type": "session.created"})]) + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + connector=lambda url, headers: rt_ws, + ) as rt: + assert next(rt.events())["type"] == "session.created" + + +def test_smoke_fine_tunes_full_cycle(): + seen = [] + + def handler(request): + seen.append((request.method, request.url.path)) + path = request.url.path + if request.method == "POST" and path.endswith("/fine-tunes"): + return _json( + { + "request_id": "req-1", + "output": {"job_id": "ft-1", "status": "PENDING"}, + }, + ) + if path.endswith("/stream"): + return httpx.Response( + 200, + content=_sse([{"status": "RUNNING"}]), + headers={"content-type": "text/event-stream"}, + ) + if path.endswith("/cancel"): + return _json( + { + "request_id": "req-2", + "output": {"status": "canceled"}, + }, + ) + if request.method == "DELETE": + return _json( + {"request_id": "req-3", "output": {"status": "deleted"}}, + ) + if request.method == "GET" and path.endswith("/fine-tunes"): + return _json( + { + "request_id": "req-4", + "output": { + "page_no": 1, + "page_size": 10, + "total": 1, + "jobs": [{"job_id": "ft-1", "status": "RUNNING"}], + }, + }, + ) + return _json( + { + "request_id": "req-5", + "output": {"job_id": "ft-1", "status": "SUCCEEDED"}, + }, + ) + + client = _make_client(handler) + job = client.fine_tunes.create( + model="qwen-turbo", + training_file_ids=["file-1"], + mode="sft", + ) + assert job.job_id == "ft-1" + done = client.fine_tunes.wait(job, _sleep=lambda seconds: None) + assert done.status == "SUCCEEDED" + assert client.fine_tunes.get("ft-1").status == "SUCCEEDED" + assert client.fine_tunes.list().total_count == 1 + events = list(client.fine_tunes.stream_events("ft-1")) + assert events[0].output == {"status": "RUNNING"} + assert client.fine_tunes.cancel("ft-1").status == "canceled" + assert client.fine_tunes.delete("ft-1").status == "deleted" + + +def test_smoke_deployments_full_cycle(): + deployment = { + "deployed_model": "dep-1", + "model_name": "ft-model", + "status": "RUNNING", + "capacity": 1, + } + + def handler(request): + if request.method == "GET" and request.url.path.endswith( + "/deployments", + ): + return _json( + { + "request_id": "req-1", + "output": { + "page_no": 1, + "page_size": 10, + "total": 1, + "deployments": [deployment], + }, + }, + ) + if request.method == "DELETE": + return _json( + { + "request_id": "req-2", + "output": {"status": "deleted"}, + }, + ) + return _json({"request_id": "req-3", "output": deployment}) + + client = _make_client(handler) + assert ( + client.deployments.create( + model="ft-model", + capacity=1, + ).deployed_model + == "dep-1" + ) + assert client.deployments.get("dep-1").model == "ft-model" + assert client.deployments.list().total_count == 1 + assert client.deployments.scale( + "dep-1", + capacity=2, + ).deployed_model + assert client.deployments.delete("dep-1").status == "deleted" + + +def test_smoke_rl_and_apps_and_agentstudio(): + def handler(request): + if request.url.path.endswith("/logs"): + return _json( + {"request_id": "req-1", "output": {"logs": "ok"}}, + ) + if request.url.path.startswith("/api/v1/apps/"): + return _json( + { + "request_id": "req-2", + "output": {"text": "app says hi", "session_id": "s"}, + }, + ) + return _json( + { + "request_id": "req-3", + "output": {"job_id": "ft-1", "status": "RUNNING"}, + }, + ) + + client = _make_client(handler, workspace="ws-1") + for name in ( + "register_functions", + "test_functions", + "upload_data", + ): + assert callable(getattr(client.rl, name)) + for name in ("create", "fetch", "wait", "list", "cancel", "logs"): + assert callable(getattr(client.rl.jobs, name)) + assert client.rl.jobs.fetch("ft-1").status == "RUNNING" + assert client.rl.jobs.logs("ft-1").content == "ok" + app = client.apps.completions.create(app_id="app-1", prompt="hi") + assert app.text == "app says hi" + from dashscope.agentstudio import Client as AgentStudioClient + + mounted = client.agentstudio + assert isinstance(mounted, AgentStudioClient) + mounted.close() + + +@pytest.mark.asyncio +async def test_smoke_async_surface(): + def handler(request): + if request.url.path == CHAT_PATH: + return _json(_chat_body()) + if request.url.path.startswith("/api/v1/apps/"): + return _json( + { + "request_id": "req-app", + "output": {"text": "hi"}, + }, + ) + if request.url.path.endswith("/systemone"): + return _json( + { + "request_id": "req-dec", + "model": "decision-model-preview", + "answers": {"q": {"type": "noul", "noul": 0.5}}, + }, + ) + if request.method == "POST": + return _json( + { + "request_id": "req-sub", + "output": {"task_id": "t-1", "task_status": "PENDING"}, + }, + ) + if request.url.path.startswith("/api/v1/fine-tunes"): + return _json( + { + "request_id": "req-ft", + "output": {"job_id": "ft-1", "status": "SUCCEEDED"}, + }, + ) + return _json( + { + "request_id": "req-get", + "output": { + "task_id": "t-1", + "task_status": "SUCCEEDED", + "results": [{"url": "https://cdn.example.com/a.png"}], + }, + }, + ) + + client = _make_async_client(handler) + resp = await client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + ) + assert resp.output_text == "Hello" + decision = await client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert decision.answers["q"].noul == 0.5 + img = await client.images.generate( + model="wanx2.1-t2i", + prompt="a cat", + poll_interval=0.01, + ) + assert img.succeeded + app = await client.apps.completions.create(app_id="app-1", prompt="hi") + assert app.text == "hi" + job = await client.fine_tunes.get("ft-1") + assert job.job_id == "ft-1" + assert job.status == "SUCCEEDED" diff --git a/tests/v2/test_agentstudio_errors.py b/tests/v2/test_agentstudio_errors.py new file mode 100644 index 00000000..2c45e7cb --- /dev/null +++ b/tests/v2/test_agentstudio_errors.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the AgentStudio -> v2 error-alignment proxy (M3 3.12). + +Three things are pinned here: + +* :func:`translate_agentstudio_error` maps the 1.x AgentStudio family + onto the v2 family by *two* routes -- HTTP-status errors through + ``error_from_status`` and the four no-response local/stream errors + through ``error_from_sdk_exception``. +* The transparent proxy keeps ``isinstance`` / ``.transport`` working + while making every failure catchable as ``DashScopeError``. +* Lazily-failing return values -- event streams and cursor pages -- + translate errors raised during iteration and pagination, not just + errors raised by the call itself. +""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import httpx +import pytest + +from dashscope.agentstudio import AsyncClient as AsAgentStudioClient +from dashscope.agentstudio import Client as AgentStudioClient +from dashscope.agentstudio import exceptions as as_exc +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage +from dashscope.v2 import errors as v2_errors +from dashscope.v2.resources._agentstudio import ( + _wrap_result, + translate_agentstudio_error, + wrap_agentstudio, +) + +BASE_URL = "https://as.test.local" + + +# --------------------------------------------------------------------------- +# Two-branch translation, exercised directly (no network) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "as_cls,status,expected", + [ + (as_exc.InvalidRequestError, 400, v2_errors.BadRequestError), + (as_exc.AuthenticationError, 401, v2_errors.AuthenticationError), + (as_exc.PermissionDeniedError, 403, v2_errors.PermissionDeniedError), + (as_exc.NotFoundError, 404, v2_errors.NotFoundError), + (as_exc.RateLimitError, 429, v2_errors.RateLimitError), + (as_exc.InternalServerError, 500, v2_errors.InternalServerError), + (as_exc.OverloadedError, 503, v2_errors.ServiceUnavailableError), + # 409 has no catalog status entry -> generic APIStatusError, but + # still inside the v2 family and still carrying the real status. + (as_exc.ConflictError, 409, v2_errors.APIStatusError), + ], +) +def test_http_status_errors_map_through_status_map(as_cls, status, expected): + err = translate_agentstudio_error( + as_cls("boom", status_code=status, request_id="req-1"), + ) + assert isinstance(err, expected) + assert isinstance(err, v2_errors.DashScopeError) + assert err.status_code == status + assert err.request_id == "req-1" + + +@pytest.mark.parametrize( + "as_cls,code", + [ + (as_exc.APIConnectionError, "sdk.agentstudio.APIConnectionError"), + (as_exc.APITimeoutError, "sdk.agentstudio.APITimeoutError"), + (as_exc.StreamError, "sdk.agentstudio.StreamError"), + (as_exc.StreamClosedError, "sdk.agentstudio.StreamClosedError"), + ], +) +def test_local_and_stream_errors_map_to_sdk_error(as_cls, code): + err = translate_agentstudio_error(as_cls("boom")) + assert isinstance(err, v2_errors.SdkError) + assert err.code == code + assert err.status_code is None + + +def test_unregistered_base_class_does_not_keyerror(): + # The bare base class has no catalog entry and there is deliberately + # no ``sdk.agentstudio.Error`` fallback; translation must degrade + # gracefully instead of raising KeyError and masking the original. + err = translate_agentstudio_error(as_exc.AgentStudioError("boom")) + assert isinstance(err, v2_errors.DashScopeError) + assert isinstance(err, v2_errors.APIConnectionError) + + +def test_status_less_http_error_recovers_class_status(): + # The missing-api_key guard raises an HTTP-family error with no + # status_code; translation must still land on AuthenticationError + # rather than degrading to a 500 InternalServerError. + err = translate_agentstudio_error( + as_exc.AuthenticationError("api_key is required", code="auth"), + ) + assert isinstance(err, v2_errors.AuthenticationError) + assert err.status_code == 401 + + +# --------------------------------------------------------------------------- +# Transparent proxy over a real (mock-transport) client +# --------------------------------------------------------------------------- + + +def _make_proxy(handler, **kwargs): + kwargs.setdefault("max_retries", 0) + real = AgentStudioClient( + api_key="sk-test", + base_url=BASE_URL, + http_client=httpx.Client( + transport=httpx.MockTransport(handler), + base_url=BASE_URL, + ), + **kwargs, + ) + return wrap_agentstudio(real) + + +def test_proxy_is_transparent(): + proxy = _make_proxy(lambda request: httpx.Response(200, json={})) + assert isinstance(proxy, AgentStudioClient) + assert hasattr(proxy, "agents") + assert hasattr(proxy, "sessions") + assert proxy.transport.base_url == BASE_URL + proxy.close() + + +def test_proxy_translates_http_error_and_is_catchable_as_v2(): + def handler(request): + return httpx.Response( + 404, + json={ + "type": "error", + "error": { + "code": "not_found_error", + "message": "Session not found", + }, + "request_id": "req-404", + }, + ) + + proxy = _make_proxy(handler) + with pytest.raises(v2_errors.DashScopeError) as ei: + proxy.sessions.retrieve("s-1") + assert isinstance(ei.value, v2_errors.NotFoundError) + assert ei.value.status_code == 404 + assert ei.value.request_id == "req-404" + # The original 1.x error is preserved as the cause. + assert isinstance(ei.value.__cause__, as_exc.NotFoundError) + proxy.close() + + +def test_proxy_translates_rate_limit_error(): + proxy = _make_proxy(lambda request: httpx.Response(429, json={})) + with pytest.raises(v2_errors.RateLimitError): + proxy.sessions.retrieve("s-1") + proxy.close() + + +def test_proxy_translates_connection_error_to_sdk_error(): + def handler(request): + raise httpx.ConnectError("boom") + + proxy = _make_proxy(handler) + with pytest.raises(v2_errors.SdkError) as ei: + proxy.sessions.retrieve("s-1") + assert ei.value.code == "sdk.agentstudio.APIConnectionError" + proxy.close() + + +# --------------------------------------------------------------------------- +# Async proxy +# --------------------------------------------------------------------------- + + +def _make_async_proxy(handler, **kwargs): + kwargs.setdefault("max_retries", 0) + real = AsAgentStudioClient( + api_key="sk-test", + base_url=BASE_URL, + http_client=httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url=BASE_URL, + ), + **kwargs, + ) + return wrap_agentstudio(real) + + +@pytest.mark.asyncio +async def test_async_proxy_translates_http_error(): + def handler(request): + return httpx.Response( + 429, + json={ + "type": "error", + "error": { + "code": "rate_limit_error", + "message": "slow down", + }, + }, + ) + + proxy = _make_async_proxy(handler) + assert isinstance(proxy, AsAgentStudioClient) + with pytest.raises(v2_errors.RateLimitError): + await proxy.sessions.retrieve("s-1") + await proxy.aclose() + + +# --------------------------------------------------------------------------- +# Lazy stream errors surface during iteration, not at call time +# --------------------------------------------------------------------------- + + +class _FakeStream: + """CM + iterable that fails partway through, like a dropped SSE stream.""" + + def __enter__(self): + return self + + def __exit__(self, *exc): + return None + + def __iter__(self): + yield {"ok": 1} + raise as_exc.StreamClosedError("stream dropped") + + +def test_sync_stream_proxy_translates_iteration_error(): + stream = _wrap_result(_FakeStream()) + iterator = iter(stream) + assert next(iterator) == {"ok": 1} + with pytest.raises(v2_errors.SdkError) as ei: + next(iterator) + assert ei.value.code == "sdk.agentstudio.StreamClosedError" + + +# --------------------------------------------------------------------------- +# Cursor pages: the fetch_next closure captures the raw 1.x resource +# method, so pagination fetches bypass the method guards and the page +# proxy must translate errors raised while iterating / calling get_next() +# --------------------------------------------------------------------------- + + +def _sync_page(items, fetch_next): + return CursorPage( + data=items, + next_page="cursor-2", + request_id="req-p1", + fetch_next=fetch_next, + ) + + +def test_sync_page_proxy_translates_auto_pagination_error(): + def fetch_next(cursor): + raise as_exc.APIConnectionError("dropped mid-pagination") + + page = _wrap_result(_sync_page([{"id": "a-1"}], fetch_next)) + iterator = iter(page) + assert next(iterator) == {"id": "a-1"} + with pytest.raises(v2_errors.SdkError) as ei: + next(iterator) + assert ei.value.code == "sdk.agentstudio.APIConnectionError" + + +def test_sync_page_proxy_translates_get_next_error(): + def fetch_next(cursor): + raise as_exc.NotFoundError("gone", status_code=404, request_id="r-2") + + page = _wrap_result(_sync_page([], fetch_next)) + with pytest.raises(v2_errors.NotFoundError): + page.get_next() + + +def test_sync_page_proxy_stays_transparent_and_wraps_next_page(): + second = CursorPage( + data=[{"id": "a-2"}], + next_page=None, + request_id="req-p2", + ) + page = _wrap_result(_sync_page([{"id": "a-1"}], lambda cursor: second)) + assert isinstance(page, CursorPage) + assert len(page) == 1 + assert page.data == [{"id": "a-1"}] + assert page.has_next() + assert list(page) == [{"id": "a-1"}, {"id": "a-2"}] + page2 = page.get_next() + assert isinstance(page2, CursorPage) + assert list(page2) == [{"id": "a-2"}] + assert page2.get_next() is None + + +@pytest.mark.asyncio +async def test_async_page_proxy_translates_auto_pagination_error(): + async def fetch_next(cursor): + raise as_exc.APITimeoutError("timed out mid-pagination") + + page = _wrap_result( + AsyncCursorPage( + data=[{"id": "a-1"}], + next_page="cursor-2", + request_id="req-p1", + fetch_next=fetch_next, + ), + ) + seen = [] + with pytest.raises(v2_errors.SdkError) as ei: + async for item in page: + seen.append(item) + assert seen == [{"id": "a-1"}] + assert ei.value.code == "sdk.agentstudio.APITimeoutError" + + +@pytest.mark.asyncio +async def test_async_page_proxy_translates_get_next_error(): + async def fetch_next(cursor): + raise as_exc.RateLimitError("slow", status_code=429, request_id="r-2") + + page = _wrap_result( + AsyncCursorPage( + data=[], + next_page="cursor-2", + request_id="req-p1", + fetch_next=fetch_next, + ), + ) + with pytest.raises(v2_errors.RateLimitError): + await page.get_next() + + +def test_proxy_translates_pagination_fetch_error_end_to_end(): + # Page 2's fetch goes through the raw 1.x resource method captured in + # fetch_next -- without the page proxy this raises the 1.x error. + def handler(request): + if "page=cursor-2" in str(request.url): + return httpx.Response( + 404, + json={ + "type": "error", + "error": { + "code": "not_found_error", + "message": "no such page", + }, + "request_id": "req-p2", + }, + ) + return httpx.Response( + 200, + json={ + "data": [{"id": "a-1", "name": "demo", "model": "qwen-plus"}], + "next_page": "cursor-2", + "request_id": "req-p1", + }, + ) + + proxy = _make_proxy(handler) + page = proxy.agents.list() + assert isinstance(page, CursorPage) + with pytest.raises(v2_errors.NotFoundError): + list(page) + proxy.close() diff --git a/tests/v2/test_apps.py b/tests/v2/test_apps.py new file mode 100644 index 00000000..f5c08a87 --- /dev/null +++ b/tests/v2/test_apps.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.apps and the agentstudio mount (M3 3.8).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import RateLimitError + +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 _completion_body(text="Hello from app"): + return { + "request_id": "req-1", + "output": { + "text": text, + "finish_reason": "stop", + "session_id": "sess-1", + }, + "usage": {"models": [{"output_tokens": 5}]}, + } + + +def _chunks_sse(*texts): + return "".join( + f"id: {index}\ndata: {json.dumps(_completion_body(text))}\n\n" + for index, text in enumerate(texts, start=1) + ) + + +def test_create_non_streaming_builds_wire_body(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_completion_body()) + + client = _make_client(handler) + resp = client.apps.completions.create( + app_id="app-1", + prompt="hi", + session_id="sess-1", + temperature=0.2, + parameters={"flow_id": "flow-1"}, + ) + assert captured["path"] == "/api/v1/apps/app-1/completion" + body = captured["body"] + assert body["input"]["prompt"] == "hi" + assert body["input"]["session_id"] == "sess-1" + assert body["parameters"]["temperature"] == 0.2 + assert body["parameters"]["flow_id"] == "flow-1" + assert resp.text == "Hello from app" + assert resp.finish_reason == "stop" + assert resp.request_id == "req-1" + + +def test_create_messages_appends_prompt(): + def handler(request): + body = json.loads(request.content) + assert body["input"]["messages"] == [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second"}, + {"role": "user", "content": "next"}, + ] + return httpx.Response(200, json=_completion_body()) + + client = _make_client(handler) + client.apps.completions.create( + app_id="app-1", + prompt="next", + messages=[ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second"}, + ], + ) + + +def test_workflow_message_and_usage_models_parse(): + body = _completion_body() + body["output"]["workflow_message"] = { + "node_id": "node-1", + "node_name": "LLM", + "node_type": "llm", + "node_status": "running", + "node_is_completed": "false", + "node_msg_seq_id": 3, + "message": {"role": "assistant", "content": "partial"}, + } + body["usage"] = { + "models": [ + { + "model_id": "qwen-max", + "input_tokens": 7, + "output_tokens": 5, + }, + ], + } + + def handler(request): + return httpx.Response(200, json=body) + + client = _make_client(handler) + resp = client.apps.completions.create(app_id="app-1", prompt="hi") + message = resp.workflow_message + assert message.node_id == "node-1" + assert message.node_name == "LLM" + assert message.node_is_completed == "false" + assert message.node_msg_seq_id == 3 + assert message.message.role == "assistant" + assert message.message.content == "partial" + usage = resp.usage + assert usage.models[0].model_id == "qwen-max" + assert usage.models[0].input_tokens == 7 + assert usage.models[0].output_tokens == 5 + + +def test_create_requires_prompt_or_messages(): + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(ValueError): + client.apps.completions.create(app_id="app-1") + with pytest.raises(ValueError): + client.apps.completions.create(app_id="", prompt="hi") + + +def test_create_streaming_yields_chunks_and_raises_on_error(): + captured = {} + sse = ( + "id: 1\ndata: " + + json.dumps(_completion_body("Hel")) + + "\n\n" + + "id: 2\ndata: " + + json.dumps(_completion_body("Hello")) + + "\n\n" + + "id: 3\nevent:error\ndata: " + + json.dumps({"code": "Throttling", "message": "slow down"}) + + "\n\n" + ) + + def handler(request): + captured["sse"] = request.headers.get("X-DashScope-SSE") + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + chunks = [] + with pytest.raises(RateLimitError): + for chunk in stream: + chunks.append(chunk.text) + assert captured["sse"] == "enable" + assert chunks == ["Hel", "Hello"] + + +def test_agentstudio_mounted_lazily(): + from dashscope.agentstudio import Client as AgentStudioClient + + client = _make_client( + lambda request: httpx.Response(200, json={}), + workspace="ws-1", + ) + mounted = client.agentstudio + assert isinstance(mounted, AgentStudioClient) + assert client.agentstudio is mounted + assert hasattr(mounted, "agents") + assert hasattr(mounted, "sessions") + # No region configured -> the mount keeps the cn-beijing default. + assert mounted.transport.base_url == ( + "https://ws-1.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio" + ) + mounted.close() + + +def test_agentstudio_mount_follows_region(): + # agentstudio resolves its own base_url from workspace + region (the + # v2 base_url escape hatch does not propagate), so the v2 region must + # reach the mount instead of silently defaulting to cn-beijing. + client = _make_client( + lambda request: httpx.Response(200, json={}), + workspace="ws-1", + region="ap-southeast-1", + ) + mounted = client.agentstudio + assert mounted.transport.base_url == ( + "https://ws-1.ap-southeast-1.maas.aliyuncs.com/api/v1/agentstudio" + ) + mounted.close() + + +@pytest.mark.asyncio +async def test_async_agentstudio_mount_follows_region(): + from dashscope.agentstudio import AsyncClient as AsAgentStudioClient + + client = _make_async_client( + lambda request: httpx.Response(200, json={}), + workspace="ws-1", + region="eu-central-1", + ) + mounted = client.agentstudio + assert isinstance(mounted, AsAgentStudioClient) + assert mounted.transport.base_url == ( + "https://ws-1.eu-central-1.maas.aliyuncs.com/api/v1/agentstudio" + ) + await mounted.aclose() + + +@pytest.mark.asyncio +async def test_async_create_non_streaming(): + def handler(request): + assert request.url.path == "/api/v1/apps/app-1/completion" + return httpx.Response(200, json=_completion_body()) + + client = _make_async_client(handler) + resp = await client.apps.completions.create(app_id="app-1", prompt="hi") + assert resp.text == "Hello from app" + + +def _error_sse(): + return ( + "id: 1\ndata: " + + json.dumps(_completion_body("Hel")) + + "\n\n" + + "id: 2\nevent:error\ndata: " + + json.dumps( + { + "code": "Throttling", + "message": "slow down", + "request_id": "req-err", + }, + ) + + "\n\n" + ) + + +def test_create_streaming_yields_error_event_when_not_raising(): + def handler(request): + return httpx.Response( + 200, + content=_error_sse(), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, raise_on_error=False) + stream = client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + chunks = list(stream) + assert [c.text for c in chunks[:-1]] == ["Hel"] + error_event = chunks[-1] + assert error_event.is_error is True + assert error_event.code == "Throttling" + assert error_event.message == "slow down" + assert error_event.request_id == "req-err" + + +@pytest.mark.asyncio +async def test_async_create_streaming_yields_error_event_when_not_raising(): + def handler(request): + return httpx.Response( + 200, + content=_error_sse(), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler, raise_on_error=False) + stream = await client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + chunks = [chunk async for chunk in stream] + assert [c.text for c in chunks[:-1]] == ["Hel"] + assert chunks[-1].is_error is True + assert chunks[-1].code == "Throttling" + + +def test_close_closes_mounted_agentstudio(): + client = _make_client( + lambda request: httpx.Response(200, json={}), + workspace="ws-1", + ) + mounted = client.agentstudio + assert mounted.transport.is_closed is False + client.close() + assert mounted.transport.is_closed is True + assert client._agentstudio is None # pylint: disable=protected-access + + +@pytest.mark.asyncio +async def test_aclose_closes_mounted_agentstudio(): + client = _make_async_client( + lambda request: httpx.Response(200, json={}), + workspace="ws-1", + ) + mounted = client.agentstudio + assert mounted.transport.is_closed is False + await client.aclose() + assert mounted.transport.is_closed is True + assert client._agentstudio is None # pylint: disable=protected-access + + +@pytest.mark.asyncio +async def test_async_create_streaming(): + def handler(request): + return httpx.Response( + 200, + content=_chunks_sse("one", "two"), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + stream = await client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + texts = [chunk.text async for chunk in stream] + assert texts == ["one", "two"] + + +def test_create_stream_text_stream(): + def handler(request): + return httpx.Response( + 200, + content=_chunks_sse("Hel", "Hello"), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + assert list(stream.text_stream) == ["Hel", "Hello"] + + +@pytest.mark.asyncio +async def test_async_create_stream_text_stream(): + def handler(request): + return httpx.Response( + 200, + content=_chunks_sse("one", "two"), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + stream = await client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + texts = [text async for text in stream.text_stream] + assert texts == ["one", "two"] + + +def test_create_streaming_carries_raw_response_headers(): + # The SSE envelope is a single HTTP response; its headers sit on the + # stream wrapper (ChatStream parity), not on the chunks. + def handler(request): + return httpx.Response( + 200, + content=_chunks_sse("Hel", "Hello"), + headers={ + "content-type": "text/event-stream", + "x-request-id": "req-s", + "x-gw-region": "cn-hz", + }, + ) + + client = _make_client(handler) + stream = client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + assert stream.headers["x-request-id"] == "req-s" + assert stream.headers["x-gw-region"] == "cn-hz" + assert [chunk.text for chunk in stream] == ["Hel", "Hello"] + + +@pytest.mark.asyncio +async def test_async_create_streaming_carries_raw_response_headers(): + def handler(request): + return httpx.Response( + 200, + content=_chunks_sse("one", "two"), + headers={ + "content-type": "text/event-stream", + "x-request-id": "req-s", + "x-gw-region": "cn-hz", + }, + ) + + client = _make_async_client(handler) + stream = await client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + assert stream.headers["x-request-id"] == "req-s" + assert stream.headers["x-gw-region"] == "cn-hz" + assert [chunk.text async for chunk in stream] == ["one", "two"] + + +def test_create_streaming_error_event_carries_raw_response_headers(): + # Non-raising mode: the terminal error event is an envelope-level + # response, so it mirrors the gateway headers like the non-streaming + # compat error path does. + def handler(request): + return httpx.Response( + 200, + content=_error_sse(), + headers={ + "content-type": "text/event-stream", + "x-request-id": "req-err-h", + }, + ) + + client = _make_client(handler, raise_on_error=False) + stream = client.apps.completions.create( + app_id="app-1", + prompt="hi", + stream=True, + ) + error_event = list(stream)[-1] + assert error_event.is_error is True + assert error_event.headers["x-request-id"] == "req-err-h" diff --git a/tests/v2/test_audio.py b/tests/v2/test_audio.py new file mode 100644 index 00000000..9702786f --- /dev/null +++ b/tests/v2/test_audio.py @@ -0,0 +1,691 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.audio.asr (transcription + recognition, M3 3.4).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import DashScopeError +from dashscope.v2.resources.audio._callbacks import run_with_callback +from dashscope.v2.resources.audio.asr import RecognitionResult + +BASE_URL = "https://api.test.local/api/v1" +TRANSCRIPTION_PATH = "/api/v1/services/audio/asr/transcription" + + +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_bare_client(**kwargs): + kwargs.setdefault("base_url", BASE_URL) + # WsSession derives the websocket endpoint from the workspace (the + # base_url escape hatch only covers HTTP), so the WS-based audio tests + # need a workspace too. + kwargs.setdefault("workspace", "ws-test") + return Client(api_key="sk-test", **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 _task_body(status, output_extra=None): + output = {"task_id": "task-1", "task_status": status} + output.update(output_extra or {}) + return {"request_id": "req-1", "output": output} + + +class FakeWebSocket: + """Scripted websocket stub: records sends, replays a queued inbox.""" + + def __init__(self, inbox=None): + self.sent = [] + self.inbox = list(inbox or []) + self.closed = False + + def send(self, payload, opcode=1): + assert opcode == (2 if isinstance(payload, bytes) else 1) + self.sent.append(payload) + + def recv(self): + if not self.inbox: + return None + return self.inbox.pop(0) + + def close(self): + self.closed = True + + def settimeout(self, seconds): + pass + + +def _event(name, payload=None): + return json.dumps( + { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload or {}, + }, + ) + + +def _sent_frames(ws): + return [json.loads(item) for item in ws.sent if isinstance(item, str)] + + +def test_transcription_transcribe_submit_and_wait(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + { + "results": [ + { + "transcription_url": ( + "https://cdn.example.com/t.json" + ), + }, + ], + }, + ), + ) + + client = _make_client(handler) + job = client.audio.asr.transcribe( + ["https://example.com/a.wav"], + model="paraformer-v2", + parameters={"language_hints": ["zh"]}, + ) + assert captured["path"] == TRANSCRIPTION_PATH + assert captured["body"] == { + "model": "paraformer-v2", + "input": {"file_urls": ["https://example.com/a.wav"]}, + "parameters": {"language_hints": ["zh"]}, + } + assert job.succeeded is True + assert isinstance(job.output, dict) + assert job.output["results"][0]["transcription_url"] + + +def test_transcription_requires_file_urls(): + client = _make_bare_client() + with pytest.raises(ValueError, match="file_urls"): + client.audio.asr.jobs.create( + model="paraformer-v2", + file_urls=[], + ) + + +def test_transcription_threads_per_call_auth(): + # Submit and poll must both carry the override, or a job created + # under a per-call credential cannot be waited on with it. + requests = [] + + def handler(request): + requests.append(request) + if request.method == "POST": + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + client = _make_client(handler) + job = client.audio.asr.transcribe( + ["https://example.com/a.wav"], + model="paraformer-v2", + poll_interval=0, + api_key="sk-override", + workspace="ws-9", + ) + assert job.succeeded is True + assert [request.method for request in requests] == ["POST", "GET"] + for request in requests: + assert request.headers["Authorization"] == "Bearer sk-override" + assert request.headers["X-DashScope-WorkSpace"] == "ws-9" + + +@pytest.mark.asyncio +async def test_async_transcription_transcribe(): + def handler(request): + if request.method == "POST": + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + client = _make_async_client(handler) + job = await client.audio.asr.transcribe( + ["https://example.com/a.wav"], + ) + assert job.succeeded is True + + +def test_recognition_session_lifecycle(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + { + "output": { + "sentence": {"text": "你好", "end_time": 900}, + }, + }, + ), + _event("task-finished"), + ], + ) + captured = {} + + def connector(url, headers): + captured["url"] = url + captured["headers"] = headers + return ws + + client = _make_bare_client() + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + format="pcm", + sample_rate=16000, + connector=connector, + ) as rec: + assert captured["url"].startswith( + "wss://ws-test.cn-beijing.maas.aliyuncs.com/", + ) + assert captured["headers"]["Authorization"] == "Bearer sk-test" + rec.send_audio_frame(b"\x00\x01pcm") + rec.stop() + events = list(rec.events()) + + frames = _sent_frames(ws) + assert frames[0]["header"]["action"] == "run-task" + payload = frames[0]["payload"] + assert payload["task_group"] == "audio" + assert payload["task"] == "asr" + assert payload["function"] == "recognition" + assert payload["model"] == "paraformer-realtime-v2" + assert payload["parameters"] == {"format": "pcm", "sample_rate": 16000} + assert payload["input"] == {} + assert b"\x00\x01pcm" in ws.sent + assert frames[-1]["header"]["action"] == "finish-task" + assert [e["header"]["event"] for e in events] == [ + "result-generated", + "task-finished", + ] + assert ws.closed is True + + +def test_recognition_raises_on_task_failed(): + ws = FakeWebSocket( + [ + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": "t", + "error_code": "InvalidParameter", + "error_message": "bad", + }, + "payload": {}, + }, + ), + ], + ) + client = _make_bare_client() + with pytest.raises(DashScopeError, match="bad"): + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + connector=lambda url, headers: ws, + ): + pass + assert ws.closed + + +def test_recognition_context_parameters_headers_and_metrics(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + { + "output": {"sentence": {"heartbeat": True}}, + }, + ), + _event( + "result-generated", + { + "output": {"sentence": {"text": "你好", "end_time": 0}}, + "usage": {"duration": 1}, + }, + ), + _event("task-finished", {"usage": {"duration": 2}}), + ], + ) + captured = {} + + def connector(url, headers): + captured.update(headers) + return ws + + initial_input = {"context": {"text": "previous turn"}} + context = {"context": {"text": "next turn"}} + with _make_bare_client().audio.asr.recognize( + model="fun-asr-realtime", + input=initial_input, + phrase_id="phrase-1", + parameters={"semantic_punctuation_enabled": False}, + headers={"x-custom": "value"}, + connector=connector, + ) as rec: + with pytest.raises(RuntimeError, match="already open"): + rec.start() + rec.send_audio_frame(b"first") + events = rec.events() + assert next(events)["payload"]["output"]["sentence"]["heartbeat"] + assert rec.first_package_delay is None + assert next(events)["payload"]["output"]["sentence"]["end_time"] == 0 + assert rec.first_package_delay >= 0 + assert rec.usage == {"duration": 1} + rec.update_context(context) + rec.send_audio_frame(b"second") + rec.stop() + rec.stop() + with pytest.raises(RuntimeError, match="input is finished"): + rec.send_audio_frame(b"late") + with pytest.raises(RuntimeError, match="input is finished"): + rec.update_context(context) + assert next(events)["header"]["event"] == "task-finished" + assert list(events) == [] + assert rec.last_package_delay >= 0 + assert rec.usage == {"duration": 2} + assert rec.request_id == "r" + assert rec.last_response["header"]["event"] == "task-finished" + assert captured["x-custom"] == "value" + frames = _sent_frames(ws) + assert frames[0]["payload"]["input"] == initial_input + assert frames[0]["payload"]["resources"] == [ + {"resource_id": "phrase-1", "resource_type": "asr_phrase"}, + ] + assert ( + frames[0]["payload"]["parameters"]["semantic_punctuation_enabled"] + is False + ) + assert frames[1]["payload"] == {"input": context} + assert [frame["header"]["action"] for frame in frames] == [ + "run-task", + "continue-task", + "finish-task", + ] + assert ws.sent[1] == b"first" and ws.sent[3] == b"second" + assert ws.closed + + +@pytest.mark.parametrize("frame", [None, "", "{}", _event("result-generated")]) +def test_recognition_start_failure_closes_socket(frame): + ws = FakeWebSocket([frame]) + rec = _make_bare_client().audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) + with pytest.raises(DashScopeError): + rec.start() + assert ws.closed + with pytest.raises(RuntimeError, match="not open"): + rec.send_audio_frame(b"audio") + + +@pytest.mark.parametrize("during_start", [True, False]) +def test_recognition_timeout_closes_socket(during_start): + from websocket import WebSocketTimeoutException + + class TimeoutSocket(FakeWebSocket): + def recv(self): + if self.inbox: + return super().recv() + raise WebSocketTimeoutException("timed out") + + ws = TimeoutSocket([] if during_start else [_event("task-started")]) + rec = _make_bare_client().audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) + with pytest.raises(WebSocketTimeoutException): + with rec: + next(rec.events()) + assert ws.closed + + +def test_recognition_send_failure_closes_socket(): + class BrokenSocket(FakeWebSocket): + def send(self, payload, opcode=1): + if opcode == 2: + raise OSError("send failed") + super().send(payload, opcode=opcode) + + ws = BrokenSocket([_event("task-started")]) + with _make_bare_client().audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + with pytest.raises(OSError, match="send failed"): + rec.send_audio_frame(b"audio") + assert ws.closed + with pytest.raises(RuntimeError): + rec.send_audio_frame(b"more") + + +@pytest.mark.parametrize("timeout", [None, 1.5]) +def test_recognition_timeout_applies_to_handshake(monkeypatch, timeout): + import websocket + + ws = FakeWebSocket([_event("task-started")]) + captured = {} + + def connect(url, **kwargs): + captured.update(kwargs) + return ws + + monkeypatch.setattr(websocket, "create_connection", connect) + with _make_bare_client(timeout=7).audio.asr.recognize( + model="fun-asr-realtime", + timeout=timeout, + ): + pass + assert captured["timeout"] == (7 if timeout is None else timeout) + assert ws.closed + + +def test_transcription_phrase_resource_is_not_a_parameter(): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + assert request.headers["X-DashScope-Async"] == "enable" + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + with _make_client(handler) as client: + job = client.audio.asr.transcribe( + ["https://example.com/a.wav"], + phrase_id="phrase-1", + parameters={"diarization_enabled": False, "channel_id": [0]}, + ) + assert job.succeeded + assert bodies[0]["resources"] == [ + {"resource_id": "phrase-1", "resource_type": "asr_phrase"}, + ] + assert bodies[0]["parameters"] == { + "diarization_enabled": False, + "channel_id": [0], + } + + +@pytest.mark.asyncio +async def test_async_transcription_phrase_resource(): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + async with _make_async_client(handler) as client: + job = await client.audio.asr.transcribe( + ["https://example.com/a.wav"], + phrase_id="phrase-2", + ) + assert job.succeeded + assert bodies[0]["resources"] == [ + {"resource_id": "phrase-2", "resource_type": "asr_phrase"}, + ] + + +def test_recognition_named_parameters_merge_into_run_task(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event("task-finished"), + ], + ) + client = _make_bare_client() + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + vocabulary_id="vocab-1", + disfluency_removal_enabled=True, + diarization_enabled=False, + # Named kwargs win over the escape hatch. + parameters={"diarization_enabled": True, "speaker_count": 2}, + connector=lambda url, headers: ws, + ) as rec: + rec.stop() + list(rec.events()) + + frames = _sent_frames(ws) + parameters = frames[0]["payload"]["parameters"] + assert parameters["format"] == "pcm" + assert parameters["sample_rate"] == 16000 + assert parameters["vocabulary_id"] == "vocab-1" + assert parameters["disfluency_removal_enabled"] is True + assert parameters["diarization_enabled"] is False + # Untouched escape-hatch keys pass through. + assert parameters["speaker_count"] == 2 + + +def test_recognition_results_yield_typed_results(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + {"output": {"sentence": {"text": "你"}}}, + ), + _event( + "result-generated", + { + "output": { + "sentence": {"text": "你好", "end_time": 900}, + }, + "usage": {"duration": 1}, + }, + ), + # Heartbeats are dropped before reaching the caller. + _event( + "result-generated", + {"output": {"sentence": {"heartbeat": True}}}, + ), + _event("task-finished"), + ], + ) + client = _make_bare_client() + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + connector=lambda url, headers: ws, + ) as rec: + rec.stop() + results = list(rec.results()) + + assert len(results) == 2 + partial, final = results + assert isinstance(partial, RecognitionResult) + assert partial.get_sentence() == {"text": "你"} + assert partial.is_sentence_end() is False + assert partial.get_usage() is None + assert final.get_sentence() == {"text": "你好", "end_time": 900} + assert final.is_sentence_end() is True + assert final.get_usage() == {"duration": 1} + # request_id rides the event header onto the typed result. + assert final.request_id == "r" + + +def test_asr_recognize_file_chunks_and_completes(tmp_path): + # 1.x reads 12800 bytes per frame (recognition.py:519): two full + # chunks plus a 100-byte tail. + path = tmp_path / "sample.pcm" + path.write_bytes(b"\x01" * (12800 * 2 + 100)) + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + {"output": {"sentence": {"text": "你好", "end_time": 900}}}, + ), + _event("task-finished"), + ], + ) + client = _make_bare_client() + events = list( + client.audio.asr.recognize_file( + str(path), + model="paraformer-realtime-v2", + connector=lambda url, headers: ws, + ), + ) + + binary_frames = [item for item in ws.sent if isinstance(item, bytes)] + assert [len(frame) for frame in binary_frames] == [12800, 12800, 100] + frames = _sent_frames(ws) + assert frames[0]["header"]["action"] == "run-task" + assert frames[-1]["header"]["action"] == "finish-task" + assert [e["header"]["event"] for e in events] == [ + "result-generated", + "task-finished", + ] + assert ws.closed is True + + +def test_asr_recognize_file_validates_path(tmp_path): + client = _make_bare_client() + with pytest.raises(ValueError, match="not found"): + client.audio.asr.recognize_file( + str(tmp_path / "missing.pcm"), + model="paraformer-realtime-v2", + connector=lambda url, headers: FakeWebSocket(), + ) + empty = tmp_path / "empty.pcm" + empty.write_bytes(b"") + with pytest.raises(ValueError, match="empty"): + client.audio.asr.recognize_file( + str(empty), + model="paraformer-realtime-v2", + connector=lambda url, headers: FakeWebSocket(), + ) + + +class RecordingCallback: + """1.x-style callback stub recording every invocation.""" + + def __init__(self): + self.calls = [] + + def on_open(self): + self.calls.append(("on_open",)) + + def on_event(self, event): + self.calls.append(("on_event", event)) + + def on_complete(self): + self.calls.append(("on_complete",)) + + def on_error(self, error): + self.calls.append(("on_error", error)) + + def on_close(self): + self.calls.append(("on_close",)) + + +def test_run_with_callback_recognition_invokes_methods_in_order(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + {"output": {"sentence": {"text": "你好", "end_time": 900}}}, + ), + _event("task-finished"), + ], + ) + client = _make_bare_client() + callback = RecordingCallback() + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + connector=lambda url, headers: ws, + ) as rec: + rec.stop() + run_with_callback(rec.results(), callback) + + kinds = [call[0] for call in callback.calls] + assert kinds == ["on_open", "on_event", "on_complete", "on_close"] + # Pairing with results() gives the callback typed results, like 1.x. + event = callback.calls[1][1] + assert isinstance(event, RecognitionResult) + assert event.is_sentence_end() is True + + +def test_run_with_callback_maps_task_finished_to_on_complete(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event("task-finished"), + ], + ) + client = _make_bare_client() + callback = RecordingCallback() + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + connector=lambda url, headers: ws, + ) as rec: + rec.stop() + run_with_callback(rec.events(), callback) + + # The terminal event never reaches on_event. + assert [call[0] for call in callback.calls] == [ + "on_open", + "on_complete", + "on_close", + ] + + +def test_run_with_callback_delivers_errors_via_on_error(): + ws = FakeWebSocket( + [ + _event("task-started"), + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": "t", + "error_code": "InvalidParameter", + "error_message": "bad", + }, + "payload": {}, + }, + ), + ], + ) + client = _make_bare_client() + callback = RecordingCallback() + with client.audio.asr.recognize( + model="paraformer-realtime-v2", + connector=lambda url, headers: ws, + ) as rec: + rec.stop() + # 1.x callback mode reports through on_error instead of raising. + run_with_callback(rec.events(), callback) + + kinds = [call[0] for call in callback.calls] + assert kinds == ["on_open", "on_error", "on_close"] diff --git a/tests/v2/test_audio_connection.py b/tests/v2/test_audio_connection.py new file mode 100644 index 00000000..d93a593a --- /dev/null +++ b/tests/v2/test_audio_connection.py @@ -0,0 +1,271 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Resource sessions running as tasks on a shared WsConnection.""" + +# pylint: disable=protected-access + +import json +import queue + +import pytest + +from dashscope.v2.client import Client + +BASE_URL = "https://api.test.local/api/v1" +WS_URL = "wss://ws.test.local/api-ws/v1/inference" + + +class AutoWebSocket: + """Blocking stub that answers run-task/finish-task automatically.""" + + def __init__(self): + self.sent = [] + self.inbox = queue.SimpleQueue() + self.closed = False + + def send(self, payload, opcode=1): + assert opcode == (2 if isinstance(payload, bytes) else 1) + self.sent.append(payload) + if isinstance(payload, str): + header = json.loads(payload)["header"] + action = header.get("action") + if action == "run-task": + self.inbox.put(self._event("task-started", header["task_id"])) + elif action == "finish-task": + self.inbox.put(self._event("task-finished", header["task_id"])) + + @staticmethod + def _event(name, task_id, payload=None): + return json.dumps( + { + "header": { + "event": name, + "task_id": task_id, + "request_id": "r", + }, + "payload": payload or {}, + }, + ) + + def recv(self): + item = self.inbox.get(timeout=5) + if isinstance(item, BaseException): + raise item + return item + + def settimeout(self, seconds): + pass + + def close(self): + self.closed = True + self.inbox.put(None) + + +def _make_client_and_connection(): + connects = [] + + def connector(url, headers): + ws = AutoWebSocket() + connects.append({"url": url, "headers": headers, "ws": ws}) + return ws + + client = Client(api_key="sk-test", base_url=BASE_URL) + conn = client.ws_connection( + url=WS_URL, + connector=connector, + timeout=5, + ) + return client, conn, connects + + +def _run_task_frames(ws): + return [ + json.loads(item) + for item in ws.sent + if isinstance(item, str) + and json.loads(item)["header"].get("action") == "run-task" + ] + + +def test_ws_connection_factory_uses_client_config(): + client, conn, connects = _make_client_and_connection() + assert connects[0]["url"] == WS_URL + assert connects[0]["headers"]["Authorization"] == "Bearer sk-test" + conn.close() + client.close() + + +def test_recognition_sequential_reuse_on_shared_connection(): + client, conn, connects = _make_client_and_connection() + ws = connects[0]["ws"] + + for _ in range(2): + rec = client.audio.asr.recognize( + model="paraformer-realtime-v2", + connection=conn, + ) + rec.start() + rec.send_audio_frame(b"\x00\x01") + rec.stop() + list(rec.events()) + rec.close() + assert not conn.closed + + assert len(connects) == 1 + run_tasks = _run_task_frames(ws) + assert len(run_tasks) == 2 + assert ( + run_tasks[0]["header"]["task_id"] != run_tasks[1]["header"]["task_id"] + ) + # Exactly one binary frame per recognition, both on the shared socket. + assert len([item for item in ws.sent if isinstance(item, bytes)]) == 2 + conn.close() + client.close() + + +def test_recognition_and_synthesis_concurrently_on_one_connection(): + client, conn, connects = _make_client_and_connection() + ws = connects[0]["ws"] + + rec = client.audio.asr.recognize( + model="paraformer-realtime-v2", + connection=conn, + ) + rec.start() + tts = client.audio.tts.connect(model="cosyvoice-v1", connection=conn) + tts.start() + + # ASR owns the binary uplink, TTS the binary downlink. + rec.send_audio_frame(b"\x02\x03") + tts.send_text("你好") + ws.inbox.put(b"synthesized-audio") + ws.inbox.put( + AutoWebSocket._event( + "result-generated", + rec.session.task_id, + {"output": {"sentence": {"text": "hello"}}}, + ), + ) + chunks = [] + for frame in tts.events(): + if isinstance(frame, bytes): + chunks.append(frame) + # task-finished arrives from stop(); stop after the first chunk. + tts.stop() + assert chunks == [b"synthesized-audio"] + event = rec.session.recv_event() + assert event["payload"]["output"]["sentence"]["text"] == "hello" + + tts.close() + rec.close() + assert not conn.closed + conn.close() + assert len(connects) == 1 + client.close() + + +def test_two_syntheses_on_one_connection_fail_fast(): + client, conn, _ = _make_client_and_connection() + tts1 = client.audio.tts.connect(model="cosyvoice-v1", connection=conn) + tts1.start() + tts2 = client.audio.tts.connect(model="cosyvoice-v1", connection=conn) + with pytest.raises(RuntimeError, match="binary downlink"): + tts2.start() + # The first session is unaffected. + tts1.send_text("继续") + tts1.close() + conn.close() + client.close() + + +def test_tingwu_realtime_on_shared_connection(): + client, conn, connects = _make_client_and_connection() + ws = connects[0]["ws"] + + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="app-1", + connection=conn, + ) as session: + session.send_audio_frame(b"\x04\x05") + assert not conn.closed + + run_tasks = _run_task_frames(ws) + assert len(run_tasks) == 1 + assert run_tasks[0]["payload"]["task_group"] == "aigc" + conn.close() + assert len(connects) == 1 + client.close() + + +def test_recognition_close_detaches_but_connection_survives_errors(): + client, conn, connects = _make_client_and_connection() + ws = connects[0]["ws"] + rec1 = client.audio.asr.recognize( + model="paraformer-realtime-v2", + connection=conn, + ) + rec1.start() + rec1.close() + + # A second recognition still opens on the same socket afterwards. + rec2 = client.audio.asr.recognize( + model="paraformer-realtime-v2", + connection=conn, + ) + rec2.start() + rec2.stop() + list(rec2.events()) + rec2.close() + assert len(connects) == 1 + assert not ws.closed + conn.close() + assert ws.closed + client.close() + + +class _RealtimeWebSocket: + """Minimal realtime stub: one socket per session, no task framing.""" + + def __init__(self): + self.sent = [] + self.closed = False + + def send(self, payload): + self.sent.append(payload) + + def recv(self): + return None + + def settimeout(self, seconds): + pass + + def close(self): + self.closed = True + + +def test_realtime_opens_one_socket_per_session(): + """The realtime protocol cannot share a socket (no task id; the + connection closes with the session), so sequential sessions each + perform their own handshake -- reuse only exists via WsConnection + on the run-task family.""" + client = Client(api_key="sk-test", base_url=BASE_URL) + sockets = [] + + def connector(url, headers): + ws = _RealtimeWebSocket() + sockets.append({"url": url, "ws": ws}) + return ws + + for _ in range(2): + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + ws_url="wss://ws.test.local/api-ws/v1/realtime", + connector=connector, + timeout=5, + ) as session: + session.send_event({"type": "session.update", "session": {}}) + assert len(sockets) == 2 + assert sockets[0]["ws"].closed + assert sockets[1]["ws"].closed + client.close() diff --git a/tests/v2/test_audio_management.py b/tests/v2/test_audio_management.py new file mode 100644 index 00000000..f9880751 --- /dev/null +++ b/tests/v2/test_audio_management.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the audio management plane (M5). + +``client.audio.vocabularies`` / ``phrases`` / ``voices`` / +``voice_design`` -- 1.x ``VocabularyService`` / ``AsrPhraseManager`` / +``VoiceEnrollmentService`` plus the qwen voice-design REST family. +""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import Client +from dashscope.v2.errors import BadRequestError + +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) + + +# ── vocabularies (1.x VocabularyService) ────────────────────────────── + + +def test_vocabularies_crud_wire(): + calls = [] + + def handler(request): + body = json.loads(request.content.decode()) + calls.append((request.url.path, body)) + action = body["input"]["action"] + if action == "create_vocabulary": + return httpx.Response( + 200, + json={ + "request_id": "req-v1", + "output": {"vocabulary_id": "vocab-1"}, + }, + ) + if action == "list_vocabulary": + return httpx.Response( + 200, + json={ + "output": { + "vocabulary_list": [ + {"vocabulary_id": "vocab-1", "prefix": "med"}, + ], + }, + }, + ) + if action == "query_vocabulary": + return httpx.Response( + 200, + json={ + "output": { + "vocabulary_id": "vocab-1", + "vocabulary": [{"text": "心肌梗死", "weight": 4}], + }, + }, + ) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + created = client.audio.vocabularies.create( + target_model="paraformer-v2", + prefix="med", + vocabulary=[{"text": "心肌梗死", "weight": 4}], + ) + assert created.vocabulary_id == "vocab-1" + assert created.request_id == "req-v1" + + listed = client.audio.vocabularies.list(prefix="med", page_size=5) + assert listed.vocabularies == [ + {"vocabulary_id": "vocab-1", "prefix": "med"}, + ] + + fetched = client.audio.vocabularies.get("vocab-1") + assert fetched.vocabulary == [{"text": "心肌梗死", "weight": 4}] + + updated = client.audio.vocabularies.update( + "vocab-1", + vocabulary=[{"text": "脑梗死", "weight": 4}], + ) + assert not updated.is_error + deleted = client.audio.vocabularies.delete("vocab-1") + assert not deleted.is_error + + path, body = calls[0] + assert path == "/api/v1/services/audio/asr/customization" + assert body == { + "model": "speech-biasing", + "input": { + "action": "create_vocabulary", + "target_model": "paraformer-v2", + "prefix": "med", + "vocabulary": [{"text": "心肌梗死", "weight": 4}], + }, + } + assert calls[1][1]["input"] == { + "action": "list_vocabulary", + "page_index": 0, + "page_size": 5, + "prefix": "med", + } + assert calls[2][1]["input"] == { + "action": "query_vocabulary", + "vocabulary_id": "vocab-1", + } + assert calls[3][1]["input"] == { + "action": "update_vocabulary", + "vocabulary_id": "vocab-1", + "vocabulary": [{"text": "脑梗死", "weight": 4}], + } + assert calls[4][1]["input"] == { + "action": "delete_vocabulary", + "vocabulary_id": "vocab-1", + } + + +# ── voices (1.x VoiceEnrollmentService) ─────────────────────────────── + + +def test_voices_clone_create_and_crud(): + calls = [] + + def handler(request): + body = json.loads(request.content.decode()) + calls.append(body) + action = body["input"]["action"] + if action == "create_voice": + return httpx.Response( + 200, + json={ + "request_id": "req-clone", + "output": {"voice_id": "voice-1"}, + }, + ) + if action == "list_voice": + return httpx.Response( + 200, + json={ + "output": { + "voice_list": [ + {"voice_id": "voice-1", "status": "OK"}, + ], + }, + }, + ) + if action == "query_voice": + return httpx.Response( + 200, + json={ + "output": {"voice_id": "voice-1", "status": "OK"}, + }, + ) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + created = client.audio.voices.create( + target_model="cosyvoice-v3-plus", + prefix="demo", + url="https://cdn.test/sample.wav", + language_hints=["zh"], + extra_input={"url": "https://evil.test/override.wav", "extra": 1}, + ) + assert created.voice_id == "voice-1" + assert created.request_id == "req-clone" + + # Named arguments win over the extra_input escape hatch. + assert calls[0] == { + "model": "voice-enrollment", + "input": { + "action": "create_voice", + "extra": 1, + "target_model": "cosyvoice-v3-plus", + "prefix": "demo", + "url": "https://cdn.test/sample.wav", + "language_hints": ["zh"], + }, + } + + listed = client.audio.voices.list(prefix="demo") + assert listed.voices == [{"voice_id": "voice-1", "status": "OK"}] + assert calls[1]["input"] == { + "action": "list_voice", + "page_index": 0, + "page_size": 10, + "prefix": "demo", + } + + fetched = client.audio.voices.get("voice-1") + assert fetched.status == "OK" + assert calls[2]["input"] == { + "action": "query_voice", + "voice_id": "voice-1", + } + + client.audio.voices.update("voice-1", url="https://cdn.test/new.wav") + assert calls[3]["input"] == { + "action": "update_voice", + "voice_id": "voice-1", + "url": "https://cdn.test/new.wav", + } + client.audio.voices.delete("voice-1") + assert calls[4]["input"] == { + "action": "delete_voice", + "voice_id": "voice-1", + } + assert all(call["model"] == "voice-enrollment" for call in calls) + + +# ── voice design (声音设计, qwen-voice-design) ───────────────────────── + + +def test_voice_design_crud(): + calls = [] + + def handler(request): + body = json.loads(request.content.decode()) + calls.append(body) + action = body["input"]["action"] + if action == "create": + return httpx.Response( + 200, + json={ + "output": { + "voice": "announcer", + "preview_audio": { + "data": "QUJD", + "sample_rate": 24000, + "response_format": "wav", + }, + }, + "usage": {"count": 1}, + }, + ) + if action == "list": + return httpx.Response( + 200, + json={ + "output": { + "voice_list": [ + {"voice": "announcer", "language": "zh"}, + ], + "total_count": 1, + }, + }, + ) + if action == "query": + return httpx.Response( + 200, + json={ + "output": {"voice": "announcer", "language": "zh"}, + }, + ) + return httpx.Response( + 200, + json={"output": {"voice": "announcer"}}, + ) + + client = _make_client(handler) + created = client.audio.voice_design.create( + target_model="qwen3-tts-vd-realtime-2026-01-15", + voice_prompt="沉稳的中年男性,音色低沉浑厚", + preview_text="各位听众朋友,大家好", + preferred_name="announcer", + language="zh", + parameters={"sample_rate": 24000, "response_format": "wav"}, + ) + assert created.voice == "announcer" + assert created.preview_audio is not None + # pylint: disable-next=unsubscriptable-object # Optional narrowed above + assert created.preview_audio["response_format"] == "wav" + assert calls[0] == { + "model": "qwen-voice-design", + "input": { + "action": "create", + "target_model": "qwen3-tts-vd-realtime-2026-01-15", + "voice_prompt": "沉稳的中年男性,音色低沉浑厚", + "preview_text": "各位听众朋友,大家好", + "preferred_name": "announcer", + "language": "zh", + }, + "parameters": {"sample_rate": 24000, "response_format": "wav"}, + } + + listed = client.audio.voice_design.list() + assert listed.voices == [{"voice": "announcer", "language": "zh"}] + assert listed.total_count == 1 + assert calls[1]["input"] == { + "action": "list", + "page_index": 0, + "page_size": 10, + } + + fetched = client.audio.voice_design.get("announcer") + assert fetched.language == "zh" + assert calls[2]["input"] == {"action": "query", "voice": "announcer"} + + client.audio.voice_design.delete("announcer") + assert calls[3]["input"] == {"action": "delete", "voice": "announcer"} + assert all(call["model"] == "qwen-voice-design" for call in calls) + + +# ── phrases (1.x AsrPhraseManager, fine-tunes rider) ────────────────── + + +def test_phrases_create_and_update_ride_fine_tunes(): + calls = [] + + def handler(request): + calls.append((request.method, request.url.path, request.url.params)) + body = json.loads(request.content.decode()) + return httpx.Response( + 200, + json={ + "request_id": "req-p1", + "output": { + "job_id": "phrase-1", + "status": "PENDING", + "model": body["model"], + }, + }, + ) + + client = _make_client(handler) + created = client.audio.phrases.create( + model="paraformer-v2", + phrases={"下一首": 90, "上一首": 90}, + ) + assert created.phrase_id == "phrase-1" + assert created.status == "PENDING" + assert created.request_id == "req-p1" + + method, path, _ = calls[0] + assert (method, path) == ("POST", "/api/v1/fine-tunes") + # The create body was captured via the handler's own read; re-assert + # the wire shape by calling update and inspecting both bodies below. + + updated = client.audio.phrases.update( + "phrase-1", + model="paraformer-v2", + phrases={"暂停": 90}, + ) + assert updated.phrase_id == "phrase-1" + + +def test_phrases_create_wire_body(): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content.decode())) + return httpx.Response(200, json={"output": {"job_id": "p-1"}}) + + client = _make_client(handler) + client.audio.phrases.create( + model="paraformer-v2", + phrases={"下一首": 90}, + ) + client.audio.phrases.update( + "p-1", + model="paraformer-v2", + phrases={"暂停": 90}, + ) + assert bodies[0] == { + "model": "paraformer-v2", + "training_file_ids": [], + "validation_file_ids": [], + "training_type": "compile_asr_phrase", + "hyper_parameters": {"phrase_list": {"下一首": 90}}, + } + assert bodies[1] == { + "model": "paraformer-v2", + "training_file_ids": [], + "validation_file_ids": [], + "training_type": "compile_asr_phrase", + "hyper_parameters": {"phrase_list": {"暂停": 90}}, + "finetuned_output": "p-1", + } + + +def test_phrases_get_list_delete_hit_outputs(): + calls = [] + + def handler(request): + calls.append( + (request.method, request.url.path, dict(request.url.params)), + ) + if request.method == "GET" and request.url.path.endswith("/outputs"): + return httpx.Response( + 200, + json={ + "output": { + "jobs": [{"job_id": "p-1", "status": "SUCCEEDED"}], + "total": 1, + }, + }, + ) + if request.method == "GET": + return httpx.Response( + 200, + json={ + "output": { + "job_id": "p-1", + "status": "SUCCEEDED", + "hyper_parameters": { + "phrase_list": {"下一首": 90}, + }, + }, + }, + ) + return httpx.Response(200, json={"output": {"deleted": True}}) + + client = _make_client(handler) + listed = client.audio.phrases.list(page_no=2, page_size=20) + assert listed.phrases[0].phrase_id == "p-1" + assert listed.phrases[0].status == "SUCCEEDED" + assert listed.total_count == 1 + + fetched = client.audio.phrases.get("p-1") + assert fetched.phrase_id == "p-1" + assert fetched.status == "SUCCEEDED" + + deleted = client.audio.phrases.delete("p-1") + assert not deleted.is_error + + assert calls[0] == ( + "GET", + "/api/v1/fine-tunes/outputs", + {"page_no": "2", "page_size": "20"}, + ) + assert calls[1] == ("GET", "/api/v1/fine-tunes/outputs/p-1", {}) + assert calls[2] == ("DELETE", "/api/v1/fine-tunes/outputs/p-1", {}) + + +def test_phrases_local_validation(): + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(ValueError, match="phrases"): + client.audio.phrases.create(model="paraformer-v2", phrases={}) + with pytest.raises(ValueError, match="phrase_id"): + client.audio.phrases.update( + " ", + model="paraformer-v2", + phrases={"暂停": 90}, + ) + with pytest.raises(ValueError, match="phrase_id"): + client.audio.phrases.get("") + with pytest.raises(ValueError, match="phrase_id"): + client.audio.phrases.delete("") + + +def test_management_error_maps_through_catalog(): + def handler(request): + return httpx.Response( + 400, + json={"code": "InvalidParameter", "message": "bad prefix"}, + ) + + client = _make_client(handler) + with pytest.raises(BadRequestError, match="bad prefix"): + client.audio.vocabularies.create( + target_model="paraformer-v2", + prefix="BAD PREFIX", + vocabulary=[{"text": "x"}], + ) diff --git a/tests/v2/test_audio_omni_session.py b/tests/v2/test_audio_omni_session.py new file mode 100644 index 00000000..4eca4fea --- /dev/null +++ b/tests/v2/test_audio_omni_session.py @@ -0,0 +1,1881 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Omni realtime wire payloads and synchronous session lifecycle.""" + +from __future__ import annotations + +import copy +import json +import re +from urllib.parse import parse_qsl, urlsplit + +import pytest +from websocket import ( + WebSocketConnectionClosedException, + WebSocketTimeoutException, +) + +from dashscope.v2 import Client +from dashscope.v2.errors import ( + APIConnectionError, + BadRequestError, + RateLimitError, +) +from dashscope.v2.resources.audio import realtime +from dashscope.v2.transport._ident import sdk_headers + + +class FakeWebSocket: + def __init__(self, inbox=()): + self.inbox = list(inbox) + self.sent = [] + self.timeouts = [] + self.recv_calls = 0 + self.close_calls = 0 + self.send_error = None + self.timeout_error = None + self.close_error = None + + def send(self, payload): + if self.send_error is not None: + raise self.send_error + self.sent.append(payload) + + def recv(self): + self.recv_calls += 1 + raw = self.inbox.pop(0) if self.inbox else None + if isinstance(raw, BaseException): + raise raw # pylint: disable=raising-bad-type + return json.dumps(raw) if isinstance(raw, dict) else raw + + def settimeout(self, timeout): + self.timeouts.append(timeout) + if self.timeout_error is not None: + raise self.timeout_error + + def close(self): + self.close_calls += 1 + if self.close_error is not None: + raise self.close_error + + +@pytest.fixture +def client(): + with Client(api_key="sk-test", workspace="ws-test", timeout=12.5) as value: + yield value + + +@pytest.fixture +def opened(client): + ws = FakeWebSocket() + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + connector=lambda url, headers: ws, + ) as session: + yield session, ws + + +def _payloads(ws): + frames = [json.loads(raw) for raw in ws.sent] + event_ids = [frame.pop("event_id") for frame in frames] + assert all( + re.fullmatch(r"event_[0-9a-f]{32}", value) for value in event_ids + ) + assert len(set(event_ids)) == len(event_ids) + return frames + + +def _default_session(**overrides): + return { + "modalities": ["text", "audio"], + "voice": None, + "input_audio_format": "pcm16", + "output_audio_format": "pcm16", + "input_audio_transcription": {"model": None}, + "turn_detection": { + "type": "server_vad", + "threshold": 0.2, + "prefix_padding_ms": 300, + "silence_duration_ms": 800, + }, + **overrides, + } + + +def test_update_session_defaults(opened): + session, ws = opened + session.update_session(["text", "audio"]) + assert _payloads(ws) == [ + {"type": "session.update", "session": _default_session()}, + ] + + +def test_update_session_legacy_formats_and_zero_vad(opened): + session, ws = opened + session.update_session( + [], + voice="", + input_audio_format="g711_ulaw", + output_audio_format="custom-format", + input_audio_transcription_model="custom-asr", + turn_detection_type="semantic_vad", + prefix_padding_ms=0, + turn_detection_threshold=0, + turn_detection_silence_duration_ms=0, + turn_detection_param={ + "create_response": False, + "interrupt_response": False, + }, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": _default_session( + modalities=[], + voice="", + input_audio_format="g711_ulaw", + output_audio_format="custom-format", + input_audio_transcription={"model": "custom-asr"}, + turn_detection={ + "type": "semantic_vad", + "threshold": 0, + "prefix_padding_ms": 0, + "silence_duration_ms": 0, + "create_response": False, + "interrupt_response": False, + }, + ), + }, + ] + + +def test_update_session_disabled_features(opened): + session, ws = opened + session.update_session( + ["text", "audio"], + enable_turn_detection=False, + enable_input_audio_transcription=False, + turn_detection_param={"create_response": True}, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": _default_session( + turn_detection=None, + input_audio_transcription=None, + ), + }, + ] + + +def test_update_session_custom_vad_and_transcription(opened): + session, ws = opened + params = { + "language": "zh", + "sample_rate": 0, + "input_audio_format": "wav", + "corpus": {"text": "ignored"}, + "corpus_text": "", + "future_option": False, + } + translation = {"language": "en", "corpus": {"phrases": {"你好": "hello"}}} + vad = {"type": "custom_vad", "threshold": 0.8, "eagerness": "low"} + original = copy.deepcopy([params, translation, vad]) + session.update_session( + ["text", "audio"], + transcription_params=params, + translation_params=translation, + turn_detection_param=vad, + input_audio_transcription_model="replaced", + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": _default_session( + input_audio_format="wav", + sample_rate=0, + input_audio_transcription={ + "language": "zh", + "corpus": {"text": ""}, + "future_option": False, + }, + translation=translation, + turn_detection={ + "type": "custom_vad", + "threshold": 0.8, + "prefix_padding_ms": 300, + "silence_duration_ms": 800, + "eagerness": "low", + }, + ), + }, + ] + assert [params, translation, vad] == original + + +@pytest.mark.parametrize( + "params, expected", + [ + ({}, {}), + ({"language": None, "corpus": None}, {}), + ( + {"language": "en", "corpus": {"text": "terms"}}, + {"language": "en", "corpus": {"text": "terms"}}, + ), + ], +) +def test_transcription_dict_legacy_defaults(opened, params, expected): + session, ws = opened + session.update_session( + ["text", "audio"], + transcription_params=params, + enable_input_audio_transcription=False, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": _default_session( + input_audio_format="pcm", + sample_rate=16000, + input_audio_transcription=expected, + ), + }, + ] + + +@pytest.mark.parametrize( + "input_config, output_config", + [ + ({"type": "wav", "sample_rate": 48000}, None), + (None, {"type": "opus", "sample_rate": 24000, "speech_rate": 0}), + ({}, {}), + ({"type": "pcm", "sample_rate": 0}, {"type": "wav", "custom": False}), + ], +) +def test_nested_audio_configs_suppress_legacy_fields( + opened, + input_config, + output_config, +): + session, ws = opened + original = copy.deepcopy([input_config, output_config]) + session.update_session( + ["text", "audio"], + input_audio_config=input_config, + output_audio_config=output_config, + transcription_params={ + "language": "zh", + "sample_rate": 8000, + "input_audio_format": "ignored", + "corpus_text": "vocabulary", + }, + ) + expected = _default_session( + input_audio_transcription={ + "language": "zh", + "corpus": {"text": "vocabulary"}, + }, + ) + del expected["input_audio_format"] + del expected["output_audio_format"] + expected["audio"] = {} + if input_config is not None: + expected["audio"]["input"] = {"format": input_config} + if output_config is not None: + expected["audio"]["output"] = {"format": output_config} + assert _payloads(ws) == [{"type": "session.update", "session": expected}] + assert [input_config, output_config] == original + + +@pytest.mark.parametrize("compact", ["", "custom-representation"]) +def test_video_representation_compact(opened, compact): + session, ws = opened + session.update_session( + ["text", "audio"], + input_video_representation_compact=compact, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": _default_session( + video={ + "input": {"representation_compact": compact}, + }, + ), + }, + ] + + +def test_update_session_kwargs_override_and_do_not_leak_between_updates( + opened, +): + session, ws = opened + kwargs = { + "modalities": [], + "instructions": "", + "temperature": 0, + "future_option": False, + "turn_detection": {"type": "custom", "threshold": 0}, + "input_audio_transcription": {"model": "asr", "language": "en"}, + "audio": {"input": {"format": {"type": "mp3"}}}, + "video": {"input": {"representation_compact": "override"}}, + } + original = copy.deepcopy(kwargs) + session.update_session( + ["audio"], + input_audio_config={}, + input_video_representation_compact="original", + **kwargs, + ) + expected = _default_session(**kwargs) + del expected["input_audio_format"] + del expected["output_audio_format"] + session.update_session(["text", "audio"]) + assert _payloads(ws) == [ + {"type": "session.update", "session": expected}, + {"type": "session.update", "session": _default_session()}, + ] + assert kwargs == original + + +def test_all_helpers_exact_payloads(opened): + session, ws = opened + item = { + "id": "item-1", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "你好"}], + "future_option": False, + } + original = copy.deepcopy(item) + session.append_audio(audio_b64="AAH/") + session.append_audio(b"\x00\x01\xff") + session.append_video(video_b64="aW1hZ2U=") + session.append_video(b"image") + session.commit() + session.clear_appended_audio() + session.create_item(item) + session.delete_item("item-1") + session.create_response() + session.create_response(["text", "audio"], "Answer briefly") + session.create_response( + modalities=[], + instructions="", + temperature=0, + parallel_tool_calls=False, + metadata={"tag": "test"}, + ) + session.cancel_response() + session.end_session() + assert _payloads(ws) == [ + {"type": "input_audio_buffer.append", "audio": "AAH/"}, + {"type": "input_audio_buffer.append", "audio": "AAH/"}, + {"type": "input_image_buffer.append", "image": "aW1hZ2U="}, + {"type": "input_image_buffer.append", "image": "aW1hZ2U="}, + {"type": "input_audio_buffer.commit"}, + {"type": "input_audio_buffer.clear"}, + {"type": "conversation.item.create", "item": item}, + {"type": "conversation.item.delete", "item_id": "item-1"}, + {"type": "response.create", "response": {"instructions": None}}, + { + "type": "response.create", + "response": { + "instructions": "Answer briefly", + "modalities": ["text", "audio"], + }, + }, + { + "type": "response.create", + "response": { + "instructions": "", + "modalities": [], + "temperature": 0, + "parallel_tool_calls": False, + "metadata": {"tag": "test"}, + }, + }, + {"type": "response.cancel"}, + {"type": "session.finish"}, + ] + assert item == original + assert ws.recv_calls == 0 + assert ws.close_calls == 0 + + +@pytest.mark.parametrize("value", ["", b""]) +def test_empty_media_is_not_dropped(opened, value): + session, ws = opened + session.append_audio(value) + session.append_video(value) + assert _payloads(ws) == [ + {"type": "input_audio_buffer.append", "audio": ""}, + {"type": "input_image_buffer.append", "image": ""}, + ] + + +@pytest.mark.parametrize("method", ["append_audio", "append_video"]) +def test_invalid_media_type(opened, method): + session, ws = opened + with pytest.raises(TypeError, match="base64 string or bytes"): + getattr(session, method)(123) + assert not ws.sent + + +@pytest.mark.parametrize("event_id", ["explicit-id", "", None]) +def test_send_event_preserves_explicit_id_and_caller_dict(opened, event_id): + session, ws = opened + event = {"event_id": event_id, "type": "custom", "nested": {"value": 0}} + original = copy.deepcopy(event) + session.send_event(event) + assert json.loads(ws.sent[0]) == original + assert event == original + + +def test_send_event_assigns_unique_ids_without_mutation(opened): + session, ws = opened + event = {"type": "custom", "extension": {"enabled": False}} + original = copy.deepcopy(event) + session.send_event(event) + session.send_event(event) + assert _payloads(ws) == [original, original] + assert event == original + + +@pytest.mark.parametrize( + "method, args", + [ + ("send_event", ({"type": "custom"},)), + ("update_session", (["audio"],)), + ("append_audio", (b"audio",)), + ("append_video", (b"video",)), + ("commit", ()), + ("clear_appended_audio", ()), + ("create_item", ({},)), + ("retrieve_item", ("item",)), + ("delete_item", ("item",)), + ("create_response", ()), + ("cancel_response", ()), + ("end_session", ()), + ("recv_event", ()), + ("events", ()), + ], +) +def test_operations_require_open_session(client, method, args): + session = client.audio.realtime.connect(model="omni") + with pytest.raises(RuntimeError, match="not open"): + result = getattr(session, method)(*args) + if method == "events": + list(result) + + +def test_context_is_lazy_and_close_is_idempotent(client): + ws = FakeWebSocket() + calls = [] + + def connector(url, headers): + calls.append((url, headers)) + return ws + + session = client.audio.realtime.connect(model="omni", connector=connector) + assert not calls + with session as entered: + assert entered is session + assert len(calls) == 1 + assert ws.timeouts == [12.5] + session.close() + assert ws.close_calls == 1 + assert session.recv_event() is None + assert session.recv_event() is None + with pytest.raises(RuntimeError, match="not open"): + session.commit() + + +def test_explicit_connect_rejects_duplicate_opens(client): + ws = FakeWebSocket() + calls = [] + + def connector(url, headers): + calls.append((url, headers)) + return ws + + session = client.audio.realtime.connect(model="omni", connector=connector) + assert session.connect() is session + try: + with pytest.raises(RuntimeError, match="already open"): + session.connect() + with pytest.raises(RuntimeError, match="already open"): + session.__enter__() + assert len(calls) == 1 + assert ws.close_calls == 0 + finally: + session.close() + + +def test_every_session_opens_its_own_socket(client): + # Contract relied on by examples/04_real_world_scenarios/ + # realtime_audio_meeting_harness.py: realtime sessions never share a + # socket, so concurrent sessions need one dedicated connection each. + sockets = [FakeWebSocket(), FakeWebSocket()] + calls = [] + + def connector(url, headers): + calls.append((url, headers)) + return sockets[len(calls) - 1] + + first = client.audio.realtime.connect(model="omni", connector=connector) + second = client.audio.realtime.connect(model="omni", connector=connector) + with first, second: + assert len(calls) == 2 + first.send_event({"type": "ping"}) + assert len(sockets[0].sent) == 1 + assert sockets[1].sent == [] + + +@pytest.mark.parametrize("timeout, expected", [(None, 12.5), (7, 7)]) +def test_default_connector_applies_timeout_during_handshake( + client, + monkeypatch, + timeout, + expected, +): + ws = FakeWebSocket() + calls = [] + + def create_connection(url, *, header, timeout): + calls.append((url, header, timeout)) + return ws + + monkeypatch.setattr("websocket.create_connection", create_connection) + with client.audio.realtime.connect(model="omni", timeout=timeout): + assert calls[0][2] == expected + assert ws.timeouts == [expected] + assert ws.close_calls == 1 + + +def test_custom_connector_stays_two_argument_and_timeout_override(client): + ws = FakeWebSocket() + with client.audio.realtime.connect( + model="omni", + timeout=3.5, + connector=lambda url, headers: ws, + ): + assert ws.timeouts == [3.5] + + +def test_handshake_failure_can_be_retried(client, monkeypatch): + ws = FakeWebSocket() + calls = [] + failure = WebSocketTimeoutException("handshake timed out") + + def create_connection(url, *, header, timeout): + calls.append(timeout) + if len(calls) == 1: + raise failure + return ws + + monkeypatch.setattr("websocket.create_connection", create_connection) + session = client.audio.realtime.connect(model="omni") + with pytest.raises(WebSocketTimeoutException) as caught: + session.__enter__() + assert caught.value is failure + with pytest.raises(RuntimeError, match="not open"): + session.recv_event() + with session: + assert calls == [12.5, 12.5] + assert ws.close_calls == 1 + + +def test_settimeout_failure_closes_without_masking_error(client): + ws = FakeWebSocket() + failure = OSError("settimeout failed") + ws.timeout_error = failure + ws.close_error = OSError("close failed") + session = client.audio.realtime.connect( + model="omni", + connector=lambda url, headers: ws, + ) + with pytest.raises(OSError) as caught: + session.connect() + assert caught.value is failure + assert ws.close_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.commit() + with pytest.raises(RuntimeError, match="not open"): + session.recv_event() + + +def test_context_cleanup_does_not_mask_body_exception(client): + ws = FakeWebSocket() + ws.close_error = OSError("close failed") + failure = ValueError("body failed") + with pytest.raises(ValueError) as caught: + with client.audio.realtime.connect( + model="omni", + connector=lambda url, headers: ws, + ): + raise failure + assert caught.value is failure + assert ws.close_calls == 1 + + +def test_sdk_headers_and_case_insensitive_user_overrides(client): + captured = [] + + def connector(url, headers): + captured.append(dict(headers)) + return FakeWebSocket() + + with client.audio.realtime.connect(model="omni", connector=connector): + pass + defaults = captured[0] + assert defaults.items() >= sdk_headers("audio").items() + assert defaults["Authorization"] == "Bearer sk-test" + assert defaults["X-DashScope-WorkSpace"] == "ws-test" + assert defaults["user-agent"] + custom = { + "X-DashScope-SDK-Client": "custom", + "X-DashScope-SDK-Session-ID": "session-override", + "User-Agent": "custom-agent", + "authorization": "Bearer custom", + "x-dashscope-workspace": "custom-workspace", + "X-Trace": "trace", + } + original = dict(custom) + with client.audio.realtime.connect( + model="omni", + connector=connector, + headers=custom, + ): + pass + assert captured[1] == custom + assert custom == original + + +@pytest.mark.parametrize( + "url, expected_query", + [ + ("wss://example.test/realtime", [("model", "omni +/中文&?")]), + ( + "wss://example.test/realtime" + "?other_model=x&flag=&tag=a&tag=b#anchor", + [ + ("other_model", "x"), + ("flag", ""), + ("tag", "a"), + ("tag", "b"), + ("model", "omni +/中文&?"), + ], + ), + ( + "wss://example.test/realtime?note=model=value", + [("note", "model=value"), ("model", "omni +/中文&?")], + ), + ( + "wss://example.test/realtime?model=pinned%2Bvalue&flag=0", + [("model", "pinned+value"), ("flag", "0")], + ), + ("wss://example.test/realtime?model=", [("model", "")]), + ], +) +def test_model_url_query_encoding(client, url, expected_query): + session = client.audio.realtime.connect(model="omni +/中文&?", ws_url=url) + parts = urlsplit(session.url) + assert parse_qsl(parts.query, keep_blank_values=True) == expected_query + assert parts.fragment == urlsplit(url).fragment + assert session.model == "omni +/中文&?" + assert "中文" not in session.url + assert " " not in session.url + + +@pytest.mark.parametrize( + "raw", + [ + None, + "", + b"", + WebSocketConnectionClosedException("closed"), + ], +) +def test_normal_remote_close_cleans_up_and_keeps_returning_none(opened, raw): + session, ws = opened + ws.inbox.append(raw) + assert session.recv_event() is None + assert session.recv_event() is None + assert list(session.events()) == [] + assert ws.close_calls == 1 + assert ws.recv_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.cancel_response() + + +@pytest.mark.parametrize( + "raw", + [ + None, + "", + b"", + WebSocketConnectionClosedException("closed"), + ], +) +@pytest.mark.parametrize("raw_send", [False, True]) +def test_finish_requires_acknowledgement(opened, raw, raw_send): + session, ws = opened + if raw_send: + session.send_event({"type": "session.finish"}) + else: + session.end_session() + assert ws.recv_calls == 0 + ws.inbox.append(raw) + with pytest.raises(APIConnectionError, match="before session.finished"): + list(session.events()) + assert ws.close_calls == 1 + with pytest.raises(APIConnectionError, match="before session.finished"): + session.recv_event() + + +@pytest.mark.parametrize("request_finish", [False, True]) +def test_events_yield_finished_once_then_stop_and_close( + opened, + request_finish, +): + session, ws = opened + events = [ + {"type": "session.created", "session": {"id": "session-1"}}, + {"type": "response.created", "response": {"id": "response-1"}}, + {"type": "response.done", "response": {"id": "response-1"}}, + {"type": "response.created", "response": {"id": "response-2"}}, + {"type": "session.finished", "extension": {"ok": True}}, + ] + ws.inbox.extend(events + [AssertionError("must not read past finished")]) + if request_finish: + session.end_session() + iterator = session.events() + assert [next(iterator) for _ in events] == events + assert ws.close_calls == 1 + assert list(iterator) == [] + assert list(session.events()) == [] + assert session.recv_event() is None + assert session.last_message == events[-1] + assert ws.recv_calls == len(events) + + +@pytest.mark.parametrize( + "failure", + [ + WebSocketTimeoutException("timed out"), + ConnectionResetError("reset"), + ], +) +@pytest.mark.parametrize("finishing", [False, True]) +def test_recv_transport_failure_closes_and_propagates( + opened, + failure, + finishing, +): + session, ws = opened + if finishing: + session.end_session() + ws.inbox.append(failure) + ws.close_error = OSError("cleanup failed") + with pytest.raises(type(failure)) as caught: + session.recv_event() + assert caught.value is failure + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "failure", + [ + WebSocketTimeoutException("send timed out"), + WebSocketConnectionClosedException("send closed"), + ConnectionResetError("send reset"), + ], +) +def test_send_failure_closes_and_propagates(opened, failure): + session, ws = opened + ws.send_error = failure + ws.close_error = OSError("cleanup failed") + with pytest.raises(type(failure)) as caught: + session.end_session() + assert caught.value is failure + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "raw, error_type", + [ + ("{invalid", json.JSONDecodeError), + (b"\xff", UnicodeDecodeError), + ("[]", ValueError), + ], +) +def test_invalid_event_closes_and_propagates(opened, raw, error_type): + session, ws = opened + ws.inbox.append(raw) + with pytest.raises(error_type): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "code, error_type", + [ + ("Throttling", RateLimitError), + ("InvalidParameter", BadRequestError), + ], +) +@pytest.mark.parametrize("finishing", [False, True]) +def test_error_translation_preserves_raw_event_and_cleans_up( + opened, + code, + error_type, + finishing, +): + session, ws = opened + event = { + "type": "error", + "event_id": "server-event", + "extension": False, + "error": {"code": code, "message": "server rejected request"}, + } + if finishing: + session.end_session() + ws.inbox.append(event) + with pytest.raises(error_type, match="server rejected request") as caught: + session.recv_event() + assert caught.value.body == event + assert caught.value.code == code + assert caught.value.status_code == 200 + assert session.last_message == event + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "encode", + [ + str, + lambda value: value.encode(), + lambda value: bytearray(value.encode()), + ], +) +def test_raw_events_are_preserved(opened, encode): + session, ws = opened + event = {"type": "custom.event", "future": {"value": 0, "enabled": False}} + ws.inbox.append(encode(json.dumps(event))) + received = session.recv_event() + assert received == event + assert session.last_message is received + assert ws.close_calls == 0 + + +def test_session_ids_and_multi_response_metrics(opened, monkeypatch): + session, ws = opened + now = [0.0] + monkeypatch.setattr(realtime.time, "monotonic", lambda: now[0]) + + def receive(kind, at, **fields): + now[0] = at + event = {"type": kind, **fields} + ws.inbox.append(event) + assert session.recv_event() == event + + receive("session.created", 0, session={"id": "session-1"}) + receive("session.updated", 0, session={"voice": "Cherry"}) + assert session.session_id == "session-1" + receive("response.audio.delta", 0, delta="before any response") + assert session.last_first_audio_delay is None + receive("response.created", 0, response={"id": "response-1"}) + assert session.last_response_create_time == 0 + receive("response.text.delta", 0.01, response_id="response-1", delta="hi") + receive("response.audio_transcript.delta", 0.015, delta="second text") + receive( + "response.audio.delta", + 0.02, + response_id="response-1", + delta="audio", + ) + receive("response.audio.delta", 0.03, delta="second audio") + assert session.last_first_text_delay == pytest.approx(10) + assert session.last_first_audio_delay == pytest.approx(20) + receive("response.done", 0.04, response={"id": "response-1"}) + receive("response.created", 1, response={"id": "response-2"}) + assert session.last_response_id == "response-2" + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + receive("response.audio.delta", 1, response_id="response-1", delta="stale") + assert session.last_first_audio_delay is None + receive("response.done", 1, response={"id": "response-1"}) + receive("response.audio.delta", 1, response_id="response-2", delta="audio") + receive("response.audio.delta", 1.01, delta="second audio") + receive("response.audio_transcript.delta", 1.05, delta="text") + assert session.last_first_audio_delay == 0 + assert session.last_first_text_delay == pytest.approx(50) + receive("response.created", 2, response={"id": "response-3"}) + receive( + "response.text.delta", + 2.1, + response_id="response-2", + delta="stale", + ) + assert session.last_first_text_delay is None + receive("response.text.delta", 2.2, delta="text") + receive("response.done", 2.3, response={"id": "response-3"}) + receive( + "response.audio.delta", + 2.4, + response_id="response-3", + delta="late", + ) + assert session.last_first_text_delay == pytest.approx(200) + assert session.last_first_audio_delay is None + + +def test_reconnect_resets_finish_state_and_metrics(client): + first = FakeWebSocket( + [ + {"type": "session.created", "session": {"id": "old-session"}}, + {"type": "response.created", "response": {"id": "old-response"}}, + {"type": "response.audio.delta", "delta": "audio"}, + {"type": "response.text.delta", "delta": "text"}, + {"type": "session.finished"}, + ], + ) + second = FakeWebSocket() + sockets = iter([first, second]) + session = client.audio.realtime.connect( + model="omni", + connector=lambda url, headers: next(sockets), + ) + with session: + session.end_session() + assert len(list(session.events())) == 5 + with session: + assert session.session_id is None + assert session.last_message is None + assert session.last_response_id is None + assert session.last_response_create_time is None + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + assert list(session.events()) == [] + assert first.close_calls == second.close_calls == 1 + + +@pytest.mark.parametrize("timeout", [0, -1]) +def test_timeout_must_be_positive(client, timeout): + with pytest.raises(ValueError, match="timeout must be positive"): + client.audio.realtime.connect(model="omni", timeout=timeout) + + +def test_history_insert_and_retrieve(opened): + session, ws = opened + item = { + "type": "function_call_output", + "call_id": "call-1", + "output": "ok", + } + session.create_item(item, previous_item_id="item-1") + session.retrieve_item("item-2") + assert _payloads(ws) == [ + { + "type": "conversation.item.create", + "item": item, + "previous_item_id": "item-1", + }, + {"type": "conversation.item.retrieve", "item_id": "item-2"}, + ] + + +def test_finish_is_idempotent_and_rejects_late_input(opened): + session, ws = opened + session.end_session() + session.end_session() + assert len(ws.sent) == 1 + with pytest.raises(RuntimeError, match="finishing"): + session.append_audio(b"late") + ws.inbox.append({"type": "session.finished"}) + assert list(session.events()) == [{"type": "session.finished"}] + + +def test_client_error_keeps_session_usable(opened): + session, ws = opened + ws.inbox.extend( + [ + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "invalid voice", + }, + }, + {"type": "session.updated", "session": {"id": "sess-1"}}, + ], + ) + with pytest.raises(BadRequestError, match="invalid voice"): + session.recv_event() + assert ws.close_calls == 0 + session.update_session(["audio", "text"], voice="Cherry") + assert session.recv_event()["type"] == "session.updated" + assert session.session_id == "sess-1" + + +@pytest.mark.parametrize("raw", [None, WebSocketConnectionClosedException()]) +def test_disconnect_before_response_done_is_not_silent(opened, raw): + session, ws = opened + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "response-1"}}, + {"type": "response.audio.delta", "delta": "YXVkaW8="}, + raw, + ], + ) + with pytest.raises(APIConnectionError, match="before response.done"): + list(session.events()) + assert ws.close_calls == 1 + + +def test_disconnect_after_response_done_is_normal(opened): + session, ws = opened + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "response-1"}}, + {"type": "response.done", "response": {"id": "response-1"}}, + ], + ) + assert len(list(session.events())) == 2 + assert ws.close_calls == 1 + + +_QWEN_AUDIO_MODELS = [ + name + suffix + for name in ( + "qwen-audio-3.0-realtime-plus", + "qwen-audio-3.0-realtime-flash", + "qwen-audio-3.1-realtime-plus", + ) + for suffix in ("", "-2026-09-01") +] +_LIVE_MODELS = [ + name + suffix + for name in ( + "qwen3-livetranslate-flash-realtime", + "qwen3.5-livetranslate-flash-realtime", + ) + for suffix in ("", "-2026-09-01") +] +_LIVE38_MODELS = [ + "qwen3.8-livetranslate-flash-realtime" + suffix + for suffix in ("", "-2026-09-01") +] +_PATCH_MODELS = _QWEN_AUDIO_MODELS + _LIVE_MODELS + _LIVE38_MODELS + + +@pytest.fixture +def model_opened(client, model): + ws = FakeWebSocket() + with client.audio.realtime.connect( + model=model, + connector=lambda url, headers: ws, + ) as session: + yield session, ws + + +@pytest.mark.parametrize("model", _PATCH_MODELS) +def test_new_protocol_updates_are_incremental(model_opened, model): + session, ws = model_opened + session.update_session() + session.update_session( + ["text", "audio"], + voice="Cherry", + enable_turn_detection=True, + ) + session.update_session(instructions="Translate into English") + session.update_session() + if model in _LIVE38_MODELS: + configured = { + "output_modalities": ["text", "audio"], + "audio": { + "input": {"turn_detection": {"type": "speaker_detection"}}, + "output": {"voice": "Cherry"}, + }, + } + else: + configured = { + "modalities": ["text", "audio"], + "voice": "Cherry", + "turn_detection": {"type": "server_vad"}, + } + assert _payloads(ws) == [ + {"type": "session.update", "session": {}}, + {"type": "session.update", "session": configured}, + { + "type": "session.update", + "session": {"instructions": "Translate into English"}, + }, + {"type": "session.update", "session": {}}, + ] + + +@pytest.mark.parametrize("model", _PATCH_MODELS) +def test_new_protocol_updates_preserve_falsey_values(model_opened, model): + session, ws = model_opened + session.update_session( + [], + voice=None, + turn_detection_param={}, + translation_params={}, + instructions="", + temperature=0, + future_option=False, + metadata=None, + tools=[], + ) + expected = { + "translation": {}, + "instructions": "", + "temperature": 0, + "future_option": False, + "metadata": None, + "tools": [], + } + if model in _LIVE38_MODELS: + expected.update( + output_modalities=[], + audio={ + "input": {"turn_detection": {}}, + "output": {"voice": None}, + }, + ) + else: + expected.update(modalities=[], voice=None, turn_detection={}) + assert _payloads(ws) == [{"type": "session.update", "session": expected}] + + +@pytest.mark.parametrize("model", _PATCH_MODELS) +@pytest.mark.parametrize( + "options", + [{"enable_turn_detection": False}, {"turn_detection_param": None}], +) +def test_new_protocol_explicit_vad_disable(model_opened, model, options): + session, ws = model_opened + session.update_session(**options) + expected = ( + {"audio": {"input": {"turn_detection": None}}} + if model in _LIVE38_MODELS + else {"turn_detection": None} + ) + assert _payloads(ws) == [{"type": "session.update", "session": expected}] + + +@pytest.mark.parametrize("model", _PATCH_MODELS) +@pytest.mark.parametrize( + "input_config, output_config", + [ + (None, None), + ({}, {}), + ( + {"type": "pcm", "sample_rate": 0}, + {"type": "opus", "speech_rate": 0, "future_option": False}, + ), + ], +) +def test_new_protocol_audio_configs_are_explicit_and_not_mutated( + model_opened, + input_config, + output_config, +): + session, ws = model_opened + original = copy.deepcopy([input_config, output_config]) + session.update_session( + input_audio_config=input_config, + output_audio_config=output_config, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": { + "audio": { + "input": {"format": input_config}, + "output": {"format": output_config}, + }, + }, + }, + ] + assert [input_config, output_config] == original + + +@pytest.mark.parametrize("model", _LIVE_MODELS) +@pytest.mark.parametrize( + "options, transcription", + [ + ({"enable_input_audio_transcription": False}, {"model": None}), + ( + { + "enable_input_audio_transcription": False, + "input_audio_transcription_model": "qwen3-asr-flash-realtime", + }, + {"model": None}, + ), + ( + { + "enable_input_audio_transcription": False, + "transcription_params": { + "model": "qwen3-asr-flash-realtime", + "language": "zh", + }, + }, + {"model": None}, + ), + ( + {"enable_input_audio_transcription": True}, + {"model": "qwen3-asr-flash-realtime"}, + ), + ({"input_audio_transcription_model": None}, {"model": None}), + ({"transcription_params": None}, {"model": None}), + ({"transcription_params": {}}, {}), + ( + { + "transcription_params": { + "language": None, + "corpus": None, + "future_option": False, + }, + }, + {"language": None, "corpus": None, "future_option": False}, + ), + ], +) +def test_live_transcription_is_explicit_without_implicit_formats( + model_opened, + options, + transcription, +): + session, ws = model_opened + original = copy.deepcopy(options) + session.update_session(**options) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": {"input_audio_transcription": transcription}, + }, + ] + assert options == original + + +@pytest.mark.parametrize("model", _LIVE_MODELS + _LIVE38_MODELS) +def test_live_transcription_promotes_only_explicit_audio_fields( + model_opened, + model, +): + session, ws = model_opened + params = { + "language": None, + "input_audio_format": None, + "sample_rate": 0, + "corpus_text": None, + } + original = copy.deepcopy(params) + session.update_session(transcription_params=params, translation_params={}) + expected = { + "input_audio_transcription": { + "language": None, + "corpus": {"text": None}, + }, + "translation": {}, + } + if model in _LIVE38_MODELS: + expected["audio"] = { + "input": {"format": {"type": None, "sample_rate": 0}}, + } + else: + expected.update(input_audio_format=None, sample_rate=0) + assert _payloads(ws) == [{"type": "session.update", "session": expected}] + assert params == original + + +@pytest.mark.parametrize("model", _LIVE38_MODELS) +@pytest.mark.parametrize( + "options, expected", + [ + ( + { + "output_modalities": ["text", "audio"], + "voice": "Cherry", + "input_audio_format": "pcm", + "output_audio_format": "opus", + "sample_rate": 24000, + "enable_turn_detection": True, + "prefix_padding_ms": 0, + "turn_detection_threshold": 0, + "turn_detection_silence_duration_ms": 0, + "turn_detection_param": {"interrupt_response": False}, + }, + { + "output_modalities": ["text", "audio"], + "audio": { + "input": { + "format": {"type": "pcm", "sample_rate": 24000}, + "turn_detection": { + "type": "speaker_detection", + "prefix_padding_ms": 0, + "threshold": 0, + "silence_duration_ms": 0, + "interrupt_response": False, + }, + }, + "output": {"format": {"type": "opus"}, "voice": "Cherry"}, + }, + }, + ), + ( + { + "output_modalities": ["audio"], + "modalities": [], + "voice": None, + "input_audio_format": None, + "output_audio_format": None, + "sample_rate": 0, + "enable_turn_detection": True, + "turn_detection": None, + }, + { + "output_modalities": [], + "audio": { + "input": { + "format": {"type": None, "sample_rate": 0}, + "turn_detection": None, + }, + "output": {"format": {"type": None}, "voice": None}, + }, + }, + ), + ( + {"sample_rate": 0, "turn_detection": {}}, + { + "audio": { + "input": { + "format": {"sample_rate": 0}, + "turn_detection": {}, + }, + }, + }, + ), + ( + {"input_audio_config": {"type": "pcm"}, "sample_rate": 8000}, + { + "audio": { + "input": {"format": {"type": "pcm", "sample_rate": 8000}}, + }, + }, + ), + ( + { + "input_audio_config": {"sample_rate": 0}, + "input_audio_format": "pcm", + }, + { + "audio": { + "input": {"format": {"type": "pcm", "sample_rate": 0}}, + }, + }, + ), + ( + {"input_audio_config": None, "sample_rate": 8000}, + {"audio": {"input": {"format": None}}}, + ), + ({"enable_input_audio_transcription": True}, {}), + ({"output_modalities": None}, {"output_modalities": None}), + ], +) +def test_live38_maps_flat_helpers_and_kwargs_without_legacy_fields( + model_opened, + options, + expected, +): + session, ws = model_opened + original = copy.deepcopy(options) + session.update_session(**options) + assert _payloads(ws) == [{"type": "session.update", "session": expected}] + assert options == original + + +@pytest.mark.parametrize("model", _LIVE38_MODELS) +@pytest.mark.parametrize( + "raw_audio, expected_audio", + [ + ( + { + "input": { + "format": {"type": "wav", "sample_rate": 0}, + "turn_detection": None, + }, + "output": {"format": {"type": "wav"}, "voice": ""}, + "future": {"enabled": False, "options": []}, + }, + { + "input": { + "format": {"type": "wav", "sample_rate": 0}, + "turn_detection": None, + }, + "output": {"format": {"type": "wav"}, "voice": ""}, + "future": {"enabled": False, "options": []}, + }, + ), + ( + {"input": {"format": {"type": "wav"}}, "output": {"voice": None}}, + { + "input": { + "format": {"type": "wav", "sample_rate": 24000}, + "turn_detection": {"type": "speaker_detection"}, + }, + "output": {"format": {"type": "opus"}, "voice": None}, + }, + ), + ( + {"input": None, "output": None}, + {"input": None, "output": None}, + ), + (None, None), + ], +) +def test_live38_raw_audio_overrides_helpers_without_mutating_inputs( + model_opened, + raw_audio, + expected_audio, +): + session, ws = model_opened + options = { + "voice": "Cherry", + "input_audio_format": "pcm", + "output_audio_format": "opus", + "sample_rate": 24000, + "enable_turn_detection": True, + "audio": raw_audio, + } + original = copy.deepcopy(options) + session.update_session(**options) + assert _payloads(ws) == [ + {"type": "session.update", "session": {"audio": expected_audio}}, + ] + assert options == original + + +@pytest.mark.parametrize("model", _LIVE38_MODELS) +@pytest.mark.parametrize("raw_audio", [None, {}, {"input": {}, "output": {}}]) +def test_live38_raw_audio_does_not_gain_implicit_formats( + model_opened, + raw_audio, +): + session, ws = model_opened + original = copy.deepcopy(raw_audio) + session.update_session(audio=raw_audio) + assert _payloads(ws) == [ + {"type": "session.update", "session": {"audio": raw_audio}}, + ] + assert raw_audio == original + + +@pytest.mark.parametrize("model", _LIVE38_MODELS) +@pytest.mark.parametrize( + "options, message", + [ + ( + {"enable_input_audio_transcription": False}, + "ASR cannot be disabled", + ), + ( + { + "enable_input_audio_transcription": False, + "input_audio_transcription_model": "qwen3-asr-flash-realtime", + }, + "ASR cannot be disabled", + ), + ( + { + "enable_input_audio_transcription": False, + "transcription_params": {"model": "qwen3-asr-flash-realtime"}, + }, + "ASR cannot be disabled", + ), + ({"input_audio_transcription_model": None}, "ASR cannot be disabled"), + ({"transcription_params": None}, "ASR cannot be disabled"), + ({"transcription_params": {"model": None}}, "ASR cannot be disabled"), + ({"input_audio_transcription": None}, "ASR cannot be disabled"), + ( + {"input_audio_transcription": {"model": None}}, + "ASR cannot be disabled", + ), + ({"same_language_skip_options": None}, "same_language_skip_options"), + ({"same_language_skip_options": {}}, "same_language_skip_options"), + ({"same_language_skip_options": []}, "same_language_skip_options"), + ({"same_language_skip_options": False}, "same_language_skip_options"), + ], +) +def test_live38_rejects_invalid_helpers_before_send( + model_opened, + options, + message, +): + session, ws = model_opened + original = copy.deepcopy(options) + with pytest.raises(ValueError, match=message): + session.update_session(**options) + assert ws.sent == [] + assert ws.close_calls == ws.recv_calls == 0 + assert options == original + session.update_session(instructions="Still usable") + assert _payloads(ws) == [ + { + "type": "session.update", + "session": {"instructions": "Still usable"}, + }, + ] + + +@pytest.mark.parametrize("model", _LIVE38_MODELS) +@pytest.mark.parametrize("transcription", [None, {"model": None}]) +def test_live38_raw_send_event_bypasses_helper_validation( + model_opened, + transcription, +): + session, ws = model_opened + event = { + "type": "session.update", + "session": { + "modalities": [], + "voice": None, + "input_audio_transcription": transcription, + "same_language_skip_options": {}, + "audio": {"input": {"format": {}}, "output": None}, + }, + } + original = copy.deepcopy(event) + session.send_event(event) + assert _payloads(ws) == [original] + assert event == original + assert ws.close_calls == ws.recv_calls == 0 + + +@pytest.mark.parametrize("model", _QWEN_AUDIO_MODELS) +@pytest.mark.parametrize( + "options, expected", + [ + ({}, {}), + ({"instructions": None}, {"instructions": None}), + ({"modalities": None}, {"modalities": None}), + ( + {"modalities": None, "instructions": None}, + {"modalities": None, "instructions": None}, + ), + ( + { + "modalities": [], + "instructions": "", + "temperature": 0, + "metadata": {}, + }, + { + "modalities": [], + "instructions": "", + "temperature": 0, + "metadata": {}, + }, + ), + ], +) +def test_qwen_create_response_preserves_only_explicit_fields( + model_opened, + options, + expected, +): + session, ws = model_opened + original = copy.deepcopy(options) + session.create_response(**options) + assert _payloads(ws) == [{"type": "response.create", "response": expected}] + assert options == original + + +@pytest.mark.parametrize("model", _QWEN_AUDIO_MODELS) +def test_qwen_end_session_does_not_send_close_or_poison_state(model_opened): + session, ws = model_opened + events = [ + {"type": "response.created", "response": {"id": "response-1"}}, + { + "type": "response.audio.delta", + "response_id": "response-1", + "delta": "AA==", + }, + {"type": "response.done", "response": {"id": "response-1"}}, + ] + ws.inbox.extend(events) + with pytest.raises(NotImplementedError, match="session.finish"): + session.end_session() + assert ws.sent == [] + assert ws.recv_calls == ws.close_calls == 0 + session.append_audio(b"audio") + session.commit() + session.create_response() + assert session.recv_event() == events[0] + sent = list(ws.sent) + with pytest.raises(NotImplementedError, match="session.finish"): + session.end_session() + assert ws.sent == sent + assert ws.recv_calls == 1 + assert ws.close_calls == 0 + session.update_session(instructions="Continue") + assert session.recv_event() == events[1] + assert session.recv_event() == events[2] + assert ws.close_calls == 0 + assert _payloads(ws) == [ + {"type": "input_audio_buffer.append", "audio": "YXVkaW8="}, + {"type": "input_audio_buffer.commit"}, + {"type": "response.create", "response": {}}, + {"type": "session.update", "session": {"instructions": "Continue"}}, + ] + session.close() + assert ws.close_calls == 1 + assert session.recv_event() is None + assert ws.recv_calls == 3 + assert session.last_message == events[-1] + + +@pytest.mark.parametrize("model", _LIVE_MODELS + _LIVE38_MODELS) +def test_live_end_session_waits_for_session_finished(model_opened): + session, ws = model_opened + events = [ + {"type": "response.created", "response": {"id": "response-1"}}, + {"type": "response.done", "response": {"id": "response-1"}}, + {"type": "session.finished", "usage": {"input_tokens": 0}}, + ] + ws.inbox.extend(events + [AssertionError("must not read past finished")]) + session.end_session() + session.end_session() + assert _payloads(ws) == [{"type": "session.finish"}] + assert ws.recv_calls == ws.close_calls == 0 + iterator = session.events() + assert next(iterator) == events[0] + assert next(iterator) == events[1] + assert ws.close_calls == 0 + assert next(iterator) == events[2] + assert ws.close_calls == 1 + assert list(iterator) == [] + assert session.recv_event() is None + assert ws.recv_calls == 3 + + +@pytest.mark.parametrize("model", _LIVE_MODELS + _LIVE38_MODELS) +@pytest.mark.parametrize( + "eof", + [None, "", b"", WebSocketConnectionClosedException("closed")], +) +def test_live_finish_eof_after_response_done_still_raises(model_opened, eof): + session, ws = model_opened + events = [ + {"type": "response.created", "response": {"id": "response-1"}}, + {"type": "response.done", "response": {"id": "response-1"}}, + ] + ws.inbox.extend(events + [eof]) + session.end_session() + assert _payloads(ws) == [{"type": "session.finish"}] + iterator = session.events() + assert next(iterator) == events[0] + assert next(iterator) == events[1] + assert ws.close_calls == 0 + with pytest.raises(APIConnectionError, match="before session.finished"): + next(iterator) + assert ws.close_calls == 1 + with pytest.raises(APIConnectionError, match="before session.finished"): + session.recv_event() + assert ws.recv_calls == 3 + + +@pytest.mark.parametrize("model", _PATCH_MODELS) +@pytest.mark.parametrize( + "text_kind, text_fields", + [ + ("response.text.delta", {"delta": "translated"}), + ("response.audio_transcript.delta", {"delta": "translated"}), + ("response.text.text", {"text": "translated"}), + ("response.audio_transcript.text", {"text": "translated"}), + ("response.text.text", {"text": "translated", "stash": "pending"}), + ( + "response.audio_transcript.text", + {"text": "translated", "stash": "pending"}, + ), + ], +) +def test_new_protocol_metrics_track_output_and_preserve_raw_events( + model_opened, + monkeypatch, + text_kind, + text_fields, +): + session, ws = model_opened + now = [0.0] + monkeypatch.setattr(realtime.time, "monotonic", lambda: now[0]) + + def receive(kind, at, **fields): + now[0] = at + event = {"type": kind, **fields} + original = copy.deepcopy(event) + ws.inbox.append(event) + received = session.recv_event() + assert received == original + assert session.last_message is received + assert event == original + return received + + receive(text_kind, 0, **text_fields) + receive("response.audio.delta", 0, delta="before response") + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + for response_id, start in (("response-1", 10), ("response-2", 20)): + receive("response.created", start, response={"id": response_id}) + assert session.last_response_id == response_id + assert session.last_response_create_time == start * 1000 + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + for kind in ( + "conversation.item.input_audio_transcription.delta", + "conversation.item.input_audio_transcription.text", + "conversation.item.input_audio_transcription.completed", + ): + receive( + kind, + start + 0.01, + text="input ASR", + delta="input", + stash="unfinalized", + ) + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + stale_id = ( + "response-1" if response_id == "response-2" else "old-response" + ) + receive(text_kind, start + 0.02, response_id=stale_id, **text_fields) + receive( + "response.audio.delta", + start + 0.02, + response_id=stale_id, + delta="stale", + ) + receive("response.done", start + 0.03, response={"id": stale_id}) + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + received = receive( + text_kind, + start + 0.125, + response_id=response_id, + begin_time=0, + end_time=125, + **text_fields, + ) + assert received["begin_time"] == 0 + assert received["end_time"] == 125 + if "stash" in text_fields: + assert received["stash"] == "pending" + receive( + "response.audio.delta", + start + 0.25, + response_id=response_id, + delta="AA==", + ) + assert session.last_first_text_delay == pytest.approx(125) + assert session.last_first_audio_delay == pytest.approx(250) + receive(text_kind, start + 0.375, **text_fields) + receive("response.audio.delta", start + 0.5, delta="second audio") + usage = { + "input_tokens": 0, + "output_tokens": 3, + "output_tokens_details": {"audio_tokens": 2, "text_tokens": 1}, + "future": {"enabled": False}, + } + done = receive( + "response.done", + start + 0.75, + response={"id": response_id, "usage": usage}, + timestamp=1750000000123, + ) + assert done["response"]["usage"] == usage + assert done["timestamp"] == 1750000000123 + receive(text_kind, start + 1, response_id=response_id, **text_fields) + receive( + "response.audio.delta", + start + 1, + response_id=response_id, + delta="late", + ) + assert session.last_first_text_delay == pytest.approx(125) + assert session.last_first_audio_delay == pytest.approx(250) + assert _payloads(ws) == [] + assert ws.close_calls == 0 + + +@pytest.mark.parametrize( + "model, wire_model, expected", + [ + ( + "qwen-omni-turbo-realtime", + "qwen-audio-3.0-realtime-plus-2026-09-01", + {"modalities": ["text"], "voice": "Cherry"}, + ), + ( + "qwen-omni-turbo-realtime", + "qwen3.5-livetranslate-flash-realtime-2026-09-01", + {"modalities": ["text"], "voice": "Cherry"}, + ), + ( + "qwen-audio-3.1-realtime-plus", + "qwen3.8-livetranslate-flash-realtime-2026-09-01", + { + "output_modalities": ["text"], + "audio": {"output": {"voice": "Cherry"}}, + }, + ), + ( + "qwen3.8-livetranslate-flash-realtime", + "qwen-omni-turbo-realtime", + _default_session(modalities=["text"], voice="Cherry"), + ), + ], +) +def test_url_model_selects_wire_protocol_without_changing_public_model( + client, + model, + wire_model, + expected, +): + ws = FakeWebSocket() + urls = [] + + def connector(url, headers): + urls.append(url) + return ws + + with client.audio.realtime.connect( + model=model, + ws_url=f"wss://example.test/realtime?model={wire_model}&flag=0", + connector=connector, + ) as session: + assert session.model == model + assert urls == [session.url] + assert parse_qsl(urlsplit(session.url).query) == [ + ("model", wire_model), + ("flag", "0"), + ] + session.update_session(["text"], voice="Cherry") + if wire_model.startswith("qwen-audio-"): + with pytest.raises(NotImplementedError, match="session.finish"): + session.end_session() + assert _payloads(ws) == [ + {"type": "session.update", "session": expected}, + ] + else: + session.end_session() + assert _payloads(ws) == [ + {"type": "session.update", "session": expected}, + {"type": "session.finish"}, + ] + ws.inbox.append({"type": "session.finished"}) + assert list(session.events()) == [{"type": "session.finished"}] + assert session.model == model diff --git a/tests/v2/test_audio_translation.py b/tests/v2/test_audio_translation.py new file mode 100644 index 00000000..2c78305f --- /dev/null +++ b/tests/v2/test_audio_translation.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.audio.translation (Gummy realtime translation).""" + +# Fake connectors only inspect the headers/url they care about. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import pytest + +from dashscope.v2 import Client +from dashscope.v2.errors import DashScopeError +from dashscope.v2.resources.audio.asr import FILE_CHUNK_SIZE + + +def _make_client(**kwargs): + # Realtime sessions talk over the websocket transport, but the Client + # still resolves an HTTP config; pin a workspace so the derived URLs + # stay deterministic (without one the SDK falls back to the legacy + # shared gateway with a warning). + kwargs.setdefault("workspace", "ws-test") + return Client(api_key="sk-test", **kwargs) + + +class FakeWebSocket: + """Scripted websocket stub: records sends, replays a queued inbox.""" + + def __init__(self, inbox=None): + self.sent = [] + self.inbox = list(inbox or []) + self.closed = False + + def send(self, payload, opcode=1): + assert opcode == (2 if isinstance(payload, bytes) else 1) + self.sent.append(payload) + + def recv(self): + if not self.inbox: + return None + return self.inbox.pop(0) + + def close(self): + self.closed = True + + def settimeout(self, seconds): + pass + + +def _event(name, payload=None): + return json.dumps( + { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload or {}, + }, + ) + + +def _sent_frames(ws): + return [json.loads(item) for item in ws.sent if isinstance(item, str)] + + +def _bilingual_output(**overrides): + output = { + "transcription": { + "sentence_id": 1, + "text": "你好", + "begin_time": 0, + "current_time": 100, + "words": [], + "sentence_end": False, + }, + "translations": [ + { + "sentence_id": 1, + "lang": "en", + "text": "Hello", + "begin_time": 0, + "current_time": 100, + "words": [], + "sentence_end": False, + }, + ], + } + output.update(overrides) + return output + + +def test_translation_recognize_merges_named_parameters(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event("task-finished"), + ], + ) + client = _make_client() + with client.audio.translation.recognize( + model="gummy-realtime-v1", + source_language="zh", + transcription_enabled=True, + translation_enabled=True, + translation_target_languages=["en"], + max_end_silence=800, + parameters={"source_language": "ja", "future_option": False}, + connector=lambda url, headers: ws, + ) as session: + session.send_audio_frame(b"AUD") + session.stop() + events = list(session.events()) + assert events[-1]["header"]["event"] == "task-finished" + + frames = _sent_frames(ws) + assert frames[0]["header"]["action"] == "run-task" + assert frames[0]["payload"]["task_group"] == "audio" + assert frames[0]["payload"]["task"] == "asr" + assert frames[0]["payload"]["function"] == "recognition" + assert frames[0]["payload"]["model"] == "gummy-realtime-v1" + params = frames[0]["payload"]["parameters"] + assert params["format"] == "pcm" + assert params["sample_rate"] == 16000 + # Named kwargs win over the parameters= escape hatch. + assert params["source_language"] == "zh" + assert params["transcription_enabled"] is True + assert params["translation_enabled"] is True + assert params["translation_target_languages"] == ["en"] + assert params["max_end_silence"] == 800 + # The parameters= pass-through itself survives. + assert params["future_option"] is False + assert ws.sent[1] == b"AUD" + assert frames[1]["header"]["action"] == "finish-task" + assert ws.closed is True + + +def test_translation_results_yield_typed_bilingual(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + { + "output": { + "transcription": { + "sentence_id": 0, + "text": "", + "heartbeat": True, + }, + }, + }, + ), + _event( + "result-generated", + {"output": _bilingual_output(), "usage": {"duration": 1}}, + ), + _event( + "result-generated", + { + "output": _bilingual_output( + transcription=None, + translations=[ + { + "sentence_id": 2, + "lang": "en", + "text": "Done.", + "sentence_end": True, + }, + ], + ), + }, + ), + _event("task-finished"), + ], + ) + client = _make_client() + with client.audio.translation.recognize( + model="gummy-realtime-v1", + translation_enabled=True, + translation_target_languages=["en"], + connector=lambda url, headers: ws, + ) as session: + session.send_audio_frame(b"AUD") + session.stop() + results = list(session.results()) + + # Heartbeat frame and task-finished are filtered out. + assert len(results) == 2 + first = results[0] + assert first.get_transcription()["text"] == "你好" + assert first.get_translations()[0]["lang"] == "en" + assert first.is_sentence_end() is False + assert first.request_id == "r" + assert first.usage == {"duration": 1} + second = results[1] + assert second.get_transcription() is None + assert second.get_translations()[0]["text"] == "Done." + assert second.is_sentence_end() is True + # Gummy reports text under ``output.transcription`` (not ``output. + # sentence``); the shared first-package-delay trigger must still fire. + assert session.first_package_delay is not None + assert session.first_package_delay >= 0 + + +def test_translation_recognize_file_chunks_and_yields_typed_results( + tmp_path, +): + audio_file = tmp_path / "sample.wav" + audio_file.write_bytes(b"\x00" * (FILE_CHUNK_SIZE + 100)) + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + {"output": _bilingual_output(), "usage": {"duration": 1}}, + ), + _event("task-finished"), + ], + ) + client = _make_client() + results = list( + client.audio.translation.recognize_file( + str(audio_file), + model="gummy-realtime-v1", + translation_enabled=True, + translation_target_languages=["en"], + connector=lambda url, headers: ws, + ), + ) + + assert len(results) == 1 + assert results[0].get_transcription()["text"] == "你好" + assert results[0].get_translations()[0]["lang"] == "en" + assert ws.sent[1] == b"\x00" * FILE_CHUNK_SIZE + assert ws.sent[2] == b"\x00" * 100 + frames = _sent_frames(ws) + assert frames[0]["header"]["action"] == "run-task" + assert frames[-1]["header"]["action"] == "finish-task" + assert ws.closed is True + + +def test_translation_recognize_file_validates_path(tmp_path): + client = _make_client() + with pytest.raises(ValueError, match="not found"): + client.audio.translation.recognize_file( + "/nonexistent/audio.wav", + model="gummy-realtime-v1", + ) + empty = tmp_path / "empty.wav" + empty.write_bytes(b"") + with pytest.raises(ValueError, match="empty"): + client.audio.translation.recognize_file( + str(empty), + model="gummy-realtime-v1", + ) + + +def test_translation_recognize_file_forwards_named_parameters(tmp_path): + # recognize_file re-assembles the run-task payload separately from + # recognize; assert every named param reaches the wire so the duplicate + # forwarding block cannot silently drop one (M3 wire parity). + audio_file = tmp_path / "sample.wav" + audio_file.write_bytes(b"\x00" * FILE_CHUNK_SIZE) + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + {"output": _bilingual_output(), "usage": {"duration": 1}}, + ), + _event("task-finished"), + ], + ) + client = _make_client() + list( + client.audio.translation.recognize_file( + str(audio_file), + model="gummy-realtime-v1", + format="opus", + sample_rate=8000, + source_language="zh", + transcription_enabled=False, + translation_enabled=True, + translation_target_languages=["en", "ja"], + max_end_silence=800, + phrase_id="ph-1", + connector=lambda url, headers: ws, + ), + ) + + frames = _sent_frames(ws) + run = frames[0] + assert run["header"]["action"] == "run-task" + assert run["payload"]["task_group"] == "audio" + assert run["payload"]["task"] == "asr" + assert run["payload"]["function"] == "recognition" + assert run["payload"]["model"] == "gummy-realtime-v1" + assert run["payload"]["input"] == {} + params = run["payload"]["parameters"] + assert params["format"] == "opus" + assert params["sample_rate"] == 8000 + assert params["source_language"] == "zh" + # Falsey named values survive forwarding (not dropped by an ``if value``). + assert params["transcription_enabled"] is False + assert params["translation_enabled"] is True + assert params["translation_target_languages"] == ["en", "ja"] + assert params["max_end_silence"] == 800 + assert run["payload"]["resources"] == [ + {"resource_id": "ph-1", "resource_type": "asr_phrase"}, + ] + assert frames[-1]["header"]["action"] == "finish-task" + assert ws.closed is True + + +def test_translation_raises_on_task_failed(): + ws = FakeWebSocket( + [ + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": "t", + "error_code": "InvalidParameter", + "error_message": "bad source_language", + }, + "payload": {}, + }, + ), + ], + ) + client = _make_client() + with pytest.raises(DashScopeError, match="bad source_language"): + with client.audio.translation.recognize( + model="gummy-realtime-v1", + source_language="xx", + connector=lambda url, headers: ws, + ): + pass + assert ws.closed is True + + +def test_translation_threads_per_call_auth(): + # Per-call api_key/workspace must drive the gateway host AND the auth + # headers together, so the socket and its credentials never disagree. + captured = {} + + def connector(url, headers): + captured["url"] = url + captured["headers"] = headers + return FakeWebSocket( + [_event("task-started"), _event("task-finished")], + ) + + client = _make_client() + with client.audio.translation.recognize( + model="gummy-realtime-v1", + api_key="sk-override", + workspace="ws-override", + connector=connector, + ) as session: + assert captured["url"].startswith( + "wss://ws-override.cn-beijing.maas.aliyuncs.com/", + ) + assert captured["headers"]["Authorization"] == "Bearer sk-override" + assert captured["headers"]["X-DashScope-WorkSpace"] == "ws-override" + session.stop() + list(session.events()) diff --git a/tests/v2/test_audio_tts_http.py b/tests/v2/test_audio_tts_http.py new file mode 100644 index 00000000..f43ac986 --- /dev/null +++ b/tests/v2/test_audio_tts_http.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.audio.tts.http (M4): sambert/cosyvoice + qwen-tts.""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import base64 +import json + +import httpx +import pytest + +from dashscope.v2 import Client +from dashscope.v2.errors import BadRequestError + +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 _b64(payload: bytes) -> str: + return base64.b64encode(payload).decode() + + +def test_http_synthesize_returns_audio_reference(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content.decode()) + captured["sse"] = request.headers.get("x-dashscope-sse") + return httpx.Response( + 200, + headers={"x-request-id": "req-http-1"}, + json={ + "request_id": "req-body-1", + "output": { + "audio": { + "url": "https://cdn.test/audio.wav", + "id": "audio-1", + "expires_at": 1720000000, + }, + }, + "usage": {"characters": 4}, + }, + ) + + client = _make_client(handler) + result = client.audio.tts.http.synthesize( + "你好世界", + model="sambert-zhichu-v1", + voice="zhichu", + format="wav", + sample_rate=16000, + ) + + assert captured["path"] == "/api/v1/services/audio/tts/SpeechSynthesizer" + assert captured["body"] == { + "model": "sambert-zhichu-v1", + "input": { + "text": "你好世界", + "voice": "zhichu", + "format": "wav", + "sample_rate": 16000, + }, + } + assert captured["sse"] is None + assert result.audio_url == "https://cdn.test/audio.wav" + assert result.audio_id == "audio-1" + assert result.expires_at == 1720000000 + assert result.usage == {"characters": 4} + assert result.request_id == "req-body-1" + + +def test_http_qwen_tts_routes_to_multimodal_generation(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content.decode()) + return httpx.Response( + 200, + json={ + "output": { + "audio": { + "url": "https://cdn.test/qwen.mp3", + "id": "audio-2", + "expires_at": 1720000100, + }, + }, + "usage": {"characters": 2}, + }, + ) + + client = _make_client(handler) + result = client.audio.tts.http.synthesize( + "你好", + model="qwen3-tts-flash", + voice="Cherry", + language_type="Chinese", + ) + + assert ( + captured["path"] + == "/api/v1/services/aigc/multimodal-generation/generation" + ) + assert captured["body"]["input"] == { + "text": "你好", + "voice": "Cherry", + "language_type": "Chinese", + } + assert result.audio_url == "https://cdn.test/qwen.mp3" + + +def test_http_extra_input_and_parameters_merge(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content.decode()) + return httpx.Response(200, json={"output": {"audio": {}}}) + + client = _make_client(handler) + client.audio.tts.http.synthesize( + "你好", + model="cosyvoice-v3-flash", + voice="longanfengyu", + format="mp3", + extra_input={"volume": 50, "format": "pcm"}, + parameters={"seed": 42}, + ) + + # Named arguments win over the extra_input escape hatch. + assert captured["body"]["input"] == { + "text": "你好", + "voice": "longanfengyu", + "volume": 50, + "format": "mp3", + } + assert captured["body"]["parameters"] == {"seed": 42} + + +def test_http_stream_yields_chunks_and_final_reference(): + sentence = {"id": 1, "text": "你好"} + sse = ( + "id: 1\ndata: " + + json.dumps( + {"output": {"type": "sentence-begin", "sentence": sentence}}, + ) + + "\n\nid: 2\ndata: " + + json.dumps( + { + "output": { + "type": "sentence-synthesis", + "sentence": sentence, + "audio": {"data": _b64(b"pcm-bytes")}, + }, + }, + ) + + "\n\nid: 3\ndata: " + + json.dumps( + { + "output": { + "finish_reason": "stop", + "audio": { + "url": "https://cdn.test/full.wav", + "id": "audio-3", + "expires_at": 1720000200, + }, + }, + "usage": {"characters": 2}, + }, + ) + + "\n\n" + ) + captured = {} + + def handler(request): + captured["sse"] = request.headers.get("x-dashscope-sse") + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "x-request-id": "req-stream-1", + }, + content=sse.encode(), + ) + + client = _make_client(handler) + chunks = list( + client.audio.tts.http.stream( + "你好", + model="cosyvoice-v3-flash", + voice="longanfengyu", + format="pcm", + sample_rate=24000, + ), + ) + + assert captured["sse"] == "enable" + assert len(chunks) == 3 + + assert chunks[0].sentence == sentence + assert chunks[0].audio is None + assert not chunks[0].is_final + + assert chunks[1].audio == b"pcm-bytes" + assert chunks[1].sentence == sentence + + assert chunks[2].is_final + assert chunks[2].finish_reason == "stop" + assert chunks[2].audio_url == "https://cdn.test/full.wav" + assert chunks[2].audio_id == "audio-3" + assert chunks[2].expires_at == 1720000200 + assert chunks[2].usage == {"characters": 2} + assert chunks[2].request_id == "req-stream-1" + + +def test_http_stream_error_frame_raises(): + sse = ( + 'id: 1\ndata: {"output": {"type": "sentence-begin"}}\n\n' + "id: 2\nevent:error\nstatus: 400\n" + 'data: {"code": "InvalidParameter", "message": "bad voice"}\n\n' + ) + + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + content=sse.encode(), + ) + + client = _make_client(handler) + stream = client.audio.tts.http.stream( + "你好", + model="sambert-zhichu-v1", + voice="nobody", + ) + assert next(stream).output["type"] == "sentence-begin" + with pytest.raises(BadRequestError, match="bad voice"): + next(stream) + + +def test_http_synthesize_error_status_raises(): + def handler(request): + return httpx.Response( + 400, + json={"code": "InvalidParameter", "message": "text is required"}, + ) + + client = _make_client(handler) + with pytest.raises(BadRequestError, match="text is required"): + client.audio.tts.http.synthesize( + "", + model="sambert-zhichu-v1", + voice="zhichu", + ) + + +def test_http_synthesize_carries_raw_response_headers(): + # Uniform success-path contract: the synthesis mirrors the gateway's + # HTTP response headers (1.x parity, GenerationResponse.headers). + def handler(request): + return httpx.Response( + 200, + headers={"x-request-id": "req-h", "x-gw-region": "cn-hz"}, + json={ + "request_id": "req-h", + "output": {"audio": {"url": "https://cdn.test/a.wav"}}, + }, + ) + + client = _make_client(handler) + result = client.audio.tts.http.synthesize( + "你好", + model="sambert-zhichu-v1", + voice="zhichu", + ) + assert result.headers["x-request-id"] == "req-h" + assert result.headers["x-gw-region"] == "cn-hz" + + +def test_http_stream_carries_raw_response_headers(): + # The SSE envelope is a single HTTP response; its headers sit on the + # stream wrapper, not on the chunks (chunks keep the empty default). + sse = ( + "id: 1\ndata: " + + json.dumps( + { + "output": { + "finish_reason": "stop", + "audio": {"url": "https://cdn.test/f.wav"}, + }, + }, + ) + + "\n\n" + ) + + def handler(request): + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "x-request-id": "req-s", + "x-gw-region": "cn-hz", + }, + content=sse.encode(), + ) + + client = _make_client(handler) + stream = client.audio.tts.http.stream( + "你好", + model="cosyvoice-v3-flash", + voice="longanfengyu", + ) + assert stream.headers["x-request-id"] == "req-s" + assert stream.headers["x-gw-region"] == "cn-hz" + chunks = list(stream) + assert len(chunks) == 1 + assert chunks[0].is_final + assert chunks[0].audio_url == "https://cdn.test/f.wav" diff --git a/tests/v2/test_audio_tts_realtime.py b/tests/v2/test_audio_tts_realtime.py new file mode 100644 index 00000000..9505846d --- /dev/null +++ b/tests/v2/test_audio_tts_realtime.py @@ -0,0 +1,499 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.audio.tts and client.audio.realtime (M3 3.5).""" + +# Fake connectors only inspect the headers/url they care about. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import pytest +from websocket import ( + WebSocketConnectionClosedException, + WebSocketTimeoutException, +) + +from dashscope.v2 import Client +from dashscope.v2.errors import ( + BadRequestError, + DashScopeError, + RateLimitError, +) +from dashscope.v2.resources.audio._callbacks import run_with_callback + + +def _make_client(**kwargs): + # Realtime sessions talk over the websocket transport, but the Client + # still resolves an HTTP config; pin a workspace so the derived URLs + # stay deterministic (without one the SDK falls back to the legacy + # shared gateway with a warning). + kwargs.setdefault("workspace", "ws-test") + return Client(api_key="sk-test", **kwargs) + + +class FakeWebSocket: + """Scripted websocket stub: records sends, replays a queued inbox.""" + + def __init__(self, inbox=None, exc=None): + self.sent = [] + self.inbox = list(inbox or []) + self.exc = exc + self.closed = False + + def send(self, payload): + self.sent.append(payload) + + def recv(self): + if self.exc: + raise self.exc + if not self.inbox: + return None + return self.inbox.pop(0) + + def close(self): + self.closed = True + + def settimeout(self, seconds): + pass + + +def _event(name, payload=None): + return json.dumps( + { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload or {}, + }, + ) + + +def _sent_frames(ws): + return [json.loads(item) for item in ws.sent if isinstance(item, str)] + + +def test_tts_synthesize_and_stream(): + ws = FakeWebSocket( + [ + _event("task-started"), + b"AUD1", + b"AUD2", + _event("task-finished"), + ], + ) + captured = {} + + def connector(url, headers): + captured["headers"] = headers + return ws + + client = _make_client() + audio = client.audio.tts.synthesize( + "你好,世界", + model="cosyvoice-v1", + voice="longxiaochun", + connector=connector, + ) + assert audio == b"AUD1AUD2" + assert captured["headers"]["Authorization"] == "Bearer sk-test" + frames = _sent_frames(ws) + assert frames[0]["header"]["action"] == "run-task" + payload = frames[0]["payload"] + assert payload["task_group"] == "audio" + assert payload["task"] == "tts" + assert payload["function"] == "SpeechSynthesizer" + assert payload["input"] == {} + assert payload["parameters"]["voice"] == "longxiaochun" + assert payload["parameters"]["format"] == "mp3" + assert frames[1]["header"]["action"] == "continue-task" + assert frames[1]["payload"]["input"] == {"text": "你好,世界"} + assert frames[-1]["header"]["action"] == "finish-task" + assert ws.closed is True + + +def test_tts_named_parameters_merge_into_run_task(): + ws = FakeWebSocket( + [ + _event("task-started"), + b"AUD", + _event("task-finished"), + ], + ) + client = _make_client() + audio = client.audio.tts.synthesize( + "你好", + volume=80, + rate=1.2, + pitch=0.9, + language_hints=["zh"], + enable_ssml=True, + # Named kwargs win over the escape hatch. + parameters={"volume": 10, "bit_rate": 128}, + connector=lambda url, headers: ws, + ) + assert audio == b"AUD" + frames = _sent_frames(ws) + parameters = frames[0]["payload"]["parameters"] + assert parameters["volume"] == 80 + assert parameters["rate"] == 1.2 + assert parameters["pitch"] == 0.9 + assert parameters["language_hints"] == ["zh"] + assert parameters["enable_ssml"] is True + # Untouched escape-hatch keys pass through. + assert parameters["bit_rate"] == 128 + + +def test_tts_ws_url_follows_region(): + ws = FakeWebSocket( + [ + _event("task-started"), + b"AUD", + _event("task-finished"), + ], + ) + captured = {} + + def connector(url, headers): + captured["url"] = url + return ws + + client = _make_client(region="ap-southeast-1") + audio = client.audio.tts.synthesize("hi", connector=connector) + assert audio == b"AUD" + assert captured["url"] == ( + "wss://ws-test.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference" + ) + + +def test_tts_stream_yields_chunks(): + ws = FakeWebSocket( + [ + _event("task-started"), + b"AUD1", + b"AUD2", + _event("task-finished"), + ], + ) + client = _make_client() + chunks = list( + client.audio.tts.stream( + "你好", + connector=lambda url, headers: ws, + ), + ) + assert chunks == [b"AUD1", b"AUD2"] + + +def test_tts_raises_on_task_failed(): + ws = FakeWebSocket( + [ + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": "t", + "error_code": "InvalidParameter", + "error_message": "bad", + }, + "payload": {}, + }, + ), + ], + ) + client = _make_client() + with pytest.raises(BadRequestError, match="bad"): + client.audio.tts.synthesize( + "你好", + connector=lambda url, headers: ws, + ) + + +def test_tts_incremental_send_and_complete(): + ws = FakeWebSocket( + [ + _event("task-started"), + b"AUD1", + b"AUD2", + _event("task-finished"), + ], + ) + client = _make_client() + with client.audio.tts.connect( + model="cosyvoice-v1", + connector=lambda url, headers: ws, + ) as stream: + stream.send_text("你好") + stream.send_text("世界") + stream.stop() + audio = b"".join(stream.audio_chunks()) + + assert audio == b"AUD1AUD2" + frames = _sent_frames(ws) + assert frames[0]["header"]["action"] == "run-task" + assert frames[1]["header"]["action"] == "continue-task" + assert frames[1]["payload"]["input"] == {"text": "你好"} + assert frames[2]["header"]["action"] == "continue-task" + assert frames[2]["payload"]["input"] == {"text": "世界"} + assert frames[3]["header"]["action"] == "finish-task" + # SSML off by default. + assert frames[0]["payload"]["parameters"]["text_type"] == "PlainText" + assert ws.closed is True + + +def test_tts_send_text_requires_open_session(): + client = _make_client() + stream = client.audio.tts.connect() + with pytest.raises(RuntimeError, match="not open"): + stream.send_text("你好") + + +def test_tts_enable_ssml_flips_text_type(): + ws = FakeWebSocket( + [ + _event("task-started"), + b"AUD", + _event("task-finished"), + ], + ) + client = _make_client() + client.audio.tts.synthesize( + "你好", + enable_ssml=True, + connector=lambda url, headers: ws, + ) + parameters = _sent_frames(ws)[0]["payload"]["parameters"] + assert parameters["text_type"] == "SSML" + assert parameters["enable_ssml"] is True + + ws_off = FakeWebSocket( + [ + _event("task-started"), + b"AUD", + _event("task-finished"), + ], + ) + client.audio.tts.synthesize( + "你好", + enable_ssml=False, + connector=lambda url, headers: ws_off, + ) + parameters = _sent_frames(ws_off)[0]["payload"]["parameters"] + assert parameters["text_type"] == "PlainText" + assert parameters["enable_ssml"] is False + + +class RecordingCallback: + """1.x-style ResultCallback stub recording every invocation.""" + + def __init__(self): + self.calls = [] + + def on_open(self): + self.calls.append(("on_open",)) + + def on_event(self, event): + self.calls.append(("on_event", event)) + + def on_data(self, data): + self.calls.append(("on_data", data)) + + def on_complete(self): + self.calls.append(("on_complete",)) + + def on_error(self, error): + self.calls.append(("on_error", error)) + + def on_close(self): + self.calls.append(("on_close",)) + + +def test_tts_run_with_callback_delivers_audio_in_order(): + ws = FakeWebSocket( + [ + _event("task-started"), + b"AUD1", + b"AUD2", + _event("task-finished"), + ], + ) + client = _make_client() + callback = RecordingCallback() + run_with_callback( + client.audio.tts.stream("你好", connector=lambda url, headers: ws), + callback, + ) + assert callback.calls == [ + ("on_open",), + ("on_data", b"AUD1"), + ("on_data", b"AUD2"), + ("on_complete",), + ("on_close",), + ] + + +def test_tts_task_failed_maps_wire_code(): + ws = FakeWebSocket( + [ + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": "t", + "request_id": "r", + "error_code": "Throttling", + "error_message": "slow", + }, + "payload": {}, + }, + ), + ], + ) + client = _make_client() + with pytest.raises(RateLimitError, match="slow") as exc: + client.audio.tts.synthesize( + "你好", + connector=lambda url, headers: ws, + ) + error = exc.value + assert error.code == "Throttling" + # The websocket delivered the frame fine; only the task failed, so + # there is no HTTP status to invent. + assert error.status_code == 200 + # request_id rides in the frame header, not in its payload. + assert error.request_id == "r" + assert error.body["header"]["event"] == "task-failed" + + +def test_realtime_session_events_and_error(): + ws = FakeWebSocket( + [ + json.dumps({"type": "session.created"}), + json.dumps( + { + "type": "error", + "error": {"code": "invalid", "message": "boom"}, + }, + ), + ], + ) + captured = {} + + def connector(url, headers): + captured["url"] = url + captured["headers"] = headers + return ws + + client = _make_client() + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + connector=connector, + ) as rt: + assert captured["url"] == ( + "wss://ws-test.cn-beijing.maas.aliyuncs.com/api-ws/v1/realtime" + "?model=qwen-omni-turbo-realtime" + ) + assert captured["headers"]["Authorization"] == "Bearer sk-test" + rt.send_event({"type": "session.update", "session": {}}) + first = next(rt.events()) + assert first["type"] == "session.created" + with pytest.raises(DashScopeError, match="boom"): + rt.recv_event() + assert json.loads(ws.sent[0])["type"] == "session.update" + assert ws.closed is True + + +def test_realtime_session_url_follows_region(): + captured = {} + + def connector(url, headers): + captured["url"] = url + return FakeWebSocket() + + client = _make_client(region="ap-southeast-1") + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + connector=connector, + ): + assert captured["url"] == ( + "wss://ws-test.ap-southeast-1.maas.aliyuncs.com" + "/api-ws/v1/realtime?model=qwen-omni-turbo-realtime" + ) + + +def test_realtime_session_url_follows_per_call_workspace(): + # The override workspace drives the gateway host as well as the + # header; otherwise the socket and the auth header disagree. + captured = {} + + def connector(url, headers): + captured["url"] = url + captured["headers"] = headers + return FakeWebSocket() + + client = _make_client() + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + workspace="ws-override", + connector=connector, + ): + assert captured["url"] == ( + "wss://ws-override.cn-beijing.maas.aliyuncs.com" + "/api-ws/v1/realtime?model=qwen-omni-turbo-realtime" + ) + assert captured["headers"]["X-DashScope-WorkSpace"] == "ws-override" + + +def test_realtime_error_event_maps_wire_code(): + ws = FakeWebSocket( + [ + json.dumps( + { + "type": "error", + "error": {"code": "Throttling", "message": "slow down"}, + }, + ), + ], + ) + client = _make_client() + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + connector=lambda url, headers: ws, + ) as rt: + with pytest.raises(RateLimitError, match="slow down") as exc: + rt.recv_event() + error = exc.value + assert error.code == "Throttling" + # The websocket delivered the event fine; only the business call + # failed, so there is no HTTP status to invent here. + assert error.status_code == 200 + + +def test_realtime_send_requires_open_session(): + client = _make_client() + session = client.audio.realtime.connect(model="qwen-omni-turbo-realtime") + with pytest.raises(RuntimeError, match="not open"): + session.send_event({"type": "session.update"}) + + +def test_realtime_recv_none_on_normal_close(): + ws = FakeWebSocket( + exc=WebSocketConnectionClosedException("Connection is closed"), + ) + client = _make_client() + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + connector=lambda url, headers: ws, + ) as rt: + assert rt.recv_event() is None + assert not list(rt.events()) + + +def test_realtime_recv_raises_on_transport_error(): + ws = FakeWebSocket(exc=WebSocketTimeoutException("Connection timed out")) + client = _make_client() + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + connector=lambda url, headers: ws, + ) as rt: + with pytest.raises(WebSocketTimeoutException): + rt.recv_event() diff --git a/tests/v2/test_audio_tts_session.py b/tests/v2/test_audio_tts_session.py new file mode 100644 index 00000000..804bc693 --- /dev/null +++ b/tests/v2/test_audio_tts_session.py @@ -0,0 +1,495 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Duplex TTS session protocol, lifecycle, and caller-owned concurrency.""" + +# Fake connectors intentionally ignore their URL/header arguments. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json +import queue +import threading +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from dashscope.v2 import Client +from dashscope.v2.errors import APIConnectionError, BadRequestError +from dashscope.v2.resources.audio.tts import SynthesisSession + + +def _event(name, payload=None): + return { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload or {}, + } + + +class FakeWebSocket: + def __init__(self, inbox=()): + self.inbox = list(inbox) + self.sent = [] + self.recv_count = 0 + self.close_count = 0 + self.timeout = None + self.send_error = None + self.timeout_error = None + + def send(self, payload): + if self.send_error: + raise self.send_error + self.sent.append(json.loads(payload)) + + def recv(self): + self.recv_count += 1 + frame = self.inbox.pop(0) if self.inbox else None + if isinstance(frame, Exception): + raise frame # pylint: disable=raising-bad-type + return json.dumps(frame) if isinstance(frame, dict) else frame + + def settimeout(self, timeout): + if self.timeout_error: + raise self.timeout_error + self.timeout = timeout + + def close(self): + self.close_count += 1 + + +@pytest.fixture +def client(): + with Client(api_key="sk-test", workspace="ws-test", timeout=19) as value: + yield value + + +def _session(client, ws, **kwargs): + return client.audio.tts.connect( + connector=lambda url, headers: ws, + **kwargs, + ) + + +def _assert_unusable(session): + for operation in ( + session.start, + lambda: session.send_text("late"), + session.flush, + session.stop, + session.cancel, + lambda: next(session.events()), + lambda: next(session.audio_chunks()), + ): + with pytest.raises(RuntimeError, match="closed|not open"): + operation() + + +def test_multiple_text_flush_stop_and_raw_events(client, monkeypatch): + started = _event("task-started") + result = _event( + "result-generated", + {"output": {"sentence": "你好"}, "usage": {"characters": 2}}, + ) + finished = _event("task-finished", {"usage": None}) + ws = FakeWebSocket([started, result, b"first", b"second", finished]) + clock = iter([10.0, 10.25]) + monkeypatch.setattr( + "dashscope.v2.resources.audio.tts.time.monotonic", + lambda: next(clock), + ) + session = _session(client, ws) + assert isinstance(session, SynthesisSession) + assert ws.sent == [] + assert session.last_response is None + assert session.usage is None + assert session.first_package_delay is None + + with session: + assert session.last_response == started + task_id = session.session.task_id + session.send_text("你") + session.flush() + session.send_text("好") + session.stop() + # stop sends only; it never waits for the results. + assert ws.recv_count == 1 + assert list(session.events()) == [ + result, + b"first", + b"second", + finished, + ] + assert session.session.finished + assert session.last_response == finished + assert session.usage == {"characters": 2} + assert session.first_package_delay == pytest.approx(250) + + assert ws.sent[0]["payload"] == { + "task_group": "audio", + "task": "tts", + "function": "SpeechSynthesizer", + "model": "cosyvoice-v1", + "input": {}, + "parameters": { + "voice": "longxiaochun", + "format": "mp3", + "sample_rate": 22050, + "text_type": "PlainText", + }, + } + assert [frame["header"]["action"] for frame in ws.sent] == [ + "run-task", + "continue-task", + "continue-task", + "continue-task", + "finish-task", + ] + assert [frame["payload"] for frame in ws.sent[1:]] == [ + {"input": {"text": "你"}}, + {"input": {"flush": True}}, + {"input": {"text": "好"}}, + {"input": {}}, + ] + assert all(frame["header"]["task_id"] == task_id for frame in ws.sent) + assert ws.timeout == client.config.timeout + assert ws.close_count == 1 + session.close() + assert ws.close_count == 1 + _assert_unusable(session) + + +def test_audio_chunks_retains_latest_usage_and_terminal_response(client): + finished = _event("task-finished", {"usage": {"characters": 5}}) + ws = FakeWebSocket( + [ + _event("task-started"), + _event("result-generated", {"usage": {"characters": 2}}), + bytearray(b"a"), + _event("result-generated", {"output": {"new_field": True}}), + b"b", + finished, + ], + ) + with _session(client, ws) as session: + session.send_text("hello") + session.stop() + assert list(session.audio_chunks()) == [b"a", b"b"] + assert session.usage == {"characters": 5} + assert session.last_response == finished + assert session.first_package_delay >= 0 + + +@pytest.mark.parametrize("method", ["connect", "stream", "synthesize"]) +@pytest.mark.parametrize("timeout", [None, 7]) +def test_parameters_headers_and_timeout_passthrough(client, method, timeout): + ws = FakeWebSocket( + [ + _event("task-started"), + b"audio", + _event("task-finished"), + ], + ) + captured = {} + + def connector(url, headers): + captured.update(url=url, headers=headers) + return ws + + parameters = { + "volume": 75, + "rate": 1.2, + "pitch": 0.9, + "format": "pcm", + "sample_rate": 24000, + "future_parameter": {"enabled": True}, + } + headers = {"X-Test": "forward-me", "user-agent": "custom-agent"} + kwargs = { + "model": "cosyvoice-v3-plus", + "voice": "custom-voice", + "format": "wav", + "sample_rate": 16000, + "parameters": parameters, + "headers": headers, + "ws_url": "wss://example.test/tts", + "connector": connector, + "timeout": timeout, + } + if method == "connect": + with client.audio.tts.connect(**kwargs) as session: + session.send_text("hello") + session.stop() + assert list(session.audio_chunks()) == [b"audio"] + elif method == "stream": + assert list(client.audio.tts.stream("hello", **kwargs)) == [b"audio"] + else: + assert client.audio.tts.synthesize("hello", **kwargs) == b"audio" + + assert ws.sent[0]["payload"]["model"] == "cosyvoice-v3-plus" + assert ws.sent[0]["payload"]["parameters"] == { + "voice": "custom-voice", + "text_type": "PlainText", + **parameters, + } + assert "voice" not in parameters + assert captured["url"] == "wss://example.test/tts" + assert captured["headers"]["X-Test"] == "forward-me" + assert captured["headers"]["user-agent"] == "custom-agent" + assert captured["headers"]["Authorization"] == "Bearer sk-test" + assert captured["headers"]["X-DashScope-WorkSpace"] == "ws-test" + assert headers == {"X-Test": "forward-me", "user-agent": "custom-agent"} + expected_timeout = client.config.timeout if timeout is None else timeout + assert ws.timeout == expected_timeout + assert ws.close_count == 1 + + +@pytest.mark.parametrize("stop_first", [False, True]) +def test_cancel_once_before_terminal_including_after_stop( + client, + stop_first, +): + ws = FakeWebSocket([_event("task-started"), _event("task-finished")]) + with _session(client, ws) as session: + session.send_text("hello") + if stop_first: + session.stop() + session.cancel() + assert ws.recv_count == 1 + assert ws.sent[-1]["header"]["action"] == "finish-task" + assert ws.sent[-1]["payload"] == {"input": {"directive": "cancel"}} + sent_count = len(ws.sent) + session.cancel() + assert len(ws.sent) == sent_count + assert list(session.events()) == [_event("task-finished")] + assert ws.close_count == 1 + + +@pytest.mark.parametrize("ending", ["stop", "cancel"]) +@pytest.mark.parametrize("operation", ["send_text", "flush"]) +def test_no_more_text_or_flush_after_input_ends(client, ending, operation): + ws = FakeWebSocket([_event("task-started")]) + with _session(client, ws) as session: + getattr(session, ending)() + sent_count = len(ws.sent) + with pytest.raises(RuntimeError): + if operation == "send_text": + session.send_text("too late") + else: + session.flush() + assert len(ws.sent) == sent_count + _assert_unusable(session) + assert ws.close_count == 1 + + +def test_cancel_sends_nothing_after_terminal(client): + ws = FakeWebSocket([_event("task-started"), _event("task-finished")]) + with _session(client, ws) as session: + session.stop() + assert list(session.audio_chunks()) == [] + assert session.first_package_delay is None + session.cancel() + assert [frame["payload"]["input"] for frame in ws.sent] == [{}, {}] + + +@pytest.mark.parametrize("first", ["events", "audio_chunks"]) +@pytest.mark.parametrize("second", ["events", "audio_chunks"]) +def test_receive_iterators_are_exclusive_and_release_on_close( + client, + first, + second, +): + ws = FakeWebSocket( + [ + _event("task-started"), + b"a", + b"b", + _event("task-finished"), + ], + ) + with _session(client, ws) as session: + session.send_text("hello") + session.stop() + reader = getattr(session, first)() + assert next(reader) == b"a" + with pytest.raises(RuntimeError, match="only one"): + next(getattr(session, second)()) + assert ws.close_count == 0 + reader.close() + assert list(session.audio_chunks()) == [b"b"] + + +def test_manual_start_and_invalid_text(client): + ws = FakeWebSocket([_event("task-started")]) + session = _session(client, ws) + try: + assert session.start() is session + with pytest.raises(RuntimeError, match="already started"): + session.start() + with pytest.raises(TypeError, match="string"): + session.send_text(["not", "text"]) + assert len(ws.sent) == 1 + finally: + session.close() + _assert_unusable(session) + + +def test_close_before_start_and_unopened_operations(client): + ws = FakeWebSocket() + session = _session(client, ws) + with pytest.raises(RuntimeError, match="not open"): + next(session.events()) + session.close() + _assert_unusable(session) + assert ws.sent == [] + assert ws.close_count == 0 + + +@pytest.mark.parametrize("stage", ["connect", "timeout", "send", "handshake"]) +def test_start_failure_closes_and_cannot_restart(client, stage): + ws = FakeWebSocket([OSError("startup failed")]) + if stage == "timeout": + ws.timeout_error = OSError("startup failed") + if stage == "send": + ws.send_error = OSError("startup failed") + connections = [] + + def connector(url, headers): + connections.append(url) + if stage == "connect": + raise OSError("startup failed") + return ws + + session = client.audio.tts.connect(connector=connector) + with pytest.raises(OSError, match="startup failed"): + with session: + pytest.fail("failed startup must not enter the context") + _assert_unusable(session) + assert len(connections) == 1 + assert ws.close_count == (0 if stage == "connect" else 1) + + +@pytest.mark.parametrize("during_start", [False, True]) +def test_task_failure_retains_response_and_closes(client, during_start): + failed = _event("task-failed") + failed["header"].update( + error_code="InvalidParameter", + error_message="bad text", + ) + frames = [failed] if during_start else [_event("task-started"), failed] + ws = FakeWebSocket(frames) + session = _session(client, ws) + with pytest.raises(BadRequestError, match="bad text") as caught: + session.start() + session.send_text("hello") + list(session.events()) + assert caught.value.body == failed + assert session.last_response == failed + assert ws.close_count == 1 + _assert_unusable(session) + + +@pytest.mark.parametrize( + "operation", + ["send_text", "flush", "stop", "cancel"], +) +def test_send_failure_closes_and_disables_all_operations(client, operation): + ws = FakeWebSocket([_event("task-started")]) + session = _session(client, ws).start() + ws.send_error = OSError("send failed") + with pytest.raises(OSError, match="send failed"): + if operation == "send_text": + session.send_text("hello") + else: + getattr(session, operation)() + assert ws.close_count == 1 + _assert_unusable(session) + assert len(ws.sent) == 1 + + +@pytest.mark.parametrize("reader", ["events", "audio_chunks"]) +@pytest.mark.parametrize( + "frame, error", + [ + (None, APIConnectionError), + ("", APIConnectionError), + ("not json", json.JSONDecodeError), + (OSError("receive failed"), OSError), + ], +) +def test_receive_failure_closes_and_cannot_continue( + client, + reader, + frame, + error, +): + ws = FakeWebSocket([_event("task-started"), frame]) + session = _session(client, ws).start() + session.send_text("hello") + session.stop() + with pytest.raises(error): + list(getattr(session, reader)()) + assert ws.close_count == 1 + _assert_unusable(session) + + +def test_stream_close_and_context_exception_clean_up(client): + ws = FakeWebSocket([_event("task-started"), b"audio"]) + stream = client.audio.tts.stream( + "hello", + connector=lambda url, headers: ws, + ) + assert next(stream) == b"audio" + stream.close() + assert ws.close_count == 1 + + ws = FakeWebSocket([_event("task-started")]) + session = _session(client, ws) + with pytest.raises(ValueError, match="caller failed"): + with session: + raise ValueError("caller failed") + assert ws.close_count == 1 + _assert_unusable(session) + + +def test_caller_thread_receives_while_another_sends(client): + class DuplexWebSocket(FakeWebSocket): + def __init__(self): + super().__init__() + self.frames = queue.Queue() + self.frames.put(json.dumps(_event("task-started"))) + self.waiting_for_more = threading.Event() + + def send(self, payload): + super().send(payload) + frame = self.sent[-1] + action = frame["header"]["action"] + data = frame["payload"]["input"] + if action == "continue-task" and "text" in data: + self.frames.put(data["text"].encode("utf-8")) + elif action == "finish-task": + self.frames.put(json.dumps(_event("task-finished"))) + + def recv(self): + if self.frames.empty(): + self.waiting_for_more.set() + return self.frames.get(timeout=3) + + def close(self): + super().close() + self.frames.put(None) + + ws = DuplexWebSocket() + with ThreadPoolExecutor(max_workers=1) as pool: + with _session(client, ws) as session: + session.send_text("first") + received = pool.submit(lambda: list(session.events())) + assert ws.waiting_for_more.wait(timeout=3) + session.send_text("second") + session.flush() + session.stop() + assert received.result(timeout=3) == [ + b"first", + b"second", + _event("task-finished"), + ] + assert ws.close_count == 1 diff --git a/tests/v2/test_audio_ws_integration.py b/tests/v2/test_audio_ws_integration.py new file mode 100644 index 00000000..d90468b6 --- /dev/null +++ b/tests/v2/test_audio_ws_integration.py @@ -0,0 +1,1235 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Loopback tests using aiohttp and the real websocket-client transport.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import queue +import threading +from concurrent.futures import Future +from contextlib import contextmanager + +import pytest +import websocket +from aiohttp import WSMsgType, web +from websocket import WebSocketConnectionClosedException + +from dashscope.v2 import Client +from dashscope.v2.errors import ( + APIConnectionError, + BadRequestError, + RateLimitError, +) + + +TIMEOUT = 2 +SERVER_TIMEOUT = 4 +LOCAL_KEY = "local-integration-not-a-real-key" + + +class _LocalWebSocketServer: + """Bounded server thread preserving original protocol assertion errors.""" + + def __init__(self, scenario): + self.scenario = scenario + self.url = None + self.loop = None + self.stop_signal = None + self.ready = threading.Event() + self.handler_done = threading.Event() + self.protocol_completed = threading.Event() + self.errors = queue.Queue() + self.thread = threading.Thread( + target=self._run, + name="audio-ws-integration", + daemon=True, + ) + + def _record_error(self, error): + self.errors.put((error, error.__traceback__)) + + def _raise_server_error(self): + try: + error, traceback = self.errors.get_nowait() + except queue.Empty: + return + raise error.with_traceback(traceback) + + async def _handle(self, request): + ws = web.WebSocketResponse(timeout=TIMEOUT) + try: + await ws.prepare(request) + assert request.headers["Authorization"] == f"Bearer {LOCAL_KEY}" + await self.scenario(ws, request) + transport = request.transport + if ( + not ws.closed + and transport is not None + and not transport.is_closing() + ): + # Readers must stop on terminal events without waiting for EOF. + message = await ws.receive(timeout=SERVER_TIMEOUT) + assert message.type in (WSMsgType.CLOSE, WSMsgType.CLOSED), ( + "expected client close after scenario", + message, + ) + self.protocol_completed.set() + except asyncio.CancelledError: + raise + except BaseException as error: + # aiohttp would otherwise mask handler assertions as disconnects. + self._record_error(error) + finally: + try: + await asyncio.wait_for(ws.close(), timeout=TIMEOUT) + except Exception as error: + self._record_error(error) + self.handler_done.set() + return ws + + async def _serve(self): + self.stop_signal = asyncio.Event() + app = web.Application() + app.router.add_get("/audio", self._handle) + runner = web.AppRunner(app, shutdown_timeout=TIMEOUT) + try: + await runner.setup() + site = web.TCPSite(runner, host="127.0.0.1", port=0) + await site.start() + port = runner.addresses[0][1] + self.url = f"ws://127.0.0.1:{port}/audio" + self.ready.set() + await self.stop_signal.wait() + finally: + await asyncio.wait_for(runner.cleanup(), timeout=SERVER_TIMEOUT) + + def _run(self): + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + try: + self.loop.run_until_complete(self._serve()) + except BaseException as error: + self._record_error(error) + finally: + self.ready.set() + pending = asyncio.all_tasks(self.loop) + for task in pending: + task.cancel() + if pending: + _, remaining = self.loop.run_until_complete( + asyncio.wait(pending, timeout=TIMEOUT), + ) + if remaining: + self._record_error( + AssertionError("server tasks did not stop"), + ) + self.loop.close() + asyncio.set_event_loop(None) + + def start(self): + self.thread.start() + assert self.ready.wait( + SERVER_TIMEOUT, + ), "local WebSocket startup timed out" + self._raise_server_error() + assert self.url is not None, "local WebSocket server did not bind" + + def close(self, *, expect_completed): + if expect_completed: + self.handler_done.wait(SERVER_TIMEOUT) + if self.loop is not None and not self.loop.is_closed(): + if self.stop_signal is not None: + self.loop.call_soon_threadsafe(self.stop_signal.set) + self.thread.join(SERVER_TIMEOUT + TIMEOUT) + # Preserve the server assertion that caused the client-side EOF. + self._raise_server_error() + assert ( + not self.thread.is_alive() + ), "local WebSocket thread did not stop" + if expect_completed: + assert ( + self.protocol_completed.is_set() + ), "server scenario did not finish" + + +@pytest.fixture +def local_ws_server(monkeypatch): + # A developer's HTTP proxy must never route these loopback connections out. + monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost") + monkeypatch.setenv("no_proxy", "127.0.0.1,localhost") + + @contextmanager + def serve(scenario): + server = _LocalWebSocketServer(scenario) + failed = False + try: + server.start() + yield server + except BaseException: + failed = True + raise + finally: + server.close(expect_completed=not failed) + + return serve + + +@pytest.fixture(autouse=True) +def closed_client_sockets(monkeypatch): + connect = websocket.create_connection + sockets = [] + + def tracked_connect(*args, **kwargs): + ws = connect(*args, **kwargs) + sockets.append(ws) + return ws + + monkeypatch.setattr(websocket, "create_connection", tracked_connect) + yield + try: + assert all(ws.sock is None for ws in sockets) + finally: + for ws in sockets: + ws.shutdown() + + +@pytest.fixture +def client(): + with Client( + api_key=LOCAL_KEY, + workspace="ws-local-integration", + region="cn-beijing", + protocol="native", + base_url="http://127.0.0.1:1", + timeout=TIMEOUT, + max_retries=0, + ) as value: + yield value + + +async def _receive_json(ws): + message = await ws.receive(timeout=SERVER_TIMEOUT) + assert message.type == WSMsgType.TEXT, ( + "expected JSON text opcode", + message, + ) + frame = json.loads(message.data) + assert isinstance(frame, dict) + return frame + + +async def _receive_binary(ws, expected): + message = await ws.receive(timeout=SERVER_TIMEOUT) + assert message.type == WSMsgType.BINARY, ( + "expected binary audio opcode", + message, + ) + assert message.data == expected + + +def _task_event(kind, task_id, payload=None): + return { + "header": {"event": kind, "task_id": task_id}, + "payload": {} if payload is None else payload, + } + + +async def _receive_run(ws, *, task, model): + frame = await _receive_json(ws) + header = frame["header"] + assert header["action"] == "run-task" + assert header["streaming"] == "duplex" + assert isinstance(header["task_id"], str) and header["task_id"] + payload = frame["payload"] + assert payload["task_group"] == "audio" + assert payload["task"] == task + assert payload["function"] == ( + "recognition" if task == "asr" else "SpeechSynthesizer" + ) + assert payload["model"] == model + assert payload["input"] == {} # Required even for initially empty input. + return frame + + +async def _receive_action(ws, task_id, action, input_data): + frame = await _receive_json(ws) + assert frame["header"] == { + "action": action, + "task_id": task_id, + "streaming": "duplex", + } + assert frame["payload"] == {"input": input_data} + + +async def _wait_for_return(gate): + # This gate opens only after the nonblocking client method returns. + await asyncio.wait_for(asyncio.wrap_future(gate), timeout=SERVER_TIMEOUT) + + +async def _receive_realtime_event(ws, expected, event_ids): + event = await _receive_json(ws) + event_id = event.pop("event_id") + assert isinstance(event_id, str) and event_id + assert event_id not in event_ids + event_ids.add(event_id) + assert event == expected + + +def test_asr_binary_duplex_context_and_results_after_stop( + client, + local_ws_server, +): + model = "fun-asr-realtime" + first_audio = b"first-pcm\x00\x01" + second_audio = b"second-pcm\x80\xff" + context = {"context": {"text": "上一轮的识别上下文"}} + partial_payload = { + "output": { + "sentence": { + "text": "第一段", + "sentence_end": False, + }, + }, + } + final_payload = { + "output": { + "sentence": { + "text": "第一段,第二段。", + "sentence_end": True, + }, + }, + } + usage = {"duration": 2.5} + first_result_read = Future() + stop_returned = Future() + + async def scenario(ws, _request): + run = await _receive_run(ws, task="asr", model=model) + task_id = run["header"]["task_id"] + assert run["payload"]["parameters"]["format"] == "pcm" + assert run["payload"]["parameters"]["sample_rate"] == 16000 + await ws.send_json(_task_event("task-started", task_id)) + await _receive_binary(ws, first_audio) + await ws.send_json( + _task_event("result-generated", task_id, partial_payload), + ) + await _receive_action(ws, task_id, "continue-task", context) + assert ( + first_result_read.done() + ), "context sent before first result was read" + await _receive_binary(ws, second_audio) + await _receive_action(ws, task_id, "finish-task", {}) + await _wait_for_return(stop_returned) + await ws.send_json( + _task_event("result-generated", task_id, final_payload), + ) + await ws.send_json( + _task_event("task-finished", task_id, {"usage": usage}), + ) + + with local_ws_server(scenario) as server: + with client.audio.asr.recognize( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as recognition: + task_id = recognition.session.task_id + assert recognition.last_response == _task_event( + "task-started", + task_id, + ) + events = recognition.events() + recognition.send_audio_frame(first_audio) + assert next(events) == _task_event( + "result-generated", + task_id, + partial_payload, + ) + first_result_read.set_result(None) + recognition.update_context(context) + recognition.send_audio_frame(second_audio) + recognition.stop() + stop_returned.set_result(None) + finished = _task_event("task-finished", task_id, {"usage": usage}) + assert list(events) == [ + _task_event("result-generated", task_id, final_payload), + finished, + ] + assert recognition.last_response == finished + assert recognition.usage == usage + + +@pytest.mark.parametrize("reader", ["events", "audio_chunks"]) +def test_tts_reads_audio_before_second_text_and_drains_stop( + client, + local_ws_server, + reader, +): + model = "cosyvoice-v1" + first_text, second_text = "第一段。", "第二段。" + first_audio, second_audio = b"\x00\x81first-audio", b"\xff\x02second-audio" + first_subtitle = { + "output": {"sentence": {"text": first_text, "begin_time": 0}}, + } + second_subtitle = { + "output": {"sentence": {"text": second_text, "begin_time": 500}}, + } + usage = {"characters": len(first_text + second_text)} + first_audio_read = Future() + stop_returned = Future() + + async def scenario(ws, _request): + run = await _receive_run(ws, task="tts", model=model) + task_id = run["header"]["task_id"] + assert run["payload"]["parameters"]["voice"] == "longxiaochun" + await ws.send_json(_task_event("task-started", task_id)) + await _receive_action( + ws, + task_id, + "continue-task", + {"text": first_text}, + ) + await ws.send_bytes(first_audio) + await ws.send_json( + _task_event("result-generated", task_id, first_subtitle), + ) + await _receive_action(ws, task_id, "continue-task", {"flush": True}) + assert ( + first_audio_read.done() + ), "flush sent before first audio was consumed" + await _receive_action( + ws, + task_id, + "continue-task", + {"text": second_text}, + ) + assert ( + first_audio_read.done() + ), "second text sent before first audio was consumed" + await _receive_action(ws, task_id, "finish-task", {}) + await _wait_for_return(stop_returned) + await ws.send_bytes(second_audio) + await ws.send_json( + _task_event("result-generated", task_id, second_subtitle), + ) + await ws.send_json( + _task_event("task-finished", task_id, {"usage": usage}), + ) + + with local_ws_server(scenario) as server: + with client.audio.tts.connect( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as synthesis: + task_id = synthesis.session.task_id + assert synthesis.last_response == _task_event( + "task-started", + task_id, + ) + frames = getattr(synthesis, reader)() + synthesis.send_text(first_text) + assert next(frames) == first_audio + first_audio_read.set_result(None) + synthesis.flush() + synthesis.send_text(second_text) + synthesis.stop() + stop_returned.set_result(None) + finished = _task_event("task-finished", task_id, {"usage": usage}) + remaining = list(frames) + if reader == "events": + assert remaining == [ + _task_event("result-generated", task_id, first_subtitle), + second_audio, + _task_event("result-generated", task_id, second_subtitle), + finished, + ] + else: + assert remaining == [second_audio] + assert all(isinstance(chunk, bytes) for chunk in remaining) + assert synthesis.last_response == finished + assert synthesis.usage == usage + + +def test_tts_cancel_after_first_audio_uses_finish_directive( + client, + local_ws_server, +): + model = "cosyvoice-v1" + cancel_returned = Future() + + async def scenario(ws, _request): + run = await _receive_run(ws, task="tts", model=model) + task_id = run["header"]["task_id"] + await ws.send_json(_task_event("task-started", task_id)) + await _receive_action( + ws, + task_id, + "continue-task", + {"text": "stop this speech"}, + ) + await ws.send_bytes(b"first-audio") + await _receive_action( + ws, + task_id, + "finish-task", + {"directive": "cancel"}, + ) + await _wait_for_return(cancel_returned) + await ws.send_json(_task_event("task-finished", task_id)) + + with local_ws_server(scenario) as server: + with client.audio.tts.connect( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as synthesis: + task_id = synthesis.session.task_id + events = synthesis.events() + synthesis.send_text("stop this speech") + assert next(events) == b"first-audio" + synthesis.cancel() + cancel_returned.set_result(None) + assert list(events) == [_task_event("task-finished", task_id)] + + +def test_omni_manual_commit_two_responses_and_session_finish( + client, + local_ws_server, +): + model = "qwen-omni-turbo-realtime" + session_id = "local-omni-session" + audio_inputs = [b"\x00\xffround-one", b"\x80\x01round-two"] + settings = { + "modalities": ["text", "audio"], + "input_audio_format": "pcm16", + "turn_detection": None, + } + created = {"type": "session.created", "session": {"id": session_id}} + updated = { + "type": "session.updated", + "session": {"id": session_id, **settings}, + } + finished = {"type": "session.finished", "session": {"id": session_id}} + rounds = [] + for index in range(2): + response_id = f"response-{index}" + rounds.append( + [ + { + "type": "input_audio_buffer.committed", + "item_id": f"item-{index}", + }, + {"type": "response.created", "response": {"id": response_id}}, + { + "type": "response.audio.delta", + "response_id": response_id, + "delta": base64.b64encode( + b"\x00\xffreply" + bytes([index]), + ).decode("ascii"), + }, + { + "type": "response.audio_transcript.delta", + "response_id": response_id, + "delta": f"回答{index}", + }, + { + "type": "response.done", + "response": { + "id": response_id, + "usage": {"total_tokens": index + 3}, + }, + }, + ], + ) + response_read = [Future(), Future()] + end_returned = Future() + event_ids = set() + + async def receive_event(ws, kind): + event = await _receive_json(ws) + assert event["type"] == kind + event_id = event.pop("event_id") + assert isinstance(event_id, str) and event_id + assert event_id not in event_ids + event_ids.add(event_id) + assert "header" not in event and "payload" not in event + return event + + async def scenario(ws, request): + assert request.query["model"] == model + await ws.send_json(created) + assert await receive_event(ws, "session.update") == { + "type": "session.update", + "session": settings, + } + await ws.send_json(updated) + for index, audio in enumerate(audio_inputs): + event = await receive_event(ws, "input_audio_buffer.append") + assert set(event) == {"type", "audio"} + assert base64.b64decode(event["audio"], validate=True) == audio + if index: + assert response_read[ + index - 1 + ].done(), "next turn sent before response.done" + assert await receive_event(ws, "input_audio_buffer.commit") == { + "type": "input_audio_buffer.commit", + } + response = await receive_event(ws, "response.create") + assert response["response"]["modalities"] == ["text", "audio"] + for event in rounds[index]: + await ws.send_json(event) + assert await receive_event(ws, "session.finish") == { + "type": "session.finish", + } + assert response_read[-1].done() + await _wait_for_return(end_returned) + await ws.send_json(finished) + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as realtime: + assert realtime.recv_event() == created + realtime.send_event( + {"type": "session.update", "session": settings}, + ) + events = realtime.events() + assert next(events) == updated + for index, audio in enumerate(audio_inputs): + realtime.append_audio(audio) + realtime.commit() + realtime.create_response(modalities=["text", "audio"]) + received = [next(events) for _ in rounds[index]] + assert received == rounds[index] + assert base64.b64decode( + received[2]["delta"], + validate=True, + ) == (b"\x00\xffreply" + bytes([index])) + response_read[index].set_result(None) + realtime.end_session() + end_returned.set_result(None) + assert list(events) == [finished] + assert realtime.last_message == finished + + +def test_qwen_audio_manual_turns_recover_error_and_close( + client, + local_ws_server, +): + model = "qwen-audio-3.1-realtime-plus" + session_id = "local-qwen-audio-session" + settings = {"modalities": ["text", "audio"], "turn_detection": None} + instructions = "只描述本轮听到的声音。" + created = {"type": "session.created", "session": {"id": session_id}} + updated = { + "type": "session.updated", + "session": {"id": session_id, **settings}, + } + instruction_updated = { + "type": "session.updated", + "session": {"id": session_id, "instructions": instructions}, + } + invalid_update = { + "type": "session.update", + "session": {"instructions": 123}, + } + rejected = { + "type": "error", + "event_id": "server-invalid-instructions", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "instructions must be a string", + "param": "session.instructions", + }, + } + environment_transcript = json.dumps( + {"text": "有人敲门", "environment": {"sound": "knocking"}}, + ensure_ascii=False, + ) + tool_arguments = json.dumps({"sound": "knocking", "count": 2}) + audio_inputs = [b"\x00\xffknocking", b"\x80\x01footsteps"] + rounds = [] + for index in range(2): + response_id = f"audio-response-{index}" + rounds.append( + [ + { + "type": "input_audio_buffer.committed", + "item_id": f"audio-item-{index}", + }, + {"type": "response.created", "response": {"id": response_id}}, + { + "type": "response.audio_transcript.delta", + "response_id": response_id, + "delta": "敲门声" if index == 0 else "脚步声", + }, + { + "type": "response.audio.delta", + "response_id": response_id, + "delta": base64.b64encode( + b"\x00\xffreply" + bytes([index]), + ).decode("ascii"), + }, + { + "type": "response.done", + "response": { + "id": response_id, + "status": "completed", + "usage": { + "total_tokens": 12 + index, + "input_tokens": 8, + "output_tokens": 4 + index, + "input_token_details": {"audio_tokens": 8}, + }, + }, + }, + ], + ) + rounds[0][2:2] = [ + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "audio-item-0", + "content_index": 0, + "transcript": environment_transcript, + }, + { + "type": "response.output_item.done", + "response_id": "audio-response-0", + "output_index": 0, + "item": { + "id": "tool-item-0", + "type": "function_call", + "status": "completed", + "call_id": "call-0", + "name": "describe_environment", + "arguments": tool_arguments, + }, + }, + ] + response_read = [Future(), Future()] + event_ids = set() + + async def scenario(ws, request): + assert request.query["model"] == model + await ws.send_json(created) + await _receive_realtime_event( + ws, + {"type": "session.update", "session": settings}, + event_ids, + ) + await ws.send_json(updated) + for index, audio in enumerate(audio_inputs): + if index: + await _wait_for_return(response_read[0]) + await _receive_realtime_event(ws, invalid_update, event_ids) + await ws.send_json(rejected) + # A partial update must not resend modalities or old defaults. + await _receive_realtime_event( + ws, + { + "type": "session.update", + "session": {"instructions": instructions}, + }, + event_ids, + ) + await ws.send_json(instruction_updated) + await _receive_realtime_event( + ws, + { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(audio).decode("ascii"), + }, + event_ids, + ) + await _receive_realtime_event( + ws, + {"type": "input_audio_buffer.commit"}, + event_ids, + ) + # Qwen-Audio needs an explicit create after each manual commit. + await _receive_realtime_event( + ws, + {"type": "response.create", "response": {}}, + event_ids, + ) + for event in rounds[index]: + await ws.send_json(event) + message = await ws.receive(timeout=SERVER_TIMEOUT) + # This protocol has no session.finish: context exit sends a close. + assert message.type == WSMsgType.CLOSE + assert message.data == 1000 + assert response_read[-1].done() + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as realtime: + assert realtime.recv_event() == created + realtime.update_session( + ["text", "audio"], + enable_turn_detection=False, + ) + events = realtime.events() + assert next(events) == updated + for index, audio in enumerate(audio_inputs): + if index: + realtime.send_event(invalid_update) + with pytest.raises( + BadRequestError, + match="instructions must be a string", + ) as caught: + realtime.recv_event() + assert caught.value.body == rejected + assert caught.value.code == "InvalidParameter" + assert realtime.last_message == rejected + realtime.update_session(instructions=instructions) + assert next(events) == instruction_updated + realtime.append_audio(audio) + realtime.commit() + realtime.create_response() + assert [next(events) for _ in rounds[index]] == rounds[index] + assert realtime.last_response_id == f"audio-response-{index}" + response_read[index].set_result(None) + assert realtime.session_id == session_id + assert realtime.last_message == rounds[-1][-1] + assert realtime.recv_event() is None + + +@pytest.mark.parametrize( + "model, modality_key, text_event_type", + [ + ( + "qwen3-livetranslate-flash-realtime", + "modalities", + "response.audio_transcript.text", + ), + ( + "qwen3.5-livetranslate-flash-realtime", + "modalities", + "response.text.text", + ), + ( + "qwen3.8-livetranslate-flash-realtime", + "output_modalities", + "response.audio_transcript.delta", + ), + ], + ids=["qwen3", "qwen3.5", "qwen3.8"], +) +def test_livetranslate_auto_response_media_and_nonblocking_finish( + client, + local_ws_server, + model, + modality_key, + text_event_type, +): + session_id = "local-livetranslate-session" + response_id = "translation-response" + settings = { + modality_key: ["text", "audio"], + "translation": {"language": "en"}, + } + audio_settings = {} + if modality_key == "output_modalities": + audio_settings = { + "input_audio_config": {"type": "pcm"}, + "sample_rate": 16000, + } + settings["audio"] = { + "input": {"format": {"type": "pcm", "sample_rate": 16000}}, + } + created = {"type": "session.created", "session": {"id": session_id}} + updated = { + "type": "session.updated", + "session": {"id": session_id, **settings}, + } + audio = b"\x00\xffsource-speech" + audio_b64 = base64.b64encode(audio).decode("ascii") + image = b"\xff\xd8\xffloopback-image\xff\xd9" + image_b64 = base64.b64encode(image).decode("ascii") + translated_audio = b"\x80\x01translated-speech" + is_legacy = modality_key == "modalities" + translated_text = ( + {"text": "Good morning", "stash": ", everyone"} + if is_legacy + else {"delta": "Good morning, everyone"} + ) + source_text = ( + {"text": "早上好", "stash": ",各位"} if is_legacy else {"delta": "早上好,各位"} + ) + streaming_events = [ + {"type": "input_audio_buffer.committed", "item_id": "source-item"}, + {"type": "response.created", "response": {"id": response_id}}, + { + "type": "conversation.item.input_audio_transcription." + + ("text" if is_legacy else "delta"), + "item_id": "source-item", + "content_index": 0, + "language": "zh", + "begin_time": 0, + "end_time": 640, + "speaker_id": "speaker-0", + **source_text, + }, + { + "type": text_event_type, + "response_id": response_id, + "item_id": "translation-item", + "content_index": 0, + "begin_time": 0, + "end_time": 720, + "speaker_id": "speaker-0", + **translated_text, + }, + { + "type": "response.audio.delta", + "response_id": response_id, + "delta": base64.b64encode(translated_audio).decode("ascii"), + }, + ] + usage = { + "total_tokens": 19, + "input_tokens": 12, + "output_tokens": 7, + "input_token_details": {"audio_tokens": 10, "text_tokens": 2}, + "output_token_details": {"audio_tokens": 5, "text_tokens": 2}, + } + finished = { + "type": "session.finished", + "event_id": "server-final-event", + "session": {"id": session_id}, + } + final_events = [ + { + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "source-item", + "transcript": "早上好,各位。", + "begin_time": 0, + "end_time": 800, + "speaker_id": "speaker-0", + }, + { + "type": "response.audio_transcript.done", + "response_id": response_id, + "transcript": "Good morning, everyone.", + }, + { + "type": "response.done", + "response": {"id": response_id, "usage": usage}, + }, + finished, + ] + end_returned = Future() + event_ids = set() + + async def scenario(ws, request): + assert request.query["model"] == model + await ws.send_json(created) + await _receive_realtime_event( + ws, + {"type": "session.update", "session": settings}, + event_ids, + ) + await ws.send_json(updated) + for _ in range(2): + await _receive_realtime_event( + ws, + {"type": "input_audio_buffer.append", "audio": audio_b64}, + event_ids, + ) + if is_legacy: + for _ in range(2): + await _receive_realtime_event( + ws, + {"type": "input_image_buffer.append", "image": image_b64}, + event_ids, + ) + await _receive_realtime_event( + ws, + {"type": "input_audio_buffer.commit"}, + event_ids, + ) + # Live creates the response itself; the next client frame must finish. + for event in streaming_events: + await ws.send_json(event) + await _receive_realtime_event( + ws, + {"type": "session.finish"}, + event_ids, + ) + # Withhold trailing events until end_session has actually returned. + await _wait_for_return(end_returned) + for event in final_events: + await ws.send_json(event) + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as realtime: + assert realtime.recv_event() == created + realtime.update_session( + output_modalities=["text", "audio"], + translation_params={"language": "en"}, + **audio_settings, + ) + events = realtime.events() + assert next(events) == updated + realtime.append_audio(audio) + realtime.append_audio(audio_b64) + if is_legacy: + realtime.append_video(image) + realtime.append_video(image_b64) + realtime.commit() + received = [next(events) for _ in streaming_events] + assert received == streaming_events + assert ( + base64.b64decode(received[-1]["delta"], validate=True) + == translated_audio + ) + assert realtime.last_first_text_delay is not None + assert realtime.last_first_text_delay >= 0 + assert realtime.last_first_audio_delay is not None + first_text_delay = realtime.last_first_text_delay + realtime.end_session() + end_returned.set_result(None) + assert list(events) == final_events + assert realtime.last_message == finished + assert realtime.last_first_text_delay == first_text_delay + assert realtime.recv_event() is None + # session.finished releases the socket before context exit. + assert server.handler_done.wait(SERVER_TIMEOUT) + assert server.protocol_completed.is_set() + + +@pytest.mark.parametrize( + "task, model", + [ + ("asr", "fun-asr-realtime"), + ("tts", "cosyvoice-v1"), + ], +) +@pytest.mark.parametrize( + "during_start", + [True, False], + ids=["handshake", "streaming"], +) +@pytest.mark.parametrize( + "code, error_type", + [ + ("InvalidParameter", BadRequestError), + ("Throttling", RateLimitError), + ], +) +def test_header_task_failure_maps_v2_error_and_preserves_task_id( + client, + local_ws_server, + task, + model, + during_start, + code, + error_type, +): + observed = {} + message = "local server rejected this input" + + async def scenario(ws, _request): + run = await _receive_run(ws, task=task, model=model) + task_id = run["header"]["task_id"] + failed = _task_event("task-failed", task_id) + failed["header"].update(error_code=code, error_message=message) + observed.update(task_id=task_id, failed=failed) + if not during_start: + await ws.send_json(_task_event("task-started", task_id)) + if task == "asr": + await _receive_binary(ws, b"audio") + else: + await _receive_action( + ws, + task_id, + "continue-task", + {"text": "hello"}, + ) + # Deliberately no payload.code/message or header.request_id fallback. + await ws.send_json(failed) + + connect = ( + client.audio.asr.recognize + if task == "asr" + else client.audio.tts.connect + ) + with local_ws_server(scenario) as server: + with pytest.raises(error_type, match=message) as caught: + with connect( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as session: + assert ( + not during_start + ), "task-failed handshake entered the context" + if task == "asr": + session.send_audio_frame(b"audio") + else: + session.send_text("hello") + list(session.events()) + error = caught.value + assert error.code == code + assert error.message == message + assert error.request_id == observed["task_id"] + assert error.body == observed["failed"] + assert error.body["header"]["task_id"] == observed["task_id"] + assert observed["task_id"] in str(error) + + +@pytest.mark.parametrize("disconnect", ["close-frame", "tcp-abort"]) +def test_tts_stream_disconnect_does_not_silently_return_truncated_audio( + client, + local_ws_server, + disconnect, +): + model = "cosyvoice-v1" + first_audio_read = Future() + + async def scenario(ws, request): + run = await _receive_run(ws, task="tts", model=model) + task_id = run["header"]["task_id"] + await ws.send_json(_task_event("task-started", task_id)) + await _receive_action(ws, task_id, "continue-task", {"text": "hello"}) + await _receive_action(ws, task_id, "finish-task", {}) + await ws.send_bytes(b"partial-audio") + await _wait_for_return(first_audio_read) + # Even a normal close without task-finished means truncated audio. + if disconnect == "close-frame": + await ws.close(code=1000) + else: + assert request.transport is not None + request.transport.abort() + + with local_ws_server(scenario) as server: + chunks = client.audio.tts.stream( + "hello", + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) + try: + assert next(chunks) == b"partial-audio" + first_audio_read.set_result(None) + # A timeout or StopIteration must not mask a premature disconnect. + with pytest.raises( + (APIConnectionError, WebSocketConnectionClosedException), + ): + list(chunks) + finally: + chunks.close() + + +def test_local_server_propagates_original_handler_assertion( + client, + local_ws_server, +): + original = AssertionError("server-side protocol assertion sentinel") + + async def scenario(ws, _request): + await _receive_run(ws, task="asr", model="fun-asr-realtime") + raise original + + with pytest.raises( + AssertionError, + match="protocol assertion sentinel", + ) as caught: + with local_ws_server(scenario) as server: + with client.audio.asr.recognize( + model="fun-asr-realtime", + ws_url=server.url, + timeout=TIMEOUT, + ): + pytest.fail("server assertion should prevent the handshake") + assert caught.value is original + assert not server.thread.is_alive() + + +@pytest.mark.parametrize("active_response", [False, True]) +def test_omni_peer_close_releases_socket( + client, + local_ws_server, + active_response, +): + async def scenario(ws, _request): + await ws.send_json({"type": "session.created"}) + if active_response: + await ws.send_json( + { + "type": "response.created", + "response": {"id": "response-1"}, + }, + ) + await ws.close(code=1000) + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model="qwen-omni-turbo-realtime", + ws_url=server.url, + ) as realtime: + if active_response: + with pytest.raises(APIConnectionError, match="response.done"): + list(realtime.events()) + else: + assert list(realtime.events()) == [{"type": "session.created"}] + + +@pytest.mark.parametrize( + "model", + [ + "qwen3-livetranslate-flash-realtime", + "qwen3.5-livetranslate-flash-realtime", + "qwen3.8-livetranslate-flash-realtime", + ], +) +@pytest.mark.parametrize("disconnect", ["close-frame", "tcp-abort"]) +def test_livetranslate_response_done_does_not_replace_finish_ack( + client, + local_ws_server, + model, + disconnect, +): + async def scenario(ws, request): + await ws.send_json({"type": "session.created"}) + await _receive_realtime_event(ws, {"type": "session.finish"}, set()) + await ws.send_json( + {"type": "response.created", "response": {"id": "last-response"}}, + ) + await ws.send_json( + {"type": "response.done", "response": {"id": "last-response"}}, + ) + if disconnect == "close-frame": + await ws.close(code=1000) + else: + assert request.transport is not None + request.transport.abort() + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + timeout=TIMEOUT, + ) as realtime: + assert realtime.recv_event() == {"type": "session.created"} + realtime.end_session() + with pytest.raises( + APIConnectionError, + match="before session.finished", + ): + list(realtime.events()) + assert realtime.last_message["type"] == "response.done" diff --git a/tests/v2/test_base_url_routing.py b/tests/v2/test_base_url_routing.py new file mode 100644 index 00000000..c656c043 --- /dev/null +++ b/tests/v2/test_base_url_routing.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Request-URL routing across resources and protocols. + +Only the protocol-adapted chat family (chat.completions / multimodal.chat) +uses ``Config.protocol_base_url``; every other resource talks to the native +gateway unless base_url was explicitly configured. The one exception is +``client.decision``: its System One endpoint is pinned to the +compatible-mode path on every gateway regardless of the chat protocol +(covered in test_decision.py). +""" + +# pytest fixture injection looks like unused/redefined names to pylint. +# pylint: disable=redefined-outer-name,unused-argument + +from __future__ import annotations + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client + +# Default routes use the workspace-scoped MaaS gateway when a workspace +# is configured; without one (and no explicit base_url) they fall back to +# the legacy shared gateway with a warning. +_WS_HOST = "https://ws-123.cn-beijing.maas.aliyuncs.com" +_WS_NATIVE = _WS_HOST + "/api/v1" +_WS_OPENAI = _WS_HOST + "/compatible-mode/v1" +_WS_ANTHROPIC = _WS_HOST + "/apps/anthropic" + +_EMBEDDINGS_PATH = "/services/embeddings/text-embedding/text-embedding" +_NATIVE_CHAT_PATH = "/services/aigc/text-generation/generation" + +_OPENAI_CHAT = { + "id": "chatcmpl-1", + "model": "qwen-plus", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "hi"}, + }, + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} +_ANTHROPIC_CHAT = { + "id": "msg_1", + "model": "qwen-plus", + "role": "assistant", + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, +} +_NATIVE_CHAT = { + "request_id": "req-1", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "hi"}, + }, + ], + }, + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, +} +_EMBEDDINGS = { + "output": {"embeddings": [{"text_index": 0, "embedding": [0.1]}]}, + "usage": {"total_tokens": 1}, +} + + +@pytest.fixture +def clean_env(monkeypatch): + from dashscope.v2._config import settings + + for name in ( + "DASHSCOPE_API_KEY", + "DASHSCOPE_BASE_URL", + "DASHSCOPE_HTTP_BASE_URL", + "DASHSCOPE_WORKSPACE", + "DASHSCOPE_PROTOCOL", + "DASHSCOPE_CONFIG", + ): + monkeypatch.delenv(name, raising=False) + settings.clear() + yield monkeypatch + settings.clear() + + +def _handler(urls): + def handle(request): + urls.append(str(request.url)) + path = request.url.path + if path.endswith("/chat/completions"): + return httpx.Response(200, json=_OPENAI_CHAT) + if path.endswith("/v1/messages"): + return httpx.Response(200, json=_ANTHROPIC_CHAT) + if path.endswith(_NATIVE_CHAT_PATH): + return httpx.Response(200, json=_NATIVE_CHAT) + return httpx.Response(200, json=_EMBEDDINGS) + + return handle + + +def _client(urls, mock_base_url=_WS_NATIVE, **kwargs): + http_client = httpx.Client( + transport=httpx.MockTransport(_handler(urls)), + base_url=mock_base_url, + ) + return Client(api_key="sk-test", http_client=http_client, **kwargs) + + +def test_openai_protocol_routes_only_chat_family_to_compatible_mode( + clean_env, +): + urls = [] + client = _client(urls, workspace="ws-123", protocol="openai") + client.chat.completions.create(model="qwen-plus", messages="hi") + client.multimodal.chat.create(model="qwen-vl-plus", messages="hi") + client.embeddings.create(model="text-embedding-v3", input="hi") + client.rerank.create(model="gte-rerank-v2", query="q", documents=["d"]) + assert urls == [ + _WS_OPENAI + "/chat/completions", + _WS_OPENAI + "/chat/completions", + _WS_NATIVE + _EMBEDDINGS_PATH, + _WS_NATIVE + "/services/rerank/text-rerank/text-rerank", + ] + client.close() + + +def test_anthropic_protocol_keeps_native_resources_on_native_gateway( + clean_env, +): + urls = [] + client = _client(urls, workspace="ws-123", protocol="anthropic") + client.chat.completions.create(model="qwen-plus", messages="hi") + client.embeddings.create(model="text-embedding-v3", input="hi") + assert urls == [ + _WS_ANTHROPIC + "/v1/messages", + _WS_NATIVE + _EMBEDDINGS_PATH, + ] + client.close() + + +def test_explicit_base_url_covers_all_resources(clean_env): + explicit = "https://gateway.example.com/v1" + urls = [] + client = _client( + urls, + mock_base_url=explicit, + base_url=explicit, + protocol="openai", + ) + client.chat.completions.create(model="qwen-plus", messages="hi") + client.embeddings.create(model="text-embedding-v3", input="hi") + assert urls == [ + explicit + "/chat/completions", + explicit + _EMBEDDINGS_PATH, + ] + client.close() + + +def test_workspace_gateway_covers_all_protocols(clean_env): + ws_host = "https://ws-123.cn-beijing.maas.aliyuncs.com" + urls = [] + client = _client( + urls, + mock_base_url=ws_host + "/api/v1", + workspace="ws-123", + protocol="anthropic", + ) + client.chat.completions.create(model="qwen-plus", messages="hi") + client.embeddings.create(model="text-embedding-v3", input="hi") + assert urls == [ + ws_host + "/apps/anthropic/v1/messages", + ws_host + "/api/v1" + _EMBEDDINGS_PATH, + ] + client.close() + + +def test_no_workspace_falls_back_to_legacy_gateway(clean_env, tmp_path): + legacy = "https://dashscope.aliyuncs.com" + urls = [] + with pytest.warns(UserWarning, match="legacy shared gateway"): + client = _client( + urls, + mock_base_url=legacy + "/api/v1", + protocol="openai", + config_path=tmp_path / "absent.toml", + ) + client.chat.completions.create(model="qwen-plus", messages="hi") + client.embeddings.create(model="text-embedding-v3", input="hi") + assert urls == [ + legacy + "/compatible-mode/v1/chat/completions", + legacy + "/api/v1" + _EMBEDDINGS_PATH, + ] + client.close() + + +def test_with_options_re_resolves_protocol_default_base_url(clean_env): + urls = [] + parent = _client(urls, workspace="ws-123") + derived = parent.with_options(protocol="openai") + derived.chat.completions.create(model="qwen-plus", messages="hi") + parent.chat.completions.create(model="qwen-plus", messages="hi") + assert urls == [ + _WS_OPENAI + "/chat/completions", + _WS_NATIVE + _NATIVE_CHAT_PATH, + ] + derived.close() + parent.close() + + +@pytest.mark.asyncio +async def test_async_openai_protocol_routing(clean_env): + urls = [] + + def handle(request): + urls.append(str(request.url)) + if request.url.path.endswith("/chat/completions"): + return httpx.Response(200, json=_OPENAI_CHAT) + return httpx.Response(200, json=_EMBEDDINGS) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handle), + base_url=_WS_NATIVE, + ) + client = AsyncClient( + api_key="sk-test", + workspace="ws-123", + protocol="openai", + http_client=http_client, + ) + await client.chat.completions.create(model="qwen-plus", messages="hi") + await client.embeddings.create(model="text-embedding-v3", input="hi") + assert urls == [ + _WS_OPENAI + "/chat/completions", + _WS_NATIVE + _EMBEDDINGS_PATH, + ] + await client.aclose() diff --git a/tests/v2/test_chat.py b/tests/v2/test_chat.py new file mode 100644 index 00000000..949bd3b5 --- /dev/null +++ b/tests/v2/test_chat.py @@ -0,0 +1,1512 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.chat (M1 task 1.6).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import ContentFilterError, RateLimitError +from dashscope.v2.types.completions import parse_completion + +BASE_URL = "https://api.test.local/api/v1" +CHAT_PATH = "/api/v1/services/aigc/text-generation/generation" +MULTIMODAL_PATH = "/api/v1/services/aigc/multimodal-generation/generation" +OPENAI_PATH = "/api/v1/chat/completions" + + +def _sse(events): + out = [] + for i, data in enumerate(events, 1): + payload = data if isinstance(data, str) else json.dumps(data) + out.append(f"id: {i}\ndata: {payload}\n\n") + return "".join(out) + + +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 test_create_non_streaming(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "Hello!", + "reasoning_content": "thinking", + }, + }, + ], + }, + "usage": { + "input_tokens": 5, + "output_tokens": 3, + "total_tokens": 8, + }, + "request_id": "req-1", + }, + ) + + client = _make_client(handler) + resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + temperature=0.5, + enable_thinking=True, + ) + assert captured["path"] == CHAT_PATH + body = captured["body"] + assert body["model"] == "qwen-plus" + assert body["input"]["messages"] == [{"role": "user", "content": "hi"}] + assert body["parameters"]["result_format"] == "message" + assert body["parameters"]["temperature"] == 0.5 + assert body["parameters"]["enable_thinking"] is True + assert resp.output_text == "Hello!" + assert resp.choices[0].message.reasoning_content == "thinking" + assert resp.usage.total_tokens == 8 + assert resp.request_id == "req-1" + + +def test_create_carries_raw_response_headers(): + # 1.x parity (GenerationResponse.headers): the typed success + # envelope mirrors the gateway's HTTP response headers. + def handler(request): + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"content": "hi"}, + }, + ], + }, + "request_id": "req-h", + }, + headers={"x-request-id": "req-h", "x-gw-region": "cn-hz"}, + ) + + client = _make_client(handler) + resp = client.chat.completions.create(model="qwen-plus", messages="hi") + assert resp.headers["x-request-id"] == "req-h" + assert resp.headers["x-gw-region"] == "cn-hz" + + +def test_stream_final_message_carries_raw_response_headers(): + def handler(request): + return httpx.Response( + 200, + text=_sse( + [ + { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"content": "ok"}, + }, + ], + }, + "request_id": "req-s", + }, + ], + ), + headers={ + "content-type": "text/event-stream", + "x-gw-region": "cn-hz", + }, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + assert stream.headers["x-gw-region"] == "cn-hz" + final = stream.get_final_message() + assert final.headers["x-gw-region"] == "cn-hz" + + +def test_create_accepts_string_messages_and_parameters(): + def handler(request): + body = json.loads(request.content) + assert body["input"]["messages"] == [ + {"role": "user", "content": "Tell me a joke"}, + ] + assert body["parameters"]["top_k"] == 1 + return httpx.Response( + 200, + json={"output": {"text": "Ha-ha"}, "request_id": "r"}, + ) + + client = _make_client(handler) + resp = client.chat.completions.create( + model="qwen-plus", + messages="Tell me a joke", + parameters={"top_k": 1}, + ) + assert resp.output_text == "Ha-ha" + + +def _native_response(text="ok"): + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": text}, + }, + ], + }, + "request_id": "r", + }, + ) + + +def test_media_parts_route_to_the_multimodal_endpoint(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return _native_response() + + client = _make_client(handler) + resp = client.chat.completions.create( + model="qwen-vl-max", + messages=[ + { + "role": "user", + "content": [ + {"image": "https://example.com/cat.png"}, + {"text": "describe"}, + ], + }, + ], + ) + assert captured["path"] == MULTIMODAL_PATH + assert captured["body"]["input"]["messages"][0]["content"] == [ + {"image": "https://example.com/cat.png"}, + {"text": "describe"}, + ] + assert resp.output_text == "ok" + + +def test_text_only_stays_on_the_text_endpoint(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + return _native_response() + + client = _make_client(handler) + # A parts list holding nothing but text is still a text conversation. + client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": [{"text": "hi"}]}], + ) + assert captured["path"] == CHAT_PATH + + +def test_media_routing_keeps_compatible_protocol_path(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "resp-1", + "model": "qwen-vl-max", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "A cat.", + }, + }, + ], + }, + ) + + client = _make_client(handler, protocol="openai") + resp = client.chat.completions.create( + model="qwen-vl-max", + messages=[ + {"role": "user", "content": [{"image": "https://x.com/c.png"}]}, + ], + ) + # The adapter has no separate media endpoint; it translates the parts. + assert captured["path"] == OPENAI_PATH + assert captured["body"]["messages"][0]["content"] == [ + {"type": "image_url", "image_url": {"url": "https://x.com/c.png"}}, + ] + assert resp.output_text == "A cat." + + +def test_cache_control_marker_reaches_wire(): + # Regression: a cache_control marker on a single text content block + # used to raise pydantic ValidationError (a ValueError subclass) + # before any HTTP call; it must reach the native wire as a part-level + # sibling (1.x parity: 1.x passed blocks through unvalidated). + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "ok", + }, + }, + ], + }, + "usage": { + "input_tokens": 5, + "output_tokens": 1, + "total_tokens": 6, + }, + "request_id": "req-cc", + }, + ) + + client = _make_client(handler) + resp = client.chat.completions.create( + model="qwen3.8-max", + messages=[ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + {"role": "user", "content": "hi"}, + ], + ) + assert resp.output_text == "ok" + messages = captured["body"]["input"]["messages"] + assert messages[0]["content"] == [ + { + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ] + + +def test_search_info_parsed(): + # parse_completion is unit-tested directly so this stays independent of + # the chat resource wiring. + resp = parse_completion( + { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Hi"}, + }, + ], + "search_info": { + "search_results": [ + { + "index": 1, + "title": "DashScope docs", + "url": "https://example.com/docs", + "site_name": "example", + }, + { + "index": 2, + "title": "More", + "url": "https://example.com/more", + "site_name": "example", + }, + ], + }, + }, + "request_id": "req-si", + }, + ) + assert resp.request_id == "req-si" + assert resp.output_text == "Hi" + assert resp.search_info is not None + results = resp.search_info.search_results + assert len(results) == 2 + assert results[0].title == "DashScope docs" + assert results[0].url == "https://example.com/docs" + assert results[0].site_name == "example" + assert results[0].index == 1 + assert results[1].index == 2 + + +def test_completion_without_search_info_defaults_none(): + resp = parse_completion({"output": {"text": "ok"}}) + assert resp.search_info is None + + +def test_error_raises(): + def handler(request): + return httpx.Response( + 400, + json={"code": "DataInspectionFailed", "message": "blocked"}, + ) + + client = _make_client(handler) + with pytest.raises(ContentFilterError): + client.chat.completions.create(model="m", messages="x") + + +def test_raise_on_error_false(): + def handler(request): + return httpx.Response( + 400, + json={"code": "Throttling", "message": "slow down"}, + ) + + client = _make_client(handler, raise_on_error=False) + resp = client.chat.completions.create(model="m", messages="x") + assert resp.is_error is True + assert resp.status_code == 400 + assert resp.code == "Throttling" + assert resp.message == "slow down" + assert resp.choices == [] + + +def test_raise_on_error_false_business_code(): + def handler(request): + return httpx.Response( + 200, + json={ + "code": "InvalidApiKey", + "message": "bad key", + "request_id": "req-biz", + }, + ) + + client = _make_client(handler, raise_on_error=False) + resp = client.chat.completions.create(model="m", messages="x") + assert resp.is_error is True + assert resp.status_code == 200 + assert resp.code == "InvalidApiKey" + assert resp.message == "bad key" + assert resp.request_id == "req-biz" + + +def test_streaming_text_stream_and_final_message(): + events = [ + { + "output": { + "choices": [ + { + "finish_reason": None, + "message": { + "role": "assistant", + "content": "Hel", + "reasoning_content": "t1", + }, + }, + ], + }, + "request_id": "req-s", + }, + { + "output": { + "choices": [ + { + "finish_reason": None, + "message": { + "content": "lo", + "reasoning_content": "t2", + }, + }, + ], + }, + "request_id": "req-s", + }, + { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": ""}}, + ], + }, + "usage": { + "input_tokens": 4, + "output_tokens": 2, + "total_tokens": 6, + }, + "request_id": "req-s", + }, + ] + + def handler(request): + assert request.headers.get("X-DashScope-SSE") == "enable" + body = json.loads(request.content) + assert body["parameters"]["incremental_output"] is True + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + texts = list(stream.text_stream) + assert texts == ["Hel", "lo"] + + client2 = _make_client(handler) + stream2 = client2.chat.completions.create( + model="m", + messages="hi", + stream=True, + ) + final = stream2.get_final_message() + assert final.output_text == "Hello" + assert final.choices[0].message.reasoning_content == "t1t2" + assert final.choices[0].finish_reason == "stop" + assert final.usage.total_tokens == 6 + assert final.request_id == "req-s" + + client3 = _make_client(handler) + stream3 = client3.chat.completions.create( + model="m", + messages="hi", + stream=True, + ) + chunks = list(stream3) + assert len(chunks) == 3 + assert chunks[0].delta.content == "Hel" + + +def test_streaming_error_event_raises(): + events = [ + {"output": {"choices": [{"message": {"content": "par"}}]}}, + { + "code": "Throttling", + "message": "rate limited", + "request_id": "req-e", + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="m", + messages="hi", + stream=True, + ) + with pytest.raises(RateLimitError): + list(stream) + + +def test_openai_stream_tool_calls_fragment_aggregation(): + events = [ + { + "id": "cmpl-1", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "", + }, + }, + ], + }, + }, + ], + }, + { + "id": "cmpl-1", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": '{"city":'}, + }, + ], + }, + }, + ], + }, + { + "id": "cmpl-1", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "function": {"arguments": '"hz"}'}, + }, + { + "index": 1, + "id": "call_2", + "type": "function", + "function": {"name": "f2", "arguments": "{}"}, + }, + ], + }, + }, + ], + }, + { + "id": "cmpl-1", + "choices": [ + {"index": 0, "delta": {}, "finish_reason": "tool_calls"}, + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + }, + }, + ] + + def handler(request): + assert request.url.path == "/api/v1/chat/completions" + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + final = stream.get_final_message() + calls = final.choices[0].message.tool_calls + assert len(calls) == 2 + assert calls[0].id == "call_1" + assert calls[0].function.name == "get_weather" + assert calls[0].function.arguments == '{"city":"hz"}' + assert calls[1].id == "call_2" + assert calls[1].function.name == "f2" + assert calls[1].function.arguments == "{}" + assert final.choices[0].finish_reason == "tool_calls" + assert final.usage.total_tokens == 3 + + +def test_native_stream_tool_calls_cumulative_snapshot(): + events = [ + { + "output": { + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": {"name": "f", "arguments": ""}, + }, + ], + }, + }, + ], + }, + }, + { + "output": { + "choices": [ + { + "finish_reason": "tool_calls", + "message": { + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": { + "name": "f", + "arguments": '{"a": 1}', + }, + }, + ], + }, + }, + ], + }, + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + final = stream.get_final_message() + calls = final.choices[0].message.tool_calls + assert len(calls) == 1 + assert calls[0].id == "c1" + assert calls[0].function.name == "f" + assert calls[0].function.arguments == '{"a": 1}' + + +@pytest.mark.asyncio +async def test_async_create(): + def handler(request): + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"content": "async-ok"}, + }, + ], + }, + "request_id": "req-a", + }, + ) + + client = _make_async_client(handler) + resp = await client.chat.completions.create(model="m", messages="hi") + assert resp.output_text == "async-ok" + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_streaming(): + events = [ + { + "output": {"choices": [{"message": {"content": "a"}}]}, + "request_id": "r", + }, + { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": "b"}}, + ], + }, + "usage": { + "input_tokens": 1, + "output_tokens": 2, + "total_tokens": 3, + }, + "request_id": "r", + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + stream = await client.chat.completions.create( + model="m", + messages="x", + stream=True, + ) + got = [] + async for text in stream.text_stream: + got.append(text) + assert got == ["a", "b"] + + client2 = _make_async_client(handler) + stream2 = await client2.chat.completions.create( + model="m", + messages="x", + stream=True, + ) + final = await stream2.get_final_message() + assert final.output_text == "ab" + assert final.usage.total_tokens == 3 + await client.aclose() + await client2.aclose() + + +def _incremental_events(): + return [ + { + "output": { + "choices": [ + { + "finish_reason": None, + "message": { + "role": "assistant", + "content": "Hel", + "reasoning_content": "t1", + }, + }, + ], + }, + "request_id": "req-c", + }, + { + "output": { + "choices": [ + { + "finish_reason": None, + "message": { + "content": "lo", + "reasoning_content": "t2", + }, + }, + ], + }, + "request_id": "req-c", + }, + { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": ""}}, + ], + }, + "usage": { + "input_tokens": 4, + "output_tokens": 2, + "total_tokens": 6, + }, + "request_id": "req-c", + }, + ] + + +def _cumulative_events(): + """Native wire shape when the server streams cumulative snapshots + (wire ``incremental_output=False``): every frame carries the full + text so far, and the last frame adds finish_reason and usage.""" + return [ + { + "output": { + "choices": [ + { + "finish_reason": None, + "message": {"role": "assistant", "content": "你"}, + }, + ], + }, + "request_id": "req-c", + }, + { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"content": "你好。"}, + }, + ], + }, + "usage": { + "input_tokens": 4, + "output_tokens": 2, + "total_tokens": 6, + }, + "request_id": "req-c", + }, + ] + + +def test_incremental_output_false_merges_client_side(): + def handler(request): + body = json.loads(request.content) + # The wire stays incremental; the SDK merges (1.x behavior). + assert body["parameters"]["incremental_output"] is True + return httpx.Response( + 200, + text=_sse(_incremental_events()), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + ) + chunks = list(stream) + assert [c.delta.content for c in chunks] == ["Hel", "Hello", "Hello"] + assert [c.delta.reasoning_content for c in chunks] == [ + "t1", + "t1t2", + "t1t2", + ] + + client2 = _make_client(handler) + stream2 = client2.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + ) + assert list(stream2.text_stream) == ["Hel", "Hello", "Hello"] + + client3 = _make_client(handler) + stream3 = client3.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + ) + final = stream3.get_final_message() + assert final.output_text == "Hello" + assert final.choices[0].message.reasoning_content == "t1t2" + assert final.choices[0].finish_reason == "stop" + assert final.usage.total_tokens == 6 + assert final.request_id == "req-c" + + +def test_merge_mode_overrides_escape_hatch_incremental_output(): + # 1.x parity: while merging client-side the wire stays incremental even + # when parameters={"incremental_output": False} is also set, so the + # server cannot send cumulative frames that the merge would double. + def handler(request): + body = json.loads(request.content) + assert body["parameters"]["incremental_output"] is True + return httpx.Response( + 200, + text=_sse(_incremental_events()), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + parameters={"incremental_output": False}, + ) + assert list(stream.text_stream) == ["Hel", "Hello", "Hello"] + + +def test_incremental_output_false_passes_through_for_exempt_model(): + events = [ + {"output": {"choices": [{"message": {"content": "Hel"}}]}}, + {"output": {"choices": [{"message": {"content": "Hello"}}]}}, + ] + + def handler(request): + body = json.loads(request.content) + # omni models honor the flag server-side: no client merge. + assert body["parameters"]["incremental_output"] is False + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-omni-turbo", + messages="hi", + stream=True, + incremental_output=False, + ) + assert list(stream.text_stream) == ["Hel", "Hello"] + + +def test_escape_hatch_incremental_output_false_final_is_last_snapshot(): + # Escape hatch only (no named kwarg): the wire keeps + # incremental_output=False and the server streams cumulative + # snapshots, so the final message is the last snapshot per choice, + # not a concatenation of every snapshot. + def handler(request): + body = json.loads(request.content) + assert body["parameters"]["incremental_output"] is False + return httpx.Response( + 200, + text=_sse(_cumulative_events()), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages="打招呼", + stream=True, + parameters={"incremental_output": False}, + ) + # Raw iteration stays server-cumulative (1.x passthrough behavior). + # pylint: disable-next=not-an-iterable # stream=True overload returns ChatStream + assert [c.delta.content for c in stream] == ["你", "你好。"] + + client2 = _make_client(handler) + stream2 = client2.chat.completions.create( + model="qwen-plus", + messages="打招呼", + stream=True, + parameters={"incremental_output": False}, + ) + final = stream2.get_final_message() + assert final.output_text == "你好。" + assert final.choices[0].finish_reason == "stop" + assert final.usage.total_tokens == 6 + assert final.request_id == "req-c" + + +def test_exempt_model_incremental_output_false_final_is_last_snapshot(): + # Exempt models honor the named flag server-side; the final message + # must still be the last snapshot rather than a concatenation. + def handler(request): + body = json.loads(request.content) + assert body["parameters"]["incremental_output"] is False + return httpx.Response( + 200, + text=_sse(_cumulative_events()), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-omni-turbo", + messages="打招呼", + stream=True, + incremental_output=False, + ) + final = stream.get_final_message() + assert final.output_text == "你好。" + assert final.choices[0].finish_reason == "stop" + assert final.usage.total_tokens == 6 + + +@pytest.mark.asyncio +async def test_async_escape_hatch_incremental_output_false_final(): + def handler(request): + body = json.loads(request.content) + assert body["parameters"]["incremental_output"] is False + return httpx.Response( + 200, + text=_sse(_cumulative_events()), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + stream = await client.chat.completions.create( + model="qwen-plus", + messages="打招呼", + stream=True, + parameters={"incremental_output": False}, + ) + final = await stream.get_final_message() + assert final.output_text == "你好。" + assert final.choices[0].finish_reason == "stop" + assert final.usage.total_tokens == 6 + await client.aclose() + + +def test_incremental_output_false_openai_protocol_merges_client_side(): + events = [ + {"id": "c1", "choices": [{"index": 0, "delta": {"content": "Hel"}}]}, + { + "id": "c1", + "choices": [ + { + "index": 0, + "delta": {"content": "lo"}, + "finish_reason": "stop", + }, + ], + }, + # Real wire shape: the SDK asks for include_usage, so usage arrives + # in a trailing frame with an empty choices list. + { + "id": "c1", + "choices": [], + "usage": { + "prompt_tokens": 4, + "completion_tokens": 2, + "total_tokens": 6, + }, + }, + ] + + def handler(request): + body = json.loads(request.content) + # Resource-level option: never forwarded to the OpenAI wire. + assert "incremental_output" not in body + assert body["stream_options"] == {"include_usage": True} + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + ) + assert list(stream.text_stream) == ["Hel", "Hello"] + + client2 = _make_client(handler, protocol="openai") + stream2 = client2.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + ) + final = stream2.get_final_message() + assert final.output_text == "Hello" + assert final.choices[0].finish_reason == "stop" + assert final.usage.total_tokens == 6 + + +def test_merge_final_keeps_all_choices_with_openai_usage_tail(): + events = [ + {"id": "c1", "choices": [{"index": 0, "delta": {"content": "A"}}]}, + {"id": "c1", "choices": [{"index": 1, "delta": {"content": "B"}}]}, + { + "id": "c1", + "choices": [ + { + "index": 0, + "delta": {"content": "1"}, + "finish_reason": "stop", + }, + { + "index": 1, + "delta": {"content": "2"}, + "finish_reason": "stop", + }, + ], + }, + # With n > 1 the finish frames arrive per choice and the usage + # tail carries no choices; the final message must keep both. + { + "id": "c1", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 2, + "total_tokens": 3, + }, + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + n=2, + ) + final = stream.get_final_message() + assert [c.message.content for c in final.choices] == ["A1", "B2"] + assert [c.finish_reason for c in final.choices] == ["stop", "stop"] + assert final.usage.total_tokens == 3 + + +def test_streaming_search_info_native(): + events = [ + {"output": {"choices": [{"message": {"content": "Hel"}}]}}, + { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": "lo"}}, + ], + "search_info": { + "search_results": [{"title": "t", "url": "https://u"}], + }, + }, + "usage": { + "input_tokens": 1, + "output_tokens": 2, + "total_tokens": 3, + }, + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + # Incremental stream: search_info rides the aggregate path. + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + enable_search=True, + ) + final = stream.get_final_message() + assert final.output_text == "Hello" + assert final.search_info.search_results[0].url == "https://u" + + # Merged stream: search_info survives the cumulative rebuild. + client2 = _make_client(handler) + stream2 = client2.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + enable_search=True, + ) + final2 = stream2.get_final_message() + assert final2.output_text == "Hello" + assert final2.search_info.search_results[0].title == "t" + + +def test_merge_preserves_choice_and_delta_extras(): + # Two frames with distinct logprobs fragments: aggregated results + # must concatenate them, not keep only the last frame's fragment. + logprobs_1 = {"content": [{"token": "H", "logprob": -0.1}]} + logprobs_2 = {"content": [{"token": "i", "logprob": -0.2}]} + combined = {"content": logprobs_1["content"] + logprobs_2["content"]} + events = [ + { + "id": "c1", + "choices": [ + { + "index": 0, + "delta": {"content": "H"}, + "logprobs": logprobs_1, + }, + ], + }, + { + "id": "c1", + "choices": [ + { + "index": 0, + "delta": {"content": "i", "audio": {"id": "a1"}}, + "logprobs": logprobs_2, + "finish_reason": "stop", + }, + ], + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + def create(client): + return client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + logprobs=True, + ) + + # Raw stream: extras arrive untouched, one fragment per frame. + client = _make_client(handler, protocol="openai") + raw = list( + client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + logprobs=True, + ), + ) + assert raw[0].choices[0].logprobs == logprobs_1 + assert raw[1].choices[0].logprobs == logprobs_2 + assert raw[1].choices[0].delta.audio == {"id": "a1"} + + # Merged stream: the cumulative rebuild concatenates the logprobs + # fragments and keeps the current frame's other extras. + client2 = _make_client(handler, protocol="openai") + merged = list(create(client2)) + assert merged[0].choices[0].logprobs == logprobs_1 + assert merged[1].choices[0].logprobs == combined + assert merged[1].choices[0].delta.audio == {"id": "a1"} + + # Final message: aggregated logprobs and delta extras land on + # Choice/ChatCompletionMessage. + client3 = _make_client(handler, protocol="openai") + final = create(client3).get_final_message() + assert final.output_text == "Hi" + assert final.choices[0].logprobs == combined + assert final.choices[0].message.audio == {"id": "a1"} + + +def test_aggregate_accumulates_streaming_logprobs(): + # Default incremental stream (no incremental_output flag): the + # final Choice must carry the concatenation of every chunk's + # logprobs fragment instead of dropping them entirely. + logprobs_1 = {"content": [{"token": "Hel", "logprob": -0.1}]} + logprobs_2 = {"content": [{"token": "lo", "logprob": -0.2}]} + events = [ + { + "id": "c1", + "choices": [ + { + "index": 0, + "delta": {"content": "Hel"}, + "logprobs": logprobs_1, + }, + ], + }, + { + "id": "c1", + "choices": [ + { + "index": 0, + "delta": {"content": "lo"}, + "logprobs": logprobs_2, + "finish_reason": "stop", + }, + ], + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + logprobs=True, + ) + final = stream.get_final_message() + assert final.output_text == "Hello" + assert final.choices[0].logprobs == { + "content": logprobs_1["content"] + logprobs_2["content"], + } + + +def test_reasoning_stream_and_tool_call_stream(): + events = [ + { + "output": { + "choices": [ + { + "message": { + "role": "assistant", + "reasoning_content": "think", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "f", + "arguments": '{"a"', + }, + }, + ], + }, + }, + ], + }, + }, + { + "output": { + "choices": [ + { + "finish_reason": "tool_calls", + "message": { + "reasoning_content": " more", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "f", + "arguments": ":1}", + }, + }, + ], + }, + }, + ], + }, + }, + ] + + def handler(request): + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="m", + messages="hi", + stream=True, + ) + assert list(stream.reasoning_stream) == ["think", " more"] + + client2 = _make_client(handler) + stream2 = client2.chat.completions.create( + model="m", + messages="hi", + stream=True, + ) + call_batches = list(stream2.tool_call_stream) + assert len(call_batches) == 2 + # Native chunks carry cumulative tool_call snapshots. + assert call_batches[-1][0].function.arguments == ":1}" + + +@pytest.mark.asyncio +async def test_async_incremental_output_false_merges_client_side(): + def handler(request): + body = json.loads(request.content) + assert body["parameters"]["incremental_output"] is True + return httpx.Response( + 200, + text=_sse(_incremental_events()), + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + stream = await client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + ) + got = [] + async for text in stream.text_stream: + got.append(text) + assert got == ["Hel", "Hello", "Hello"] + + client2 = _make_async_client(handler) + stream2 = await client2.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + incremental_output=False, + ) + final = await stream2.get_final_message() + assert final.output_text == "Hello" + assert final.choices[0].message.reasoning_content == "t1t2" + assert final.usage.total_tokens == 6 + await client.aclose() + await client2.aclose() + + +def test_media_upload_uses_the_per_call_api_key(monkeypatch): + # The OSS upload must run under the per-call key (1.x behavior); + # uploading with the client key would park the file in the wrong + # account when the two differ. + seen = {} + + def fake_upload(model, content, api_key, cert, **kwargs): + seen["api_key"] = api_key + seen["base_address"] = kwargs.get("base_address") + return True, "oss://bucket/cat.png", {} + + monkeypatch.setattr( + "dashscope.v2.resources._oss.check_and_upload_local", + fake_upload, + ) + captured = {} + + def handler(request): + captured["auth"] = request.headers.get("Authorization") + captured["oss"] = request.headers.get("X-DashScope-OssResourceResolve") + captured["body"] = json.loads(request.content) + return _native_response() + + client = _make_client(handler) + client.chat.completions.create( + model="qwen-vl-max", + messages=[ + {"role": "user", "content": [{"image": "/tmp/cat.png"}]}, + ], + api_key="sk-override", + ) + assert seen["api_key"] == "sk-override" + # The certificate is fetched from the client's own base_url so a + # region/gateway-scoped client uploads to the matching regional OSS. + assert seen["base_address"] == client.config.base_url + assert captured["auth"] == "Bearer sk-override" + assert captured["oss"] == "enable" + assert captured["body"]["input"]["messages"][0]["content"] == [ + {"image": "oss://bucket/cat.png"}, + ] diff --git a/tests/v2/test_config.py b/tests/v2/test_config.py new file mode 100644 index 00000000..da5a64c6 --- /dev/null +++ b/tests/v2/test_config.py @@ -0,0 +1,615 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for config resolution and client shells (M1 task 1.1).""" + +# pytest fixture injection looks like unused/redefined names to pylint. +# pylint: disable=redefined-outer-name,unused-argument,protected-access + +from __future__ import annotations + +import pytest + +from dashscope.v2 import AsyncClient, Client, Config +from dashscope.v2._config import ( + DEFAULT_MAX_RETRIES, + DEFAULT_PROTOCOL, + DEFAULT_REGION, + DEFAULT_TIMEOUT, + LEGACY_GATEWAY_HOSTS, + LEGACY_WS_GATEWAY_HOSTS, + VALID_REGIONS, + resolve_config, +) + +# Workspace-scoped MaaS gateway endpoints (the default route when a +# workspace is configured); without a workspace, resolution falls back to +# the legacy shared gateway (the 1.x endpoint) with a warning. +_WS_HOST = "https://ws-123.cn-beijing.maas.aliyuncs.com" +_WS_GATEWAY = _WS_HOST + "/api/v1" +_WS_OPENAI_GATEWAY = _WS_HOST + "/compatible-mode/v1" +_WS_ANTHROPIC_GATEWAY = _WS_HOST + "/apps/anthropic" +_LEGACY_HOST = "https://dashscope.aliyuncs.com" +_LEGACY_GATEWAY = _LEGACY_HOST + "/api/v1" +_LEGACY_OPENAI_GATEWAY = _LEGACY_HOST + "/compatible-mode/v1" +_LEGACY_ANTHROPIC_GATEWAY = _LEGACY_HOST + "/apps/anthropic" + + +@pytest.fixture +def clean_env(monkeypatch, tmp_path): + from dashscope.v2._config import settings + + for name in ( + "DASHSCOPE_API_KEY", + "DASHSCOPE_BASE_URL", + "DASHSCOPE_HTTP_BASE_URL", + "DASHSCOPE_WORKSPACE", + "DASHSCOPE_REGION", + "DASHSCOPE_PROTOCOL", + "DASHSCOPE_TIMEOUT", + "DASHSCOPE_MAX_RETRIES", + "DASHSCOPE_CONFIG", + ): + monkeypatch.delenv(name, raising=False) + # Point the lowest api_key tier at an absent file: the 1.x key file + # (~/.dashscope/api_key) exists on many dev machines and would + # otherwise leak a real key into the "no api_key configured" cases. + # Tests exercising the key file set their own path afterwards. + monkeypatch.setenv( + "DASHSCOPE_API_KEY_FILE_PATH", + str(tmp_path / "absent_api_key"), + ) + settings.clear() + yield monkeypatch + settings.clear() + + +def _write_config(tmp_path, content): + path = tmp_path / "config.toml" + path.write_text(content, encoding="utf-8") + return path + + +def test_missing_workspace_falls_back_to_legacy_gateway(clean_env, tmp_path): + # No workspace and no explicit base_url -> fall back to the legacy + # shared gateway (the 1.x endpoint) with a warning instead of failing, + # so an api_key-only quickstart keeps working. + absent = tmp_path / "absent.toml" + with pytest.warns(UserWarning, match="legacy shared gateway"): + cfg = resolve_config(config_path=absent) + openai_cfg = resolve_config(protocol="openai", config_path=absent) + anthropic_cfg = resolve_config( + protocol="anthropic", + config_path=absent, + ) + assert cfg.base_url == _LEGACY_GATEWAY + assert cfg.protocol_base_url == _LEGACY_GATEWAY + assert cfg.workspace is None + assert cfg.configured_base_url is None + assert openai_cfg.base_url == _LEGACY_GATEWAY + assert openai_cfg.protocol_base_url == _LEGACY_OPENAI_GATEWAY + assert anthropic_cfg.base_url == _LEGACY_GATEWAY + assert anthropic_cfg.protocol_base_url == _LEGACY_ANTHROPIC_GATEWAY + + +def test_explicit_base_url_needs_no_workspace(clean_env, tmp_path): + cfg = resolve_config( + base_url="https://custom.example.com/v1", + config_path=tmp_path / "absent.toml", + ) + assert cfg.base_url == "https://custom.example.com/v1" + assert cfg.workspace is None + + +def test_defaults(clean_env, tmp_path): + cfg = resolve_config( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg == 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, + ) + + +def test_protocol_default_base_url(clean_env, tmp_path): + absent = tmp_path / "absent.toml" + cfg = resolve_config(workspace="ws-123", config_path=absent) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_GATEWAY + assert cfg.configured_base_url is None + + cfg = resolve_config( + workspace="ws-123", + protocol="openai", + config_path=absent, + ) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_OPENAI_GATEWAY + + cfg = resolve_config( + workspace="ws-123", + protocol="anthropic", + config_path=absent, + ) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_ANTHROPIC_GATEWAY + + +def test_protocol_default_base_url_via_env(clean_env, tmp_path, monkeypatch): + monkeypatch.setenv("DASHSCOPE_PROTOCOL", "anthropic") + cfg = resolve_config( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_ANTHROPIC_GATEWAY + + +def test_configured_base_url_beats_protocol_default( + clean_env, + tmp_path, + monkeypatch, +): + absent = tmp_path / "absent.toml" + cfg = resolve_config( + protocol="openai", + base_url="https://explicit.example.com", + config_path=absent, + ) + assert cfg.base_url == "https://explicit.example.com" + assert cfg.protocol_base_url == "https://explicit.example.com" + assert cfg.configured_base_url == "https://explicit.example.com" + + monkeypatch.setenv("DASHSCOPE_BASE_URL", "https://env.example.com") + cfg = resolve_config(protocol="openai", config_path=absent) + assert cfg.base_url == "https://env.example.com" + assert cfg.protocol_base_url == "https://env.example.com" + + path = _write_config( + tmp_path, + 'base_url = "https://file.example.com"\n', + ) + monkeypatch.delenv("DASHSCOPE_BASE_URL") + cfg = resolve_config(protocol="openai", config_path=path) + assert cfg.base_url == "https://file.example.com" + assert cfg.protocol_base_url == "https://file.example.com" + + +def test_workspace_scoped_native_gateway(clean_env, tmp_path): + cfg = resolve_config( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_GATEWAY + assert cfg.configured_base_url is None + + +def test_workspace_gateway_via_env_and_file(clean_env, tmp_path, monkeypatch): + monkeypatch.setenv("DASHSCOPE_WORKSPACE", "ws-123") + cfg = resolve_config(config_path=tmp_path / "absent.toml") + assert cfg.base_url == _WS_GATEWAY + + path = _write_config(tmp_path, 'workspace = "ws-123"\n') + monkeypatch.delenv("DASHSCOPE_WORKSPACE") + cfg = resolve_config(config_path=path) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_GATEWAY + + +def test_workspace_gateway_covers_all_protocols(clean_env, tmp_path): + absent = tmp_path / "absent.toml" + cfg = resolve_config( + workspace="ws-123", + protocol="openai", + config_path=absent, + ) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_OPENAI_GATEWAY + + cfg = resolve_config( + workspace="ws-123", + protocol="anthropic", + config_path=absent, + ) + assert cfg.base_url == _WS_GATEWAY + assert cfg.protocol_base_url == _WS_ANTHROPIC_GATEWAY + + +def test_configured_base_url_beats_workspace_gateway(clean_env, tmp_path): + cfg = resolve_config( + workspace="ws-123", + base_url="https://explicit.example.com", + config_path=tmp_path / "absent.toml", + ) + assert cfg.base_url == "https://explicit.example.com" + assert cfg.protocol_base_url == "https://explicit.example.com" + assert cfg.configured_base_url == "https://explicit.example.com" + + +def test_with_options_workspace_reresolves_gateway(clean_env, tmp_path): + absent = tmp_path / "absent.toml" + client = Client(api_key="k", workspace="ws-parent", config_path=absent) + assert ( + client.config.base_url + == "https://ws-parent.cn-beijing.maas.aliyuncs.com/api/v1" + ) + derived = client.with_options(workspace="ws-123") + assert derived.config.base_url == _WS_GATEWAY + assert derived.config.protocol_base_url == _WS_GATEWAY + assert derived.transport.http_client is not client.transport.http_client + derived.close() + client.close() + + +def test_region_selects_gateway_region(clean_env, tmp_path): + # region= picks one of the six regional MaaS gateways instead of + # hand-building a base_url; the default stays cn-beijing. + absent = tmp_path / "absent.toml" + cfg = resolve_config( + workspace="ws-123", + region="ap-southeast-1", + config_path=absent, + ) + assert cfg.region == "ap-southeast-1" + host = "https://ws-123.ap-southeast-1.maas.aliyuncs.com" + assert cfg.base_url == host + "/api/v1" + assert cfg.protocol_base_url == host + "/api/v1" + + cfg = resolve_config( + workspace="ws-123", + region="us-east-1", + protocol="openai", + config_path=absent, + ) + assert cfg.base_url == ( + "https://ws-123.us-east-1.maas.aliyuncs.com/api/v1" + ) + assert cfg.protocol_base_url == ( + "https://ws-123.us-east-1.maas.aliyuncs.com/compatible-mode/v1" + ) + + cfg = resolve_config(workspace="ws-123", config_path=absent) + assert cfg.region == DEFAULT_REGION + assert cfg.base_url == _WS_GATEWAY + + +def test_region_via_env_and_file(clean_env, tmp_path, monkeypatch): + monkeypatch.setenv("DASHSCOPE_WORKSPACE", "ws-123") + monkeypatch.setenv("DASHSCOPE_REGION", "ap-southeast-1") + cfg = resolve_config(config_path=tmp_path / "absent.toml") + assert cfg.region == "ap-southeast-1" + assert cfg.base_url == ( + "https://ws-123.ap-southeast-1.maas.aliyuncs.com/api/v1" + ) + + path = _write_config(tmp_path, 'region = "eu-central-1"\n') + monkeypatch.delenv("DASHSCOPE_REGION") + cfg = resolve_config(config_path=path) + assert cfg.region == "eu-central-1" + # An explicit parameter still wins over the config file. + cfg = resolve_config(region="ap-northeast-1", config_path=path) + assert cfg.region == "ap-northeast-1" + + +def test_region_legacy_fallback_per_region(clean_env, tmp_path): + # Without a workspace the selected region's legacy shared gateway is + # the fallback; regions without a dedicated shared gateway use the + # international one. + absent = tmp_path / "absent.toml" + with pytest.warns(UserWarning, match="legacy shared gateway"): + cfg = resolve_config(region="ap-southeast-1", config_path=absent) + assert cfg.base_url == "https://dashscope-intl.aliyuncs.com/api/v1" + with pytest.warns(UserWarning, match="legacy shared gateway"): + cfg = resolve_config(region="cn-hongkong", config_path=absent) + assert cfg.base_url == ( + "https://cn-hongkong.dashscope.aliyuncs.com/api/v1" + ) + with pytest.warns(UserWarning, match="legacy shared gateway"): + cfg = resolve_config(region="us-east-1", config_path=absent) + assert cfg.base_url == "https://dashscope-intl.aliyuncs.com/api/v1" + + +def test_invalid_region(clean_env): + with pytest.raises(ValueError, match="invalid region"): + resolve_config( + region="moon-1", + config_path="/nonexistent/config.toml", + ) + + +def test_region_catalogs_stay_in_sync(): + # A region added to VALID_REGIONS without its legacy gateway entries + # would surface as a KeyError only when the no-workspace fallback + # runs; pin the three catalogs together so the omission fails here. + assert set(VALID_REGIONS) == set(LEGACY_GATEWAY_HOSTS) + assert set(VALID_REGIONS) == set(LEGACY_WS_GATEWAY_HOSTS) + assert DEFAULT_REGION in VALID_REGIONS + + +def test_configured_base_url_beats_region(clean_env, tmp_path): + # An explicit base_url stays the escape hatch: region is still + # recorded but does not rewrite the configured URL. + cfg = resolve_config( + region="ap-southeast-1", + base_url="https://explicit.example.com", + config_path=tmp_path / "absent.toml", + ) + assert cfg.base_url == "https://explicit.example.com" + assert cfg.protocol_base_url == "https://explicit.example.com" + assert cfg.region == "ap-southeast-1" + + +def test_with_options_region_reresolves_gateway(clean_env, tmp_path): + client = Client( + api_key="k", + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + derived = client.with_options(region="ap-southeast-1") + assert derived.config.region == "ap-southeast-1" + assert derived.config.base_url == ( + "https://ws-123.ap-southeast-1.maas.aliyuncs.com/api/v1" + ) + assert derived.transport.http_client is not client.transport.http_client + derived.close() + client.close() + + +def test_explicit_params_win(clean_env, tmp_path, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-key") + monkeypatch.setenv("DASHSCOPE_BASE_URL", "https://env.example.com") + path = _write_config( + tmp_path, + 'api_key = "file-key"\nprotocol = "openai"\n', + ) + cfg = resolve_config( + api_key="explicit-key", + base_url="https://explicit.example.com", + protocol="anthropic", + config_path=path, + ) + assert cfg.api_key == "explicit-key" + assert cfg.base_url == "https://explicit.example.com" + assert cfg.protocol == "anthropic" + + +def test_env_over_file(clean_env, tmp_path, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-key") + path = _write_config( + tmp_path, + 'api_key = "file-key"\nworkspace = "ws-file"\n', + ) + cfg = resolve_config(config_path=path) + assert cfg.api_key == "env-key" + assert cfg.workspace == "ws-file" + + +def test_file_values(clean_env, tmp_path): + path = _write_config( + tmp_path, + """ +base_url = "https://file.example.com" +protocol = "openai" +timeout = 30.5 +max_retries = 4 +""", + ) + cfg = resolve_config(config_path=path) + assert cfg.base_url == "https://file.example.com" + assert cfg.protocol == "openai" + assert cfg.timeout == 30.5 + assert cfg.max_retries == 4 + + +def test_legacy_base_url_env_fallback(clean_env, monkeypatch): + monkeypatch.setenv("DASHSCOPE_HTTP_BASE_URL", "https://legacy.example.com") + cfg = resolve_config(config_path="/nonexistent/config.toml") + assert cfg.base_url == "https://legacy.example.com" + + +def test_api_key_file_path_env(clean_env, tmp_path, monkeypatch): + key_file = tmp_path / "my_key" + key_file.write_text("sk-from-env-file\n", encoding="utf-8") + monkeypatch.setenv("DASHSCOPE_API_KEY_FILE_PATH", str(key_file)) + cfg = resolve_config(config_path="/nonexistent/config.toml") + assert cfg.api_key == "sk-from-env-file" + + +def test_api_key_default_key_file(clean_env, tmp_path, monkeypatch): + key_dir = tmp_path / ".dashscope" + key_dir.mkdir() + (key_dir / "api_key").write_text("sk-from-default-file", encoding="utf-8") + # expanduser("~") resolves through HOME on POSIX. The clean_env + # override of DASHSCOPE_API_KEY_FILE_PATH must go so the default + # path resolution is what actually runs. + monkeypatch.delenv("DASHSCOPE_API_KEY_FILE_PATH") + monkeypatch.setenv("HOME", str(tmp_path)) + cfg = resolve_config(config_path="/nonexistent/config.toml") + assert cfg.api_key == "sk-from-default-file" + + +def test_api_key_env_and_config_beat_key_file( + clean_env, + tmp_path, + monkeypatch, +): + key_file = tmp_path / "my_key" + key_file.write_text("sk-from-file", encoding="utf-8") + monkeypatch.setenv("DASHSCOPE_API_KEY_FILE_PATH", str(key_file)) + path = _write_config(tmp_path, 'api_key = "toml-key"\n') + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-key") + cfg = resolve_config(config_path=path) + assert cfg.api_key == "env-key" + monkeypatch.delenv("DASHSCOPE_API_KEY") + cfg = resolve_config(config_path=path) + assert cfg.api_key == "toml-key" + + +def test_save_api_key_roundtrip(clean_env, tmp_path, monkeypatch): + from dashscope.v2 import save_api_key + + key_file = tmp_path / "nested" / "api_key" + save_api_key("sk-saved", str(key_file)) + assert key_file.read_text(encoding="utf-8") == "sk-saved" + monkeypatch.setenv("DASHSCOPE_API_KEY_FILE_PATH", str(key_file)) + cfg = resolve_config(config_path="/nonexistent/config.toml") + assert cfg.api_key == "sk-saved" + + +def test_invalid_protocol(clean_env): + with pytest.raises(ValueError, match="invalid protocol"): + resolve_config(protocol="grpc", config_path="/nonexistent/config.toml") + + +def test_invalid_timeout_and_retries(clean_env): + with pytest.raises(ValueError, match="timeout"): + resolve_config(timeout=0, config_path="/nonexistent/config.toml") + with pytest.raises(ValueError, match="max_retries"): + resolve_config(max_retries=-1, config_path="/nonexistent/config.toml") + + +def test_invalid_toml(clean_env, tmp_path): + path = _write_config(tmp_path, "not valid [toml") + with pytest.raises(ValueError, match="invalid TOML"): + resolve_config(config_path=path) + + +def test_unknown_file_keys_ignored(clean_env, tmp_path): + path = _write_config( + tmp_path, + 'unknown = 1\napi_key = "k"\nworkspace = "ws-123"\n', + ) + assert resolve_config(config_path=path).api_key == "k" + + +def test_client_resolves_config(clean_env, tmp_path, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "env-key") + monkeypatch.setenv("DASHSCOPE_WORKSPACE", "ws-123") + client = Client(config_path=tmp_path / "absent.toml") + assert client.config.api_key == "env-key" + assert client.config.base_url == _WS_GATEWAY + with Client(api_key="k", config_path=tmp_path / "absent.toml") as c: + assert c.config.api_key == "k" + + +@pytest.mark.asyncio +async def test_async_client(clean_env, tmp_path): + client = AsyncClient( + api_key="k", + protocol="native", + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert client.config.api_key == "k" + assert client.config.protocol == "native" + async with AsyncClient( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) as c: + assert c.config.api_key is None + + +def test_protocol_timeout_retries_env(clean_env, tmp_path, monkeypatch): + monkeypatch.setenv("DASHSCOPE_PROTOCOL", "openai") + monkeypatch.setenv("DASHSCOPE_TIMEOUT", "12.5") + monkeypatch.setenv("DASHSCOPE_MAX_RETRIES", "5") + cfg = resolve_config( + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg.protocol == "openai" + assert cfg.timeout == 12.5 + assert cfg.max_retries == 5 + + +def test_env_precedence_for_protocol(clean_env, tmp_path, monkeypatch): + path = _write_config( + tmp_path, + 'protocol = "anthropic"\nworkspace = "ws-123"\n', + ) + monkeypatch.setenv("DASHSCOPE_PROTOCOL", "openai") + cfg = resolve_config(config_path=path) + assert cfg.protocol == "openai" + cfg = resolve_config(protocol="native", config_path=path) + assert cfg.protocol == "native" + + +def test_invalid_numeric_env(clean_env, monkeypatch): + monkeypatch.setenv("DASHSCOPE_TIMEOUT", "abc") + with pytest.raises(ValueError, match="DASHSCOPE_TIMEOUT"): + resolve_config(config_path="/nonexistent/config.toml") + monkeypatch.delenv("DASHSCOPE_TIMEOUT") + monkeypatch.setenv("DASHSCOPE_MAX_RETRIES", "1.5") + with pytest.raises(ValueError, match="DASHSCOPE_MAX_RETRIES"): + resolve_config(config_path="/nonexistent/config.toml") + + +def test_settings_read_env_lazily(clean_env, monkeypatch): + from dashscope.v2._config import settings + + monkeypatch.setenv("DASHSCOPE_PROTOCOL", "anthropic") + monkeypatch.setenv("DASHSCOPE_TIMEOUT", "9") + monkeypatch.setenv("DASHSCOPE_MAX_RETRIES", "0") + assert settings.protocol == "anthropic" + assert settings.timeout == 9.0 + assert settings.max_retries == 0 + settings.protocol = "native" + assert settings.protocol == "native" + + +def test_with_options_inherits_and_shares_pool(clean_env, tmp_path): + client = Client( + api_key="k", + protocol="anthropic", + workspace="ws-123", + protocol_options={"strict": True}, + config_path=tmp_path / "absent.toml", + ) + derived = client.with_options(max_retries=7, raise_on_error=False) + assert derived.config.max_retries == 7 + assert derived.raise_on_error is False + assert derived.config.api_key == "k" + assert derived.config.protocol == "anthropic" + assert derived._protocol_options == {"strict": True} + assert derived.transport.http_client is client.transport.http_client + derived.close() + assert not client.transport.http_client.is_closed + client.close() + assert client.transport.http_client.is_closed + + +def test_with_options_new_pool_on_connection_change(clean_env, tmp_path): + client = Client( + api_key="k", + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + derived = client.with_options(base_url="https://other.example.com") + assert derived.config.base_url == "https://other.example.com" + assert derived.transport.http_client is not client.transport.http_client + derived.close() + assert derived.transport.http_client.is_closed + assert not client.transport.http_client.is_closed + client.close() + + +@pytest.mark.asyncio +async def test_async_with_options(clean_env, tmp_path): + client = AsyncClient( + api_key="k", + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + derived = client.with_options(protocol="openai", max_retries=0) + assert derived.config.protocol == "openai" + assert derived.config.max_retries == 0 + assert derived.transport.http_client is client.transport.http_client + await derived.aclose() + assert not client.transport.http_client.is_closed + await client.aclose() diff --git a/tests/v2/test_decision.py b/tests/v2/test_decision.py new file mode 100644 index 00000000..e5f87ab1 --- /dev/null +++ b/tests/v2/test_decision.py @@ -0,0 +1,558 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.decision (TypeSafe System One decision model). + +The System One endpoint is pinned to the compatible-mode path on every +gateway, independent of the client's chat ``protocol`` -- the routing +tests below pin that for native / openai / anthropic alike. +""" + +# Mock handlers intentionally ignore the request argument; pytest +# fixture injection looks like unused/redefined names to pylint. +# pylint: disable=unused-argument,redefined-outer-name + +from __future__ import annotations + +import json + +import httpx +import pytest +from pydantic import ValidationError + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import BadRequestError, RateLimitError +from dashscope.v2.resources.decision import ( + ChoiceQuestion, + NoulQuestion, + ScoreQuestion, +) +from dashscope.v2.transport._ident import sdk_client_value + +BASE_URL = "https://api.test.local/api/v1" +_WS_HOST = "https://ws-123.cn-beijing.maas.aliyuncs.com" +_SYSTEMONE = "/compatible-mode/v1/systemone" + +_STATE = { + "ticket_id": "T-1001", + "content": "订单支付后超过 24 小时仍未到账,用户无法继续使用核心服务。", +} + +# The response example from the decision-model API doc. +_DECISION_BODY = { + "model": "decision-model-preview", + "request_id": "7b986c65-b223-9341-b5f0-b988e27ecaac", + "answers": { + "department": { + "type": "choice", + "choice": "billing", + "confidence": 0.88, + "probabilities": {"billing": 0.94, "technical": 0.06}, + }, + "escalate": {"type": "noul", "noul": 0.99}, + "severity": { + "type": "score", + "score": 2.25, + "confidence": 0.91, + "legend": { + "0": "轻微问题,不影响功能", + "1": "部分功能受影响,但存在替代方案", + "2": "核心功能不可用,没有替代方案", + "3": "造成严重业务或安全影响", + }, + "probabilities": {"0": 0.0, "1": 0.01, "2": 0.73, "3": 0.26}, + }, + }, + "usage": {"input_tokens": 125}, + "latency_ms": 52.9, +} + + +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) + + +@pytest.fixture +def clean_env(monkeypatch): + from dashscope.v2._config import settings + + for name in ( + "DASHSCOPE_API_KEY", + "DASHSCOPE_BASE_URL", + "DASHSCOPE_HTTP_BASE_URL", + "DASHSCOPE_WORKSPACE", + "DASHSCOPE_REGION", + "DASHSCOPE_PROTOCOL", + "DASHSCOPE_CONFIG", + ): + monkeypatch.delenv(name, raising=False) + settings.clear() + yield monkeypatch + settings.clear() + + +def _questions(): + return { + "department": ChoiceQuestion( + instructions="应该由哪个团队处理?", + criteria={ + "billing": "支付、退款和账单问题", + "technical": "产品故障和集成问题", + }, + ), + "escalate": NoulQuestion(instructions="是否需要立即通知值班人员?"), + "severity": ScoreQuestion( + instructions="这个问题有多严重?", + criteria=[ + "轻微问题,不影响功能", + "部分功能受影响,但存在替代方案", + "核心功能不可用,没有替代方案", + "造成严重业务或安全影响", + ], + ), + } + + +def test_create_builds_wire_body(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_DECISION_BODY) + + client = _make_client(handler) + client.decision.create( + model="decision-model-preview", + state=_STATE, + questions=_questions(), + ) + # Explicit base_url stays the service root (same rule as chat). + assert captured["path"] == "/api/v1/systemone" + body = captured["body"] + assert body["model"] == "decision-model-preview" + assert body["state"] == _STATE + department = body["questions"]["department"] + assert department["type"] == "choice" + assert department["criteria"]["billing"] == "支付、退款和账单问题" + escalate = body["questions"]["escalate"] + # An unset optional (noul criteria) stays off the wire. + assert escalate == { + "type": "noul", + "instructions": "是否需要立即通知值班人员?", + } + severity = body["questions"]["severity"] + assert severity["type"] == "score" + assert len(severity["criteria"]) == 4 + client.close() + + +def test_create_accepts_string_state_and_raw_dict_questions(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_DECISION_BODY) + + client = _make_client(handler) + client.decision.create( + model="decision-model-preview", + state="这段评论是否在索要退款?", + questions={ + "refund": { + "type": "noul", + "instructions": "是否在索要退款?", + # Unknown fields pass through for forward compatibility. + "future_field": 1, + }, + }, + ) + assert captured["body"]["state"] == "这段评论是否在索要退款?" + assert captured["body"]["questions"]["refund"] == { + "type": "noul", + "instructions": "是否在索要退款?", + "future_field": 1, + } + client.close() + + +def test_response_parsing(): + def handler(request): + return httpx.Response(200, json=_DECISION_BODY) + + client = _make_client(handler) + resp = client.decision.create( + model="decision-model-preview", + state=_STATE, + questions=_questions(), + ) + assert resp.request_id == "7b986c65-b223-9341-b5f0-b988e27ecaac" + assert resp.model == "decision-model-preview" + assert resp.is_error is False + department = resp.answers["department"] + assert department.type == "choice" + assert department.choice == "billing" + assert department.confidence == 0.88 + assert department.probabilities == {"billing": 0.94, "technical": 0.06} + assert resp.answers["escalate"].noul == 0.99 + severity = resp.answers["severity"] + assert severity.score == 2.25 + assert severity.legend["2"] == "核心功能不可用,没有替代方案" + assert severity.probabilities["3"] == 0.26 + assert resp.usage.input_tokens == 125 + assert resp.latency_ms == 52.9 + client.close() + + +def test_response_headers_attached(): + def handler(request): + return httpx.Response( + 200, + json=_DECISION_BODY, + headers={"x-request-id": "rid-1"}, + ) + + client = _make_client(handler) + resp = client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert resp.headers["x-request-id"] == "rid-1" + client.close() + + +def test_create_requires_questions(): + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(ValueError, match="questions must not be empty"): + client.decision.create( + model="decision-model-preview", + state="s", + questions={}, + ) + client.close() + + +def test_question_model_validation(): + # choice needs at least one option, score at least two levels. + with pytest.raises(ValidationError): + ChoiceQuestion() + with pytest.raises(ValidationError): + ChoiceQuestion(criteria={}) + with pytest.raises(ValidationError): + ScoreQuestion(criteria=["only one level"]) + # A bare noul question is valid (criteria is optional). + assert NoulQuestion().type == "noul" + + +@pytest.mark.parametrize("protocol", ["native", "openai", "anthropic"]) +def test_routing_default_gateway_ignores_chat_protocol(clean_env, protocol): + urls = [] + http_client = httpx.Client( + transport=httpx.MockTransport( + lambda request: ( + urls.append(str(request.url)), + httpx.Response(200, json=_DECISION_BODY), + )[1], + ), + base_url=_WS_HOST + "/api/v1", + ) + client = Client( + api_key="sk-test", + workspace="ws-123", + protocol=protocol, + http_client=http_client, + ) + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert urls == [_WS_HOST + _SYSTEMONE], protocol + client.close() + + +@pytest.mark.parametrize( + "region", + ["cn-beijing", "ap-southeast-1"], +) +def test_routing_documented_region_gateways(clean_env, region): + """Both endpoints the API doc lists (Beijing / Singapore) resolve.""" + urls = [] + ws_host = f"https://ws-123.{region}.maas.aliyuncs.com" + + def handler(request): + urls.append(str(request.url)) + return httpx.Response(200, json=_DECISION_BODY) + + http_client = httpx.Client( + transport=httpx.MockTransport(handler), + base_url=ws_host + "/api/v1", + ) + client = Client( + api_key="sk-test", + workspace="ws-123", + region=region, + http_client=http_client, + ) + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert urls == [ws_host + _SYSTEMONE] + client.close() + + +def test_routing_explicit_base_url(): + urls = [] + + def handler(request): + urls.append(str(request.url)) + return httpx.Response(200, json=_DECISION_BODY) + + client = _make_client(handler) + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert urls == [BASE_URL + "/systemone"] + client.close() + + +def test_routing_legacy_fallback(clean_env, tmp_path): + legacy = "https://dashscope.aliyuncs.com" + urls = [] + + def handler(request): + urls.append(str(request.url)) + return httpx.Response(200, json=_DECISION_BODY) + + http_client = httpx.Client( + transport=httpx.MockTransport(handler), + base_url=legacy + "/api/v1", + ) + with pytest.warns(UserWarning, match="legacy shared gateway"): + client = Client( + api_key="sk-test", + http_client=http_client, + config_path=tmp_path / "absent.toml", + ) + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert urls == [legacy + _SYSTEMONE] + client.close() + + +def test_http_error_maps_openai_style_body(): + def handler(request): + return httpx.Response( + 400, + json={ + "error": { + "code": "InvalidParameter", + "message": "questions.q.criteria is required", + }, + }, + headers={"x-request-id": "rid-err"}, + ) + + client = _make_client(handler) + with pytest.raises(BadRequestError) as exc_info: + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "choice"}}, + ) + err = exc_info.value + assert err.code == "InvalidParameter" + assert err.status_code == 400 + assert err.request_id == "rid-err" + client.close() + + +def test_http_429_maps_rate_limit(): + def handler(request): + return httpx.Response(429, json={"error": {"message": "slow down"}}) + + client = _make_client(handler) + with pytest.raises(RateLimitError): + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + client.close() + + +def test_http_error_envelope_when_not_raising(): + """raise_on_error=False must not mark a non-2xx reply as success. + + The compatible-mode endpoint nests the envelope under ``error``, + so the success parser sees none of it; the response must still + carry is_error/code/message/request_id/status_code. + """ + + def handler(request): + return httpx.Response( + 404, + json={ + "error": { + "code": "Model.NotExist", + "message": "model decision-model-typo does not exist", + }, + }, + headers={"x-request-id": "rid-404"}, + ) + + client = _make_client(handler, raise_on_error=False) + resp = client.decision.create( + model="decision-model-typo", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert resp.is_error is True + assert resp.status_code == 404 + assert resp.code == "Model.NotExist" + assert resp.message == "model decision-model-typo does not exist" + assert resp.request_id == "rid-404" + assert resp.answers == {} + assert resp.headers["x-request-id"] == "rid-404" + client.close() + + +def test_business_error_layer_raise_and_report(): + body = { + "code": "InvalidParameter", + "message": "state too long", + "request_id": "rid-biz", + } + + def handler(request): + return httpx.Response(200, json=body) + + client = _make_client(handler) + with pytest.raises(BadRequestError) as exc_info: + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert exc_info.value.status_code == 200 + client.close() + + # raise_on_error=False fills the envelope instead of raising. + client = _make_client(handler, raise_on_error=False) + resp = client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert resp.is_error is True + assert resp.code == "InvalidParameter" + assert resp.status_code == 200 + assert resp.answers == {} + client.close() + + +def test_sdk_module_header(): + seen = {} + + def handler(request): + seen["client"] = request.headers.get("x-dashscope-sdk-client") + return httpx.Response(200, json=_DECISION_BODY) + + client = _make_client(handler) + client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert seen["client"] == sdk_client_value("decision") + assert seen["client"].endswith("/decision2") + client.close() + + +async def test_async_create(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + return httpx.Response(200, json=_DECISION_BODY) + + client = _make_async_client(handler) + resp = await client.decision.create( + model="decision-model-preview", + state=_STATE, + questions=_questions(), + ) + assert captured["path"] == "/api/v1/systemone" + assert resp.answers["department"].choice == "billing" + assert resp.answers["escalate"].noul == 0.99 + await client.aclose() + + +async def test_async_http_error_envelope_when_not_raising(): + def handler(request): + return httpx.Response( + 404, + json={ + "error": { + "code": "Model.NotExist", + "message": "model decision-model-typo does not exist", + }, + }, + headers={"x-request-id": "rid-404"}, + ) + + client = _make_async_client(handler, raise_on_error=False) + resp = await client.decision.create( + model="decision-model-typo", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert resp.is_error is True + assert resp.status_code == 404 + assert resp.code == "Model.NotExist" + assert resp.message == "model decision-model-typo does not exist" + assert resp.request_id == "rid-404" + assert resp.answers == {} + await client.aclose() + + +async def test_async_routing_default_gateway(clean_env): + urls = [] + + def handler(request): + urls.append(str(request.url)) + return httpx.Response(200, json=_DECISION_BODY) + + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url=_WS_HOST + "/api/v1", + ) + client = AsyncClient( + api_key="sk-test", + workspace="ws-123", + http_client=http_client, + ) + await client.decision.create( + model="decision-model-preview", + state="s", + questions={"q": {"type": "noul"}}, + ) + assert urls == [_WS_HOST + _SYSTEMONE] + await client.aclose() diff --git a/tests/v2/test_error_registry.py b/tests/v2/test_error_registry.py new file mode 100644 index 00000000..3ba08e95 --- /dev/null +++ b/tests/v2/test_error_registry.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Integrity tests for the vendored error catalog. + +``dashscope/v2/_error_registry.py`` is a generated artifact from the +separate ``dashscope-error-registry`` repo (``tools/generate.py`` over +``data/*.textproto``), vendored at ``__version__ == "1.0.6"``. Only the +``PublicError`` and ``SdkErrorDef`` tables are carried; see +``test_vendored_copy_is_pruned_to_the_tables_v2_consumes``. + +These tests pin the facts the v2 error layer relies on, so that an +upstream regeneration surfaces as a test failure instead of silent +drift. + +Nothing here asserts how ``dashscope/v2/errors.py`` maps the catalog; +that coupling lives in ``tests/v2/test_errors.py``, so this file keeps +depending on the generated artifact alone. +""" + +from __future__ import annotations + +import dataclasses +import re + +import pytest + +from dashscope.agentstudio import exceptions as agentstudio_exceptions +from dashscope.finetune.reinforcement.common import errors as rl_errors +from dashscope.v2 import _error_registry as registry + +PUBLIC = registry.PUBLIC_ERRORS +SDK = registry.SDK_ERRORS + +_PLACEHOLDER = re.compile(r"\{(\w+)\}") + +# Catalog entries whose ``name`` describes an operation outcome rather +# than an exception type. 1.x signals these by raising a generic class +# with a specific message and numeric ``error_code``, so they are +# unreachable from ``type(exc).__name__`` and deliberately stay unused. +_CONDITION_ONLY = { + "sdk.agentic_rl.FunctionRegistrationFailed", + "sdk.agentic_rl.DatasetsUploadFailed", + "sdk.agentic_rl.DuplicateFunctionNames", + "sdk.agentic_rl.JobSubmissionFailed", + "sdk.agentic_rl.WorkflowFailed", + "sdk.agentic_rl.UnsupportedFunctionType", + "sdk.agentic_rl.FunctionTestFailed", + "sdk.agentic_rl.FunctionTestTimeout", +} + +# The nine exception class names the catalog requires. +_REQUIRED_CLASSES = { + "AuthenticationError", + "BadRequestError", + "GatewayTimeoutError", + "InternalServerError", + "NotFoundError", + "PermissionDeniedError", + "RateLimitError", + "RequestTooLargeError", + "ServiceUnavailableError", +} + + +def _classes(module): + return { + name + for name in dir(module) + if isinstance(getattr(module, name), type) + and issubclass(getattr(module, name), BaseException) + } + + +def _by_name(entries): + return {entry.name: entry for entry in entries} + + +def _by_key(key): + for entry in PUBLIC: + if entry.key == key: + return entry + raise AssertionError(f"no public entry with key {key!r}") + + +def test_catalog_size_and_provenance(): + assert registry.__version__ == "1.0.6" + assert registry.__commit__ == "56b7208" + assert registry.__snapshot__ is False + assert len(PUBLIC) == 16 + assert len(SDK) == 32 + + +def test_generated_header_survives_vendoring(): + assert registry.__doc__ == ( + "Auto-generated by tools/generate.py -- DO NOT EDIT." + ) + + +def test_vendored_copy_is_pruned_to_the_tables_v2_consumes(): + """Upstream 1.0.6 also ships a 277-entry ``INTERNAL_ERRORS`` table. + + It describes server-side components (``gateway.*`` / ``mint.*`` / + ``api.*`` / ``turbo.*`` / ``model.*``) through ``InternalErrorDef``, + and its ``cause`` / ``solution`` text is Chinese. A client SDK never + raises any of them, so the table is deliberately not vendored. + + Pinned so the omission stays a recorded decision instead of looking + like a copying accident -- and so that anyone who later vendors the + full artifact has to update this test on purpose. + """ + assert not hasattr(registry, "InternalErrorDef") + assert not hasattr(registry, "INTERNAL_ERRORS") + + +def test_public_entries_well_formed(): + keys = set() + for entry in PUBLIC: + assert entry.key, entry + assert entry.key not in keys, f"duplicate key {entry.key}" + keys.add(entry.key) + assert isinstance(entry.status_code, int) + assert 400 <= entry.status_code <= 599, entry + assert entry.error_code, entry + assert entry.error_msg, entry + assert entry.anthropic_error_code, entry + + +def test_public_entries_are_immutable(): + with pytest.raises(dataclasses.FrozenInstanceError): + PUBLIC[0].key = "mutated" + + +def test_catalog_requires_exactly_nine_exception_classes(): + assert {entry.error_code for entry in PUBLIC} == _REQUIRED_CLASSES + + +def test_catalog_covers_nine_distinct_statuses(): + assert {entry.status_code for entry in PUBLIC} == { + 400, + 401, + 403, + 404, + 413, + 429, + 500, + 503, + 504, + } + + +def test_internal_entries_well_formed(): + names = set() + for entry in SDK: + assert entry.name.startswith("sdk."), entry + assert entry.name not in names, f"duplicate name {entry.name}" + names.add(entry.name) + assert entry.message, entry + assert isinstance(entry.solution, str) + assert isinstance(entry.allow_retry, bool) + assert all(isinstance(v, str) for v in entry.vars) + + +def test_internal_vars_match_message_placeholders(): + """Every declared var is used, and every placeholder is declared.""" + for entry in SDK: + used = set(_PLACEHOLDER.findall(entry.message)) + assert used == set(entry.vars), entry.name + + +def test_public_placeholders_are_undeclared(): + """``PublicError`` has no ``vars`` field, unlike ``SdkErrorDef``. + + Pinned because it means the public side cannot be validated the way + the sdk side is: a caller filling ``{parameter}`` has to discover the + name by reading ``error_msg``. + """ + assert not hasattr(PUBLIC[0], "vars") + with_placeholders = { + entry.key: sorted(set(_PLACEHOLDER.findall(entry.error_msg))) + for entry in PUBLIC + if _PLACEHOLDER.search(entry.error_msg) + } + assert with_placeholders == { + "missing_parameter": ["parameter"], + "resource_not_found": ["resource"], + "request_too_large": ["limit"], + } + + +def test_format_msg_substitution(): + entry = _by_key("missing_parameter") + assert entry.format_msg({"parameter": "model"}) == ( + "Missing required parameter: model." + ) + + +def test_format_msg_leaves_unfilled_placeholder_intact(): + """An unfilled variable must stay visible, never be dropped.""" + entry = _by_key("missing_parameter") + raw = "Missing required parameter: {parameter}." + assert entry.format_msg() == raw + assert entry.format_msg(None) == raw + assert entry.format_msg({}) == raw + assert entry.format_msg({"other": "x"}) == raw + + +def test_format_msg_ignores_unknown_substitutions(): + entry = _by_key("request_too_large") + assert entry.format_msg({"limit": "10MB", "zz": "q"}).endswith("10MB.") + + +def test_format_message_substitution(): + entry = _by_name(SDK)["sdk.agentic_rl.FunctionRegistrationFailed"] + assert entry.format_message({"inner_code": "4011"}) == ( + "Function component registration failed: 4011." + ) + assert entry.format_message() == entry.message + + +def test_agentic_rl_reachability_counts(): + """18 exact class-name hits, plus a family fallback for the rest.""" + rl = _classes(rl_errors) + entries = [e for e in SDK if e.name.startswith("sdk.agentic_rl.")] + assert len(entries) == 27 + + suffixes = {e.name.rsplit(".", 1)[1] for e in entries} + exact = suffixes & rl + assert len(exact) == 18, sorted(exact) + + # The family fallback the two-tier lookup degrades to. + assert "sdk.agentic_rl.Error" in _by_name(SDK) + + unreachable = suffixes - rl + expected = {name.rsplit(".", 1)[1] for name in _CONDITION_ONLY} + expected.add("Error") + assert unreachable == expected + + +def test_every_1x_rl_exception_has_a_catalog_landing_point(): + """The direction that matters: no 1.x class is left unmapped. + + ``AgenticRLError`` is the base class the catalog spells + ``sdk.agentic_rl.Error``; everything else matches by class name. + """ + names = _by_name(SDK) + for cls_name in _classes(rl_errors): + candidate = f"sdk.agentic_rl.{cls_name}" + assert candidate in names or cls_name == "AgenticRLError", cls_name + assert "sdk.agentic_rl.Error" in names + + +def test_agentstudio_transport_errors_fully_covered(): + """The four non-HTTP agentstudio errors map 1:1 onto the catalog.""" + expected = { + "APIConnectionError", + "APITimeoutError", + "StreamError", + "StreamClosedError", + } + entries = [e for e in SDK if e.name.startswith("sdk.agentstudio.")] + assert {e.name.rsplit(".", 1)[1] for e in entries} == expected + assert expected <= _classes(agentstudio_exceptions) + + +def test_agentstudio_has_no_family_fallback(): + """Unlike agentic_rl, this family has no generic ``.Error`` entry. + + Pinned because the sdk-side lookup cannot degrade gracefully here; + the HTTP-layer agentstudio errors carry a real ``status_code`` and + belong to the status mapping, not to ``SDK_ERRORS``. + """ + assert "sdk.agentstudio.Error" not in _by_name(SDK) + status_classes = { + name + for name in _classes(agentstudio_exceptions) + if issubclass( + getattr(agentstudio_exceptions, name), + agentstudio_exceptions.APIStatusError, + ) + } + catalogued = { + e.name.rsplit(".", 1)[1] + for e in SDK + if e.name.startswith("sdk.agentstudio.") + } + assert status_classes.isdisjoint(catalogued) + assert status_classes, "expected 1.x agentstudio HTTP error classes" + + +def test_invalid_api_key_is_the_only_unfamily_entry(): + others = [ + e.name + for e in SDK + if not e.name.startswith(("sdk.agentic_rl.", "sdk.agentstudio.")) + ] + assert others == ["sdk.InvalidApiKey"] diff --git a/tests/v2/test_errors.py b/tests/v2/test_errors.py new file mode 100644 index 00000000..617abffd --- /dev/null +++ b/tests/v2/test_errors.py @@ -0,0 +1,551 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the dashscope 2.0 error model. + +Two things are pinned here. First, the runtime behaviour of the +mapping functions (which class a given status / wire code produces). +Second, the coupling to the generated catalog in +``dashscope/v2/_error_registry.py``: exception class names, their +``status_code``, and the wire-code crosswalk must all be derivable +from it, so an upstream regeneration shows up as a test failure +instead of silent drift. +""" + +from __future__ import annotations + +import httpx +import pytest + +from dashscope.v2 import _error_registry as registry +from dashscope.v2 import errors +from dashscope.v2.errors import ( + APIStatusError, + AuthenticationError, + BadRequestError, + ContentFilterError, + DashScopeError, + ErrorCode, + GatewayTimeoutError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + RequestTooLargeError, + SdkError, + ServiceUnavailableError, + check_response, + error_from_business_code, + error_from_sdk_exception, +) +from dashscope.v2.errors import ( + _CODE_MAP, + _REFINED_CLASS, + _SDK_ERRORS, + _STATUS_MAP, + _WIRE_TO_PUBLIC, +) +from dashscope.v2.types.common import JobStatus, ResponseBase + + +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 test_status_mapping_and_attributes(): + resp = _resp( + 401, + { + "code": "InvalidApiKey", + "message": "Invalid API-key provided.", + "request_id": "req-1", + }, + ) + with pytest.raises(AuthenticationError) as ei: + check_response(resp) + err = ei.value + assert err.code == "InvalidApiKey" + assert err.status_code == 401 + assert err.request_id == "req-1" + assert err.message == "Invalid API-key provided." + assert err.body["code"] == "InvalidApiKey" + assert "AuthenticationError" in str(err) + + +def test_openai_error_envelope_parsed(): + resp = _resp( + 401, + { + "error": { + "message": "Incorrect API key provided.", + "type": "invalid_request_error", + "code": "invalid_api_key", + }, + }, + ) + with pytest.raises(AuthenticationError) as ei: + check_response(resp) + err = ei.value + assert err.message == "Incorrect API key provided." + assert err.code == "invalid_api_key" + assert err.status_code == 401 + + +def test_anthropic_error_envelope_parsed(): + resp = _resp( + 429, + { + "type": "error", + "error": { + "type": "rate_limit_error", + "message": "Number of requests exceeded.", + }, + }, + ) + with pytest.raises(RateLimitError) as ei: + check_response(resp) + assert ei.value.message == "Number of requests exceeded." + assert ei.value.code == "rate_limit_error" + + +def test_native_fields_take_precedence_over_envelope(): + resp = _resp( + 400, + { + "code": "InvalidParameter", + "message": "native says no", + "error": {"message": "nested", "code": "nested_code"}, + }, + ) + with pytest.raises(BadRequestError) as ei: + check_response(resp) + assert ei.value.message == "native says no" + assert ei.value.code == "InvalidParameter" + + +def test_non_dict_error_envelope_ignored(): + resp = _resp(400, {"message": "plain", "error": "oops"}) + with pytest.raises(BadRequestError) as ei: + check_response(resp) + assert ei.value.message == "plain" + + +def test_status_only_mapping(): + cases = [ + (400, {"message": "bad"}, BadRequestError), + (401, None, AuthenticationError), + (403, {"code": "AccessDenied"}, PermissionDeniedError), + (404, None, NotFoundError), + (413, None, RequestTooLargeError), + (429, {"code": "Throttling.RateQuota"}, RateLimitError), + (500, {"code": "InternalError"}, InternalServerError), + (503, None, ServiceUnavailableError), + (504, None, GatewayTimeoutError), + ] + for status, body, expected in cases: + with pytest.raises(expected) as ei: + check_response(_resp(status, body)) + assert type(ei.value) is expected, status + assert ei.value.status_code == status + + +def test_uncatalogued_status_falls_back_to_the_base_class(): + # The catalog defines 400/401/403/404/413/429/500/503/504. Anything + # else -- including 5xx the catalog does not list -- stays the bare + # base class rather than being guessed into InternalServerError. + for status in (418, 409, 502, 505): + with pytest.raises(APIStatusError) as ei: + check_response(_resp(status, {"message": "nope"})) + assert type(ei.value) is APIStatusError, status + assert ei.value.status_code == status + + +def test_content_filter_from_status(): + resp = _resp(400, {"code": "DataInspectionFailed", "message": "blocked"}) + with pytest.raises(ContentFilterError) as ei: + check_response(resp) + assert isinstance(ei.value, BadRequestError) + assert ei.value.status_code == 400 + + +def test_business_error_on_http_200(): + resp = _resp( + 200, + { + "code": "DataInspectionFailed", + "message": "output blocked", + "request_id": "r2", + }, + ) + with pytest.raises(ContentFilterError) as ei: + check_response(resp) + assert ei.value.status_code == 200 + assert ei.value.request_id == "r2" + # Same class on both layers, so one `except ContentFilterError` + # covers moderation blocks however the gateway chose to report them. + assert isinstance(ei.value, BadRequestError) + + +def test_unknown_business_code_on_http_200(): + resp = _resp(200, {"code": "Some.Future.Code", "message": "boom"}) + with pytest.raises(DashScopeError) as ei: + check_response(resp) + assert type(ei.value) is DashScopeError + assert ei.value.code == "Some.Future.Code" + + +def test_ok_response_passes(): + check_response(_resp(200, {"output": {"text": "hi"}, "request_id": "r3"})) + + +def test_raise_on_error_false_swallows(): + check_response( + _resp(429, {"code": "Throttling", "message": "slow down"}), + raise_on_error=False, + ) + check_response( + _resp(200, {"code": "Throttling", "message": "slow down"}), + raise_on_error=False, + ) + + +def test_response_base_flags_business_code(): + # Non-raising mode leaves the parsed model as the only channel, so it + # has to report the failure itself: same rule response_error applies. + assert ResponseBase(code="Throttling", message="slow down").is_error + assert not ResponseBase(request_id="r1").is_error + # An explicitly flagged envelope stays flagged without a code. + assert ResponseBase(is_error=True).is_error + + +def test_response_base_subclasses_inherit_the_flag(): + assert JobStatus(status="FAILED", code="Job.Failed").is_error + assert not JobStatus(status="SUCCEEDED").is_error + + +def test_non_json_body(): + resp = httpx.Response( + 502, + content=b"Bad Gateway", + headers={"content-type": "text/plain"}, + request=httpx.Request("GET", "https://api.test.local/x"), + ) + with pytest.raises(APIStatusError) as ei: + check_response(resp) + assert ei.value.body is None + assert ei.value.status_code == 502 + + +def test_sse_error_body_parsed(): + # A streaming request rejected before any output arrives as a 4xx + # whose body is the error frame itself; the frame's payload must + # drive the mapping exactly like the JSON body of a non-streaming + # failure. + body = ( + "event:error\n" + 'data:{"code":"InvalidParameter",' + '"message":"Partial mode is not supported when ' + 'enable_thinking is true","request_id":"sse-req-1"}' + ) + resp = httpx.Response( + 400, + content=body.encode("utf-8"), + headers={"content-type": "text/event-stream"}, + request=httpx.Request("POST", "https://api.test.local/x"), + ) + with pytest.raises(BadRequestError) as ei: + check_response(resp) + err = ei.value + assert err.code == "InvalidParameter" + assert err.message == ( + "Partial mode is not supported when enable_thinking is true" + ) + assert err.status_code == 400 + assert err.request_id == "sse-req-1" + assert err.body["code"] == "InvalidParameter" + + +def test_sse_error_body_wire_code_picks_the_class(): + # Wire-code priority must survive the SSE framing: the status alone + # would say BadRequestError, the frame says content moderation. The + # content-type header is deliberately absent -- frames are detected + # by shape, like resources/_sse.py. + body = ( + "event:error\n" + 'data:{"code":"DataInspectionFailed","message":"blocked",' + '"request_id":"sse-req-2"}' + ) + resp = httpx.Response( + 400, + content=body.encode("utf-8"), + request=httpx.Request("POST", "https://api.test.local/x"), + ) + with pytest.raises(ContentFilterError) as ei: + check_response(resp) + assert isinstance(ei.value, BadRequestError) + assert ei.value.code == "DataInspectionFailed" + assert ei.value.request_id == "sse-req-2" + + +def test_sse_body_without_decodable_frame_keeps_raw_text(): + # No JSON in the frame: the raw-text fallback stands and the class + # comes from the status alone. + resp = httpx.Response( + 400, + content=b"event:error\ndata:oops", + request=httpx.Request("POST", "https://api.test.local/x"), + ) + with pytest.raises(BadRequestError) as ei: + check_response(resp) + assert ei.value.code is None + assert ei.value.body is None + assert ei.value.message == "event:error\ndata:oops" + + +def test_error_code_enum(): + assert ErrorCode.INVALID_API_KEY.value == "InvalidApiKey" + assert ErrorCode.DATA_INSPECTION_FAILED.value == "DataInspectionFailed" + assert ErrorCode("Throttling") is ErrorCode.THROTTLING + + +def test_request_id_from_header_fallback(): + resp = _resp( + 404, + {"message": "not found"}, + headers={"x-request-id": "hdr-req-1"}, + ) + with pytest.raises(NotFoundError) as ei: + check_response(resp) + assert ei.value.request_id == "hdr-req-1" + + +def test_every_catalog_error_code_names_a_real_class(): + for entry in registry.PUBLIC_ERRORS: + cls = getattr(errors, entry.error_code, None) + assert isinstance(cls, type), entry.error_code + assert issubclass(cls, APIStatusError), entry.error_code + assert cls.status_code == entry.status_code, entry.error_code + + +def test_catalog_classes_are_flat_siblings(): + classes = { + entry.error_code: getattr(errors, entry.error_code) + for entry in registry.PUBLIC_ERRORS + } + assert len(classes) == 9 + for name, cls in classes.items(): + assert cls.__bases__ == (APIStatusError,), name + for other_name, other in classes.items(): + if other is not cls: + assert not issubclass(cls, other), (name, other_name) + + +def test_status_map_is_derived_from_the_catalog(): + catalogued = {e.status_code for e in registry.PUBLIC_ERRORS} + assert set(_STATUS_MAP) == catalogued + for entry in registry.PUBLIC_ERRORS: + expected = getattr(errors, entry.error_code) + assert _STATUS_MAP[entry.status_code] is expected, entry.key + + +def test_catalog_keys_and_wire_codes_are_separate_namespaces(): + # Why the crosswalk has to exist at all: the catalog's `key` is an + # internal canonical name, never a value the gateway sends. + keys = {entry.key for entry in registry.PUBLIC_ERRORS} + assert keys.isdisjoint({member.value for member in ErrorCode}) + + +def test_crosswalk_covers_every_declared_wire_code(): + assert set(_WIRE_TO_PUBLIC) == {member.value for member in ErrorCode} + assert set(_CODE_MAP) == set(_WIRE_TO_PUBLIC) + + +def test_crosswalk_values_are_catalog_entries(): + catalogued = {id(entry) for entry in registry.PUBLIC_ERRORS} + for wire, entry in _WIRE_TO_PUBLIC.items(): + assert id(entry) in catalogued, wire + + +def test_wire_code_never_leaves_the_catalogued_class(): + # A wire code may resolve to a more specific V2 class, but only a + # subclass of the one its catalog entry names -- never a sibling. + for wire, entry in _WIRE_TO_PUBLIC.items(): + catalogued = getattr(errors, entry.error_code) + assert issubclass(_CODE_MAP[wire], catalogued), wire + + +def test_unrefined_wire_codes_map_to_exactly_the_catalogued_class(): + for wire, entry in _WIRE_TO_PUBLIC.items(): + if wire in _REFINED_CLASS: + continue + catalogued = getattr(errors, entry.error_code) + assert _CODE_MAP[wire] is catalogued, wire + + +def test_content_filter_is_the_only_refinement(): + assert _REFINED_CLASS == {"DataInspectionFailed": ContentFilterError} + assert issubclass(ContentFilterError, BadRequestError) + + +def test_previously_unmapped_wire_codes_now_resolve(): + # Both were declared in ErrorCode but missing from the old + # hand-written code map, so InvalidParameter fell back to whatever + # the HTTP status implied and Model.NotExist never became a 404. + assert _CODE_MAP["InvalidParameter"] is BadRequestError + assert _CODE_MAP["Model.NotExist"] is NotFoundError + + +def test_service_unavailable_and_gateway_timeout_are_distinguishable(): + assert _CODE_MAP["ServiceUnavailable"] is ServiceUnavailableError + assert _CODE_MAP["GatewayTimeout"] is GatewayTimeoutError + assert not issubclass(ServiceUnavailableError, GatewayTimeoutError) + assert not issubclass(GatewayTimeoutError, ServiceUnavailableError) + + +def test_business_layer_resolves_wire_codes_without_a_status(): + # HTTP 200 with a business code has no status to fall back on, so + # the crosswalk is the only thing that can pick a class there. + err = error_from_business_code( + message="no such model", + code="Model.NotExist", + ) + assert type(err) is NotFoundError + assert err.status_code == 200 + assert err.code == "Model.NotExist" + + +# -- catalog text as the last-resort message --------------------------- + + +def _bare(status, headers=None): + """A response with no body at all, so ``response.text`` is empty.""" + return httpx.Response( + status, + headers=headers or {}, + request=httpx.Request("POST", "https://api.test.local/x"), + ) + + +def test_bodyless_failure_gets_the_catalog_text(): + with pytest.raises(ServiceUnavailableError) as ei: + check_response(_bare(503)) + assert ei.value.message == ( + "The service is temporarily unavailable. Please try again later." + ) + + +def test_gateway_message_wins_over_catalog_text(): + resp = _resp(503, {"message": "upstream drained"}) + with pytest.raises(ServiceUnavailableError) as ei: + check_response(resp) + assert ei.value.message == "upstream drained" + + +def test_placeholders_survive_the_catalog_fallback(): + # Nothing in the SDK knows the resource name, and dropping the + # placeholder would hide the field the catalog declares. + with pytest.raises(NotFoundError) as ei: + check_response(_bare(404)) + assert "{resource}" in ei.value.message + + +def test_ambiguous_status_gets_no_catalog_text(): + # 400 / 401 / 429 each carry several entries with different text, so + # a status alone cannot pick one without inventing a reason. + for status in (400, 401, 429): + with pytest.raises(APIStatusError) as ei: + check_response(_bare(status)) + assert ei.value.message == "", status + assert ei.value.status_code == status + + +def test_wire_code_identifies_an_entry_on_an_ambiguous_status(): + err = errors.error_from_status( + 400, + message="", + body={"code": "InvalidParameter"}, + ) + assert type(err) is BadRequestError + assert err.message == ( + "The request is invalid. Please check the request and try again." + ) + + +def test_uncatalogued_status_gets_no_catalog_text(): + with pytest.raises(APIStatusError) as ei: + check_response(_bare(502)) + assert type(ei.value) is APIStatusError + assert ei.value.message == "" + + +def test_business_layer_fills_text_from_the_code_alone(): + err = error_from_business_code(message="", code="Throttling") + assert type(err) is RateLimitError + assert err.message == ( + "Rate limit exceeded. Please slow down your requests." + ) + assert err.status_code == 200 + + +# -- SDK-side errors (catalog SDK_ERRORS, no HTTP response) ------------ + + +class OSSUploadError(Exception): + """Stand-in matching a catalogued 1.x class name.""" + + +class NotInCatalog(Exception): + """Stand-in with no catalog entry under any family.""" + + +def test_sdk_error_has_no_http_semantics(): + assert issubclass(SdkError, DashScopeError) + assert not issubclass(SdkError, APIStatusError) + assert SdkError("boom").status_code is None + + +def test_sdk_error_translation_resolves_the_catalog_by_class_name(): + err = error_from_sdk_exception( + OSSUploadError("bucket denied"), + family="sdk.agentic_rl", + ) + entry = _SDK_ERRORS["sdk.agentic_rl.OSSUploadError"] + assert err.code == entry.name + assert err.solution == entry.solution + assert err.allow_retry == entry.allow_retry + + +def test_sdk_error_translation_keeps_the_original_message(): + # The catalog message is generic ("File upload operation to OSS + # failed."); what the subsystem actually said is more useful. + err = error_from_sdk_exception( + OSSUploadError("bucket denied"), + family="sdk.agentic_rl", + ) + entry = _SDK_ERRORS["sdk.agentic_rl.OSSUploadError"] + assert err.message == "bucket denied" + assert err.message != entry.message + + +def test_sdk_error_translation_falls_back_to_the_family_entry(): + err = error_from_sdk_exception( + NotInCatalog("odd"), + family="sdk.agentic_rl", + ) + assert err.code == "sdk.agentic_rl.Error" + assert err.message == "odd" + + +def test_sdk_error_translation_needs_a_family_fallback(): + # sdk.agentstudio.* has no .Error entry, so the two-tier lookup + # cannot serve it -- which is why that mount needs its own scheme. + with pytest.raises(KeyError) as ei: + error_from_sdk_exception( + NotInCatalog("odd"), + family="sdk.agentstudio", + ) + assert "sdk.agentstudio.Error" in str(ei.value) diff --git a/tests/v2/test_files_models.py b/tests/v2/test_files_models.py new file mode 100644 index 00000000..ff76efbe --- /dev/null +++ b/tests/v2/test_files_models.py @@ -0,0 +1,523 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for files/models resources (M1 task 1.8).""" + +from __future__ import annotations + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import BadRequestError, NotFoundError + +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 test_files_upload(tmp_path): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["content_type"] = request.headers.get("content-type", "") + captured["body"] = request.content.decode("utf-8", "replace") + return httpx.Response( + 200, + json={ + "request_id": "req-u", + "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 = _make_client(handler) + resp = client.files.upload( + file_path=str(file_path), + purpose="fine-tune", + description="train", + ) + assert captured["path"] == "/api/v1/files" + assert captured["content_type"].startswith("multipart/form-data") + assert 'name="purpose"' not in captured["body"] + assert 'name="descriptions"' in captured["body"] + assert '["train"]' not in captured["body"] + assert "t.jsonl" in captured["body"] + assert resp.file_id == "f-1" + assert resp.name == "t.jsonl" + + +def test_files_upload_validation(tmp_path): + client = _make_client(lambda r: httpx.Response(200, json={"data": {}})) + with pytest.raises(FileNotFoundError): + client.files.upload(file_path=str(tmp_path / "nope.jsonl")) + + bad = tmp_path / "bad.jsonl" + bad.write_text("not json\n", encoding="utf-8") + with pytest.raises(ValueError, match="jsonl"): + client.files.upload(file_path=str(bad)) + + resp = client.files.upload(file_path=str(bad), purpose="inference") + assert resp is not None + + +def test_files_upload_retry_resends_full_body(tmp_path, monkeypatch): + bodies = [] + + def handler(request): + bodies.append(request.content) + if len(bodies) == 1: + return httpx.Response(503, json={"message": "busy"}) + return httpx.Response( + 200, + json={ + "request_id": "req-r", + "data": {"uploaded_files": [{"file_id": "f-r"}]}, + }, + ) + + from dashscope.v2.transport import http as transport_http + + monkeypatch.setattr(transport_http, "_sleep", lambda seconds: None) + file_path = tmp_path / "t.jsonl" + file_path.write_text('{"a": 1}\n', encoding="utf-8") + client = _make_client(handler) + resp = client.files.upload(file_path=str(file_path)) + assert resp.file_id == "f-r" + assert len(bodies) == 2 + # Multipart boundaries are random per request; what matters is the + # retry re-sends the full payload rather than a drained handle. + for body in bodies: + assert b'{"a": 1}' in body + assert b"t.jsonl" in body + + +def test_files_list_get_delete(): + def handler(request): + if request.method == "GET" and request.url.path == "/api/v1/files": + assert request.url.params["page_no"] == "2" + return httpx.Response( + 200, + json={ + "request_id": "r", + "data": { + "page_no": 2, + "page_size": 10, + "total": 1, + "files": [{"file_id": "f-1", "name": "t.jsonl"}], + }, + }, + ) + if request.method == "GET": + return httpx.Response( + 200, + json={ + "data": {"file_id": "f-1", "name": "t.jsonl", "size": 10}, + }, + ) + if request.method == "DELETE": + return httpx.Response(200, json={"request_id": "r"}) + raise AssertionError("unexpected request") + + client = _make_client(handler) + listed = client.files.list(page_no=2) + assert listed.total_count == 1 + assert listed.files[0].file_id == "f-1" + got = client.files.get("f-1") + assert got.size == 10 + deleted = client.files.delete("f-1") + assert deleted.deleted is True + + +def test_files_get_missing_file_raises_not_found(): + # DashScope reports a missing file as a 400 InvalidParameter ("File not + # found.") rather than a 404, so the catalog mapper yields BadRequestError. + # get/delete reclassify it so ``except NotFoundError`` works, while keeping + # the server's own status/code/request_id (never an invented 404). + client = _make_client( + lambda r: httpx.Response( + 400, + json={ + "code": "InvalidParameter", + "message": "File not found.", + "request_id": "req-nf", + }, + ), + ) + with pytest.raises(NotFoundError) as exc: + client.files.get("missing-id") + err = exc.value + assert err.code == "InvalidParameter" + assert err.status_code == 400 + assert err.request_id == "req-nf" + assert err.message == "File not found." + + +def test_files_delete_missing_file_raises_not_found(): + client = _make_client( + lambda r: httpx.Response( + 400, + json={ + "code": "InvalidParameter", + "message": "File not found.", + }, + ), + ) + with pytest.raises(NotFoundError): + client.files.delete("missing-id") + + +def test_files_missing_file_not_found_regardless_of_status(): + # InvalidParameter selects BadRequestError at any status (the wire code + # outranks the status), so the reclassification cannot hinge on 400. + for status in (400, 404): + client = _make_client( + lambda r, s=status: httpx.Response( + s, + json={ + "code": "InvalidParameter", + "message": "File not found.", + }, + ), + ) + with pytest.raises(NotFoundError) as exc: + client.files.get("missing-id") + assert exc.value.status_code == status + + +def test_files_get_delete_empty_file_id_rejected_locally(): + # "" (or blank) would collapse the path to "/files/" and hit the list + # endpoint; the call must fail locally without touching the transport. + calls = [] + + def handler(request): + calls.append(request) + return httpx.Response(200, json={"data": {}}) + + client = _make_client(handler) + for file_id in ("", " "): + with pytest.raises(ValueError, match="file_id"): + client.files.get(file_id) + with pytest.raises(ValueError, match="file_id"): + client.files.delete(file_id) + assert calls == [] + + +@pytest.mark.asyncio +async def test_async_files_get_delete_empty_file_id_rejected_locally(): + calls = [] + + def handler(request): + calls.append(request) + return httpx.Response(200, json={"data": {}}) + + client = _make_async_client(handler) + for file_id in ("", " "): + with pytest.raises(ValueError, match="file_id"): + await client.files.get(file_id) + with pytest.raises(ValueError, match="file_id"): + await client.files.delete(file_id) + assert calls == [] + await client.aclose() + + +def test_files_get_other_bad_request_stays_bad_request(): + # Only a "not found"/"not exist" message is reclassified; any other + # InvalidParameter (a genuinely malformed request) stays BadRequestError. + client = _make_client( + lambda r: httpx.Response( + 400, + json={ + "code": "InvalidParameter", + "message": "file_id contains illegal characters", + }, + ), + ) + with pytest.raises(BadRequestError) as exc: + client.files.get("bad id") + assert type(exc.value) is BadRequestError + + +def test_files_get_missing_file_no_raise_reports_envelope(): + # With raise_on_error=False nothing is raised, so there is no exception to + # reclassify; the envelope keeps the gateway's own code. + client = _make_client( + lambda r: httpx.Response( + 400, + json={ + "code": "InvalidParameter", + "message": "File not found.", + "request_id": "req-nf", + }, + ), + raise_on_error=False, + ) + info = client.files.get("missing-id") + assert info.is_error + assert info.code == "InvalidParameter" + assert info.request_id == "req-nf" + + +@pytest.mark.asyncio +async def test_async_files_get_missing_file_raises_not_found(): + client = _make_async_client( + lambda r: httpx.Response( + 400, + json={ + "code": "InvalidParameter", + "message": "File not found.", + }, + ), + ) + with pytest.raises(NotFoundError): + await client.files.get("missing-id") + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_files_delete_missing_file_raises_not_found(): + client = _make_async_client( + lambda r: httpx.Response( + 400, + json={ + "code": "InvalidParameter", + "message": "File not found.", + }, + ), + ) + with pytest.raises(NotFoundError): + await client.files.delete("missing-id") + await client.aclose() + + +def test_models_list_with_provider_filter(): + # ``data``-wrapped pages stay supported for gateways that re-wrap + # the native ``output`` envelope OpenAI-style. + captured = {} + + def handler(request): + captured["params"] = dict(request.url.params) + return httpx.Response( + 200, + json={ + "request_id": "r", + "data": { + "page_no": 1, + "page_size": 10, + "total": 3, + "models": [ + {"model": "qwen-plus", "source": "dashscope"}, + {"model": "qwen3-max", "source": "bailian"}, + {"model": "qwen-turbo", "provider": "Community"}, + ], + }, + }, + ) + + client = _make_client(handler) + all_models = client.models.list() + assert captured["params"] == {"page_no": "1", "page_size": "10"} + assert len(all_models.models) == 3 + + bailian = client.models.list(provider="Bailian") + assert [m.model for m in bailian.models] == ["qwen3-max"] + + community = client.models.list(provider="community") + assert [m.model for m in community.models] == ["qwen-turbo"] + + +def test_models_list_parses_native_output_envelope(): + # The native /api/v1/models envelope nests the page under + # ``output``; parsing only ``data`` yielded an empty list with + # default pagination against the real endpoint. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-page", + "output": { + "page_no": 1, + "page_size": 3, + "total": 8, + "models": [ + {"model": "qwen-plus", "source": "dashscope"}, + {"model": "qwen3-max", "source": "bailian"}, + {"model": "qwen-turbo", "source": "dashscope"}, + ], + }, + }, + ) + + client = _make_client(handler) + page = client.models.list(page_no=1, page_size=3) + assert page.page_no == 1 + assert page.page_size == 3 + assert page.total_count == 8 + assert [m.model for m in page.models] == [ + "qwen-plus", + "qwen3-max", + "qwen-turbo", + ] + assert page.request_id == "req-page" + + +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). + def handler(request): + return httpx.Response( + 200, + json={"request_id": "req-h", "output": {"models": []}}, + headers={"x-request-id": "req-h", "x-gw-region": "cn-hz"}, + ) + + client = _make_client(handler) + page = client.models.list() + assert page.headers["x-request-id"] == "req-h" + assert page.headers["x-gw-region"] == "cn-hz" + + +def test_models_get(): + def handler(request): + assert request.url.path == "/api/v1/models" + assert request.url.params["model"] == "qwen-plus" + assert request.url.params["page_no"] == "1" + assert request.url.params["page_size"] == "1" + return httpx.Response( + 200, + json={ + "output": { + "models": [ + {"model": "qwen-plus", "source": "dashscope"}, + ], + }, + }, + ) + + client = _make_client(handler) + model = client.models.get("qwen-plus") + assert model.model == "qwen-plus" + assert model.source == "dashscope" + + +def test_models_get_not_found(): + client = _make_client( + lambda r: httpx.Response( + 200, + json={"output": {"models": []}}, + headers={"x-request-id": "req-none"}, + ), + ) + with pytest.raises(NotFoundError, match="not found") as exc: + client.models.get("nope") + error = exc.value + assert error.code == "Model.NotExist" + # The list call itself succeeded; the model was simply absent. So the + # status is the one the server sent, not an invented 404. + assert error.status_code == 200 + assert error.request_id == "req-none" + + +@pytest.mark.asyncio +async def test_async_files_models(): + def handler(request): + if request.url.path == "/api/v1/models": + return httpx.Response( + 200, + json={ + "data": { + "models": [ + {"model": "qwen-plus", "source": "dashscope"}, + ], + }, + }, + ) + if request.url.path == "/api/v1/files": + return httpx.Response( + 200, + json={ + "data": {"files": [{"file_id": "f-9"}], "total_count": 1}, + }, + ) + return httpx.Response(200, json={"data": {"file_id": "f-9"}}) + + client = _make_async_client(handler) + models = await client.models.list() + assert models.models[0].model == "qwen-plus" + files = await client.files.list() + assert files.files[0].file_id == "f-9" + one = await client.files.get("f-9") + assert one.file_id == "f-9" + await client.aclose() + + +def test_business_code_reaches_single_item_responses(): + # DashScope can fail with HTTP 200 plus a business code. These two + # parsers build their result from an item nested under data/output, so + # the envelope has to be threaded on separately for is_error to mean the + # same thing here as it does on chat and apps. + def handler(request): + if request.url.path == "/api/v1/models": + return httpx.Response( + 200, + json={ + "request_id": "req-m", + "code": "Throttling", + "message": "slow down", + "output": {"models": []}, + }, + ) + return httpx.Response( + 200, + json={ + "request_id": "req-f", + "code": "Throttling", + "message": "slow down", + "data": {"file_id": "f-1"}, + }, + ) + + client = _make_client(handler, raise_on_error=False) + model = client.models.get("qwen-plus") + assert model.is_error + assert model.code == "Throttling" + assert model.request_id == "req-m" + + info = client.files.get("f-1") + assert info.is_error + assert info.code == "Throttling" + assert info.request_id == "req-f" + + +def test_nested_list_items_carry_no_envelope(): + def handler(request): + assert request.url.path == "/api/v1/files" + return httpx.Response( + 200, + json={ + "request_id": "req-l", + "data": {"files": [{"file_id": "f-1"}], "total": 1}, + }, + ) + + page = _make_client(handler).files.list() + assert page.request_id == "req-l" + assert not page.is_error + # The envelope belongs to the response, not to each item inside it. + assert page.files[0].request_id is None + assert not page.files[0].is_error diff --git a/tests/v2/test_finetune_deployments.py b/tests/v2/test_finetune_deployments.py new file mode 100644 index 00000000..ba9f8719 --- /dev/null +++ b/tests/v2/test_finetune_deployments.py @@ -0,0 +1,795 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.fine_tunes and client.deployments (M3 3.6).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import ( + BadRequestError, + DashScopeError, + RateLimitError, +) +from dashscope.v2.resources.fine_tunes import FineTune + +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 _job_payload(status, job_id="ft-1"): + return { + "request_id": "req-1", + "output": { + "job_id": job_id, + "status": status, + "model": "qwen-turbo", + "base_model": "qwen-turbo", + "training_file_ids": ["file-1"], + "validation_file_ids": [], + "hyper_parameters": {"n_epochs": 2}, + "training_type": "sft", + }, + } + + +def test_create_builds_wire_body(): + captured = {} + + def handler(request): + captured["method"] = request.method + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_job_payload("PENDING")) + + client = _make_client(handler) + job = client.fine_tunes.create( + model="qwen-turbo", + training_file_ids="file-1", + mode="sft", + hyper_parameters={"n_epochs": 2}, + finetuned_model="my-model", + ) + assert captured["method"] == "POST" + assert captured["path"] == "/api/v1/fine-tunes" + body = captured["body"] + assert body["model"] == "qwen-turbo" + assert body["training_file_ids"] == ["file-1"] + assert body["validation_file_ids"] is None + assert body["training_type"] == "sft" + assert body["hyper_parameters"] == {"n_epochs": 2} + assert body["model_name"] == "my-model" + # ``finetuned_output`` is a response-only field; sending it on create + # makes the server reject the request with 400 InvalidParameter. + assert "finetuned_output" not in body + assert job.job_id == "ft-1" + assert job.status == "PENDING" + assert job.request_id == "req-1" + + +def test_get_list_cancel_delete_paths(): + seen = [] + + def handler(request): + seen.append((request.method, request.url.path)) + if request.method == "GET" and request.url.path.endswith("ft-1"): + return httpx.Response(200, json=_job_payload("RUNNING")) + if request.method == "GET": + assert dict(request.url.params) == { + "page_no": "2", + "page_size": "5", + } + payload = _job_payload("SUCCEEDED") + return httpx.Response( + 200, + json={ + "request_id": "req-2", + "output": { + "page_no": 2, + "page_size": 5, + "total": 11, + "jobs": [payload["output"]], + }, + }, + ) + return httpx.Response( + 200, + json={"request_id": "req-3", "output": {"status": "done"}}, + ) + + client = _make_client(handler) + job = client.fine_tunes.get("ft-1") + assert job.status == "RUNNING" + listed = client.fine_tunes.list(page_no=2, page_size=5) + assert listed.total_count == 11 + assert listed.jobs[0].job_id == "ft-1" + canceled = client.fine_tunes.cancel("ft-1") + assert canceled.status == "done" + deleted = client.fine_tunes.delete("ft-1") + assert deleted.status == "done" + assert seen == [ + ("GET", "/api/v1/fine-tunes/ft-1"), + ("GET", "/api/v1/fine-tunes"), + ("POST", "/api/v1/fine-tunes/ft-1/cancel"), + ("DELETE", "/api/v1/fine-tunes/ft-1"), + ] + + +def test_wait_polls_until_terminal(): + statuses = iter(["RUNNING", "RUNNING", "SUCCEEDED"]) + delays = [] + + def handler(request): + return httpx.Response(200, json=_job_payload(next(statuses))) + + client = _make_client(handler) + job = client.fine_tunes.wait( + "ft-1", + _sleep=delays.append, + ) + assert job.status == "SUCCEEDED" + assert delays == [1.0, 2.0] + + +def test_unknown_status_is_not_terminal(): + """The fine-tune plane deliberately excludes ``UNKNOWN``. + + 1.x lists ``TaskStatus.UNKNOWN`` as terminal only in + ``client/base_api.py`` (async-task plane); ``finetune/finetunes.py`` + waits on FAILED/CANCELED/SUCCEEDED alone, and ``fine_tunes._TERMINAL`` + mirrors that narrower rule — the opposite boundary of + ``_jobs._TERMINAL``, which does treat UNKNOWN as terminal. + """ + job = FineTune(job_id="ft-1", status="UNKNOWN") + assert job.finished is False + assert job.succeeded is False + + +def test_wait_does_not_stop_on_unknown(): + """An UNKNOWN fine-tune snapshot keeps polling; it never ends the wait.""" + statuses = iter(["UNKNOWN", "SUCCEEDED"]) + delays = [] + + def handler(request): + return httpx.Response(200, json=_job_payload(next(statuses))) + + client = _make_client(handler) + job = client.fine_tunes.wait( + "ft-1", + _sleep=delays.append, + ) + assert job.status == "SUCCEEDED" + assert delays == [1.0] + + +def test_wait_failed_job_raises(): + def handler(request): + payload = _job_payload("FAILED") + payload["output"]["code"] = "InternalError" + payload["output"]["message"] = "training crashed" + return httpx.Response( + 200, + json=payload, + headers={"x-request-id": "req-h", "x-gw-region": "cn-hz"}, + ) + + client = _make_client(handler) + with pytest.raises(DashScopeError) as exc: + client.fine_tunes.wait("ft-1", _sleep=lambda s: None) + error = exc.value + assert error.code == "InternalError" + # The server said why the job failed, so that reason reaches the + # caller rather than a generic local sentence. + assert error.message == "training crashed" + assert error.headers["x-request-id"] == "req-h" + assert error.headers["x-gw-region"] == "cn-hz" + # ``body`` stays the job snapshot: ``headers`` is transport metadata + # and must not be smuggled into it, since callers log ``err.body``. + assert "headers" not in error.body + assert error.body["status"] == "FAILED" + assert error.body["message"] == "training crashed" + + +def test_wait_returns_error_envelope_in_compat_mode(): + def handler(request): + return httpx.Response( + 401, + json={ + "request_id": "req-x", + "code": "InvalidApiKey", + "message": "bad key", + }, + ) + + client = _make_client(handler, raise_on_error=False) + job = client.fine_tunes.wait("ft-1", _sleep=lambda s: None) + # Compat mode: the first failed poll comes back as the envelope + # instead of spinning to the timeout and raising there. + assert job.is_error is True + assert job.code == "InvalidApiKey" + assert job.request_id == "req-x" + + +def test_stream_events_yields_frames_and_raises_on_error(): + sse = ( + 'id: 1\ndata: {"status": "RUNNING"}\n\n' + "id: 2\nevent:error\nstatus: 400\n" + 'data: {"code": "InvalidParameter", "message": "boom"}\n\n' + ) + + def handler(request): + assert request.url.path == "/api/v1/fine-tunes/ft-1/stream" + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + events = client.fine_tunes.stream_events("ft-1") + first = next(events) + assert first.output == {"status": "RUNNING"} + assert first.is_error is False + with pytest.raises(BadRequestError): + next(events) + + +def test_stream_events_error_without_status_is_business_error(): + # `status:` is optional on the wire. Without it nothing reported a + # server 500, so the frame is a business failure carried on the HTTP + # 200 stream and the body's own code picks the class. + sse = ( + "id: 1\nevent:error\n" + 'data: {"code": "Throttling", "message": "slow down"}\n\n' + ) + + def handler(request): + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + events = client.fine_tunes.stream_events("ft-1") + with pytest.raises(RateLimitError, match="slow down") as exc: + next(events) + assert exc.value.status_code == 200 + + +def test_deployments_crud_and_scale(): + seen = [] + + def handler(request): + seen.append((request.method, request.url.path)) + body = json.loads(request.content) if request.content else {} + if request.method == "POST" and request.url.path.endswith( + "deployments", + ): + assert body == { + "model_name": "ft-model", + "capacity": 2, + "suffix": "alpha", + } + if request.method == "PUT": + assert body == {"deployed_model": "dep-1", "capacity": 4} + output = { + "deployed_model": "dep-1", + "model_name": "ft-model", + "status": "RUNNING", + "capacity": 2, + } + if request.method == "GET" and not request.url.path.endswith( + "deployments", + ): + return httpx.Response( + 200, + json={"request_id": "req-1", "output": output}, + ) + if request.method == "GET": + return httpx.Response( + 200, + json={ + "request_id": "req-2", + "output": { + "page_no": 1, + "page_size": 10, + "total": 1, + "deployments": [output], + }, + }, + ) + if request.method == "DELETE": + return httpx.Response( + 200, + json={ + "request_id": "req-3", + "output": {"status": "deleted"}, + }, + ) + return httpx.Response( + 200, + json={"request_id": "req-1", "output": output}, + ) + + client = _make_client(handler) + created = client.deployments.create( + model="ft-model", + capacity=2, + suffix="alpha", + ) + assert created.deployed_model == "dep-1" + fetched = client.deployments.get("dep-1") + assert fetched.model == "ft-model" + listed = client.deployments.list() + assert listed.total_count == 1 + scaled = client.deployments.scale("dep-1", capacity=4) + assert scaled.deployed_model == "dep-1" + deleted = client.deployments.delete("dep-1") + assert deleted.status == "deleted" + assert seen == [ + ("POST", "/api/v1/deployments"), + ("GET", "/api/v1/deployments/dep-1"), + ("GET", "/api/v1/deployments"), + ("PUT", "/api/v1/deployments/dep-1/scale"), + ("DELETE", "/api/v1/deployments/dep-1"), + ] + + +def test_deployments_create_sends_plan_and_mu_fields(): + # The server rejects creates without ``plan`` (400 "plan is blank"), + # and MU deployments additionally require ``deploy_spec`` and + # ``billing_method``; all of them must reach the wire verbatim. + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": {"deployed_model": "dep-1", "status": "PENDING"}, + }, + ) + + client = _make_client(handler) + created = client.deployments.create( + model="ft-model", + capacity=2, + plan="plan-xx", + template_id="tpl-1", + deploy_spec={"gpu_type": "a10", "replicas": 1}, + billing_method="postpaid", + ) + assert created.deployed_model == "dep-1" + assert captured["body"] == { + "model_name": "ft-model", + "capacity": 2, + "plan": "plan-xx", + "template_id": "tpl-1", + "deploy_spec": {"gpu_type": "a10", "replicas": 1}, + "billing_method": "postpaid", + } + + +def test_deployments_stream_events_yields_frames(): + sse = ( + 'id: 1\ndata: {"status": "RUNNING"}\n\n' + 'id: 2\ndata: {"status": "SUCCEEDED"}\n\n' + ) + + def handler(request): + assert request.url.path == "/api/v1/deployments/dep-1/stream" + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + events = list(client.deployments.stream_events("dep-1")) + assert [event.output for event in events] == [ + {"status": "RUNNING"}, + {"status": "SUCCEEDED"}, + ] + assert all(not event.is_error for event in events) + + +def test_deployments_stream_events_error_raises(): + sse = ( + "id: 1\nevent:error\nstatus: 400\n" + 'data: {"code": "InvalidParameter", "message": "boom"}\n\n' + ) + + def handler(request): + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler) + with pytest.raises(BadRequestError, match="boom"): + next(client.deployments.stream_events("dep-1")) + + +def test_deployments_stream_events_error_event_when_not_raising(): + sse = ( + 'id: 1\ndata: {"status": "RUNNING"}\n\n' + "id: 2\nevent:error\nstatus: 400\n" + 'data: {"code": "InvalidParameter", "message": "boom"}\n\n' + ) + + def handler(request): + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_client(handler, raise_on_error=False) + events = list(client.deployments.stream_events("dep-1")) + assert len(events) == 2 + assert events[0].output == {"status": "RUNNING"} + assert events[1].is_error is True + assert events[1].output == { + "code": "InvalidParameter", + "message": "boom", + } + + +def test_fine_tune_parses_identity_fields(): + def handler(request): + payload = _job_payload("SUCCEEDED") + payload["output"].update( + { + "user_identity": "uid-1", + "modifier": "bob", + "creator": "alice", + "group": "grp-1", + }, + ) + return httpx.Response(200, json=payload) + + client = _make_client(handler) + job = client.fine_tunes.get("ft-1") + assert job.user_identity == "uid-1" + assert job.modifier == "bob" + assert job.creator == "alice" + assert job.group == "grp-1" + + +def test_deployment_parses_modifier_and_creator(): + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": { + "deployed_model": "dep-1", + "status": "RUNNING", + "modifier": "bob", + "creator": "alice", + }, + }, + ) + + client = _make_client(handler) + deployment = client.deployments.get("dep-1") + assert deployment.modifier == "bob" + assert deployment.creator == "alice" + + +@pytest.mark.asyncio +async def test_async_fine_tunes_create_and_wait(): + statuses = iter(["RUNNING", "SUCCEEDED"]) + delays = [] + + def handler(request): + if request.method == "POST": + return httpx.Response(200, json=_job_payload("PENDING")) + return httpx.Response(200, json=_job_payload(next(statuses))) + + client = _make_async_client(handler) + + async def fake_sleep(seconds): + delays.append(seconds) + + job = await client.fine_tunes.create( + model="qwen-turbo", + training_file_ids=["file-1"], + ) + assert job.status == "PENDING" + done = await client.fine_tunes.wait(job, _sleep=fake_sleep) + assert done.status == "SUCCEEDED" + assert delays == [1.0, 2.0] + + +@pytest.mark.asyncio +async def test_async_deployments_scale(): + def handler(request): + assert request.method == "PUT" + assert request.url.path == "/api/v1/deployments/dep-1/scale" + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": { + "deployed_model": "dep-1", + "status": "SCALING", + "capacity": 4, + }, + }, + ) + + client = _make_async_client(handler) + scaled = await client.deployments.scale("dep-1", capacity=4) + assert scaled.capacity == 4 + + +@pytest.mark.asyncio +async def test_async_deployments_create_sends_plan(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": {"deployed_model": "dep-1", "status": "PENDING"}, + }, + ) + + client = _make_async_client(handler) + created = await client.deployments.create( + model="ft-model", + capacity=1, + plan="plan-xx", + ) + assert created.deployed_model == "dep-1" + assert captured["body"] == { + "model_name": "ft-model", + "capacity": 1, + "plan": "plan-xx", + } + + +@pytest.mark.asyncio +async def test_async_deployments_stream_events(): + sse = 'id: 1\ndata: {"status": "RUNNING"}\n\n' + + def handler(request): + assert request.url.path == "/api/v1/deployments/dep-1/stream" + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + events = [ + event async for event in client.deployments.stream_events("dep-1") + ] + assert len(events) == 1 + assert events[0].output == {"status": "RUNNING"} + assert events[0].is_error is False + + +@pytest.mark.asyncio +async def test_async_stream_events(): + sse = 'id: 1\ndata: {"status": "RUNNING"}\n\n' + + def handler(request): + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + events = [event async for event in client.fine_tunes.stream_events("ft-1")] + assert len(events) == 1 + assert events[0].output == {"status": "RUNNING"} + + +@pytest.mark.asyncio +async def test_async_stream_events_error_without_status(): + # The async stream used to carry its own copy of the fabricated 500 + # default, so it gets the same coverage as the sync path. + sse = ( + "id: 1\nevent:error\n" + 'data: {"code": "Throttling", "message": "slow down"}\n\n' + ) + + def handler(request): + return httpx.Response( + 200, + content=sse, + headers={"content-type": "text/event-stream"}, + ) + + client = _make_async_client(handler) + with pytest.raises(RateLimitError, match="slow down") as exc: + async for _ in client.fine_tunes.stream_events("ft-1"): + pass + assert exc.value.status_code == 200 + + +def test_business_code_reaches_fine_tune_and_deployment(): + # Both types flatten the envelope, so a failure reported with HTTP 200 + # surfaces as is_error instead of leaving the caller to compare status + # against the terminal values it has to know about. + seen = [] + + def handler(request): + seen.append(request.url.path) + return httpx.Response( + 200, + json={ + "request_id": "req-e", + "code": "Job.Failed", + "message": "training data invalid", + "output": {"status": "FAILED"}, + }, + ) + + client = _make_client(handler, raise_on_error=False) + job = client.fine_tunes.get("ft-1") + assert job.is_error + assert job.code == "Job.Failed" + assert job.request_id == "req-e" + + dep = client.deployments.get("dep-1") + assert dep.is_error + assert dep.code == "Job.Failed" + assert seen == [ + "/api/v1/fine-tunes/ft-1", + "/api/v1/deployments/dep-1", + ] + + +def test_logs_joins_array_of_lines(): + # The server returns training logs as an array of strings; the SDK + # normalizes them into a newline-joined ``content`` string. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-logs", + "output": {"logs": ["step 1", "step 2", "done"]}, + }, + ) + + client = _make_client(handler) + logs = client.fine_tunes.logs("ft-1") + assert logs.job_id == "ft-1" + assert logs.content == "step 1\nstep 2\ndone" + + +def test_logs_accepts_plain_string(): + # The string form of the response must keep working unchanged. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-logs", + "output": {"logs": "step 1\nstep 2"}, + }, + ) + + client = _make_client(handler) + logs = client.fine_tunes.logs("ft-1") + assert logs.content == "step 1\nstep 2" + + +@pytest.mark.asyncio +async def test_async_logs_joins_array_of_lines(): + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-logs", + "output": {"logs": ["step 1", "step 2"]}, + }, + ) + + client = _make_async_client(handler) + logs = await client.fine_tunes.logs("ft-1") + assert logs.content == "step 1\nstep 2" + await client.aclose() + + +def test_business_code_reaches_list_and_logs_responses(): + # The list/logs parsers re-pick their fields from the flattened body, + # so the envelope needs threading through just like the single-item + # responses above. + envelope = { + "request_id": "req-x", + "code": "Throttling", + "message": "slow down", + } + + def handler(request): + if request.url.path.endswith("/logs"): + return httpx.Response( + 200, + json={**envelope, "output": {"logs": ""}}, + ) + if request.url.path == "/api/v1/fine-tunes": + return httpx.Response( + 200, + json={**envelope, "output": {"jobs": []}}, + ) + return httpx.Response( + 200, + json={**envelope, "output": {"deployments": []}}, + ) + + client = _make_client(handler, raise_on_error=False) + jobs = client.fine_tunes.list() + assert jobs.is_error + assert jobs.code == "Throttling" + assert jobs.request_id == "req-x" + + logs = client.fine_tunes.logs("ft-1") + assert logs.is_error + assert logs.code == "Throttling" + + deployments = client.deployments.list() + assert deployments.is_error + assert deployments.code == "Throttling" + + +@pytest.mark.asyncio +async def test_async_business_code_reaches_list_and_logs_responses(): + envelope = { + "request_id": "req-x", + "code": "Throttling", + "message": "slow down", + } + + def handler(request): + if request.url.path.endswith("/logs"): + return httpx.Response( + 200, + json={**envelope, "output": {"logs": ""}}, + ) + if request.url.path == "/api/v1/fine-tunes": + return httpx.Response( + 200, + json={**envelope, "output": {"jobs": []}}, + ) + return httpx.Response( + 200, + json={**envelope, "output": {"deployments": []}}, + ) + + client = _make_async_client(handler, raise_on_error=False) + jobs = await client.fine_tunes.list() + assert jobs.is_error and jobs.code == "Throttling" + logs = await client.fine_tunes.logs("ft-1") + assert logs.is_error and logs.code == "Throttling" + deployments = await client.deployments.list() + assert deployments.is_error and deployments.code == "Throttling" + await client.aclose() diff --git a/tests/v2/test_http_pool.py b/tests/v2/test_http_pool.py new file mode 100644 index 00000000..f666031b --- /dev/null +++ b/tests/v2/test_http_pool.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""HTTP keep-alive pool: settings plumbing + real TCP-reuse proof.""" + +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from dashscope.v2._config import resolve_config, settings +from dashscope.v2.client import Client + + +@pytest.fixture +def clean(monkeypatch): + for name in ( + "DASHSCOPE_HTTP_MAX_CONNECTIONS", + "DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS", + "DASHSCOPE_HTTP_KEEPALIVE_EXPIRY", + ): + monkeypatch.delenv(name, raising=False) + settings.clear() + yield monkeypatch + settings.clear() + + +def test_http_pool_defaults(clean): + config = resolve_config() + assert config.http_max_connections == 100 + assert config.http_max_keepalive_connections == 20 + assert config.http_keepalive_expiry == 5.0 + + +def test_http_pool_settings_override(clean): + settings.http_max_connections = 50 + settings.http_max_keepalive_connections = 7 + settings.http_keepalive_expiry = 1.5 + config = resolve_config() + assert config.http_max_connections == 50 + assert config.http_max_keepalive_connections == 7 + assert config.http_keepalive_expiry == 1.5 + + +def test_http_pool_env_override(clean, monkeypatch): + monkeypatch.setenv("DASHSCOPE_HTTP_MAX_CONNECTIONS", "33") + monkeypatch.setenv("DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS", "3") + monkeypatch.setenv("DASHSCOPE_HTTP_KEEPALIVE_EXPIRY", "0.5") + config = resolve_config() + assert config.http_max_connections == 33 + assert config.http_max_keepalive_connections == 3 + assert config.http_keepalive_expiry == 0.5 + + +def test_http_pool_config_file(clean, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text( + "http_max_connections = 44\n" + "http_max_keepalive_connections = 4\n" + "http_keepalive_expiry = 2.5\n", + encoding="utf-8", + ) + config = resolve_config(config_path=config_file) + assert config.http_max_connections == 44 + assert config.http_max_keepalive_connections == 4 + assert config.http_keepalive_expiry == 2.5 + + +def test_http_pool_validation(clean, monkeypatch): + with pytest.raises(ValueError, match="http_max_connections"): + settings.http_max_connections = 0 + with pytest.raises(ValueError, match="http_max_keepalive_connections"): + settings.http_max_keepalive_connections = -1 + with pytest.raises(ValueError, match="http_keepalive_expiry"): + settings.http_keepalive_expiry = 0 + monkeypatch.setenv("DASHSCOPE_HTTP_MAX_CONNECTIONS", "0") + with pytest.raises(ValueError, match="http_max_connections"): + resolve_config() + + +class _Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + self.server.client_ports.append(self.client_address[1]) + body = b"{}" + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +def _serve(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + server.client_ports = [] + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server + + +def test_http_keepalive_reuses_tcp_connection(clean): + """Sequential requests on one client ride one TCP connection.""" + server = _serve() + try: + base = f"http://127.0.0.1:{server.server_port}/api/v1" + with Client(api_key="sk-test", base_url=base) as client: + for _ in range(3): + response = client.transport.request("GET", "/services/x") + assert response.status_code == 200 + assert len(server.client_ports) == 3 + assert len(set(server.client_ports)) == 1 + finally: + server.shutdown() + server.server_close() + + +def test_http_keepalive_disabled_opens_new_connections(clean): + """keepalive=0 flows settings -> limits -> httpx end to end.""" + settings.http_max_keepalive_connections = 0 + server = _serve() + try: + base = f"http://127.0.0.1:{server.server_port}/api/v1" + with Client(api_key="sk-test", base_url=base) as client: + for _ in range(3): + response = client.transport.request("GET", "/services/x") + assert response.status_code == 200 + assert len(set(server.client_ports)) == 3 + finally: + server.shutdown() + server.server_close() diff --git a/tests/v2/test_images_videos.py b/tests/v2/test_images_videos.py new file mode 100644 index 00000000..c8fdb497 --- /dev/null +++ b/tests/v2/test_images_videos.py @@ -0,0 +1,697 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.images and client.videos (M3 task 3.3).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client + +BASE_URL = "https://api.test.local/api/v1" +IMAGE_PATH = "/api/v1/services/aigc/text2image/image-synthesis" +VIDEO_PATH = "/api/v1/services/aigc/video-generation/video-synthesis" +MULTIMODAL_IMAGE_PATH = ( + "/api/v1/services/aigc/multimodal-generation/generation" +) +TASK_IMAGE_PATH = "/api/v1/services/aigc/image-generation/generation" + + +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 _task_body(status, output_extra=None): + output = {"task_id": "task-1", "task_status": status} + output.update(output_extra or {}) + return {"request_id": "req-1", "output": output} + + +def _multimodal_image_body(): + """Sync multimodal-generation answer: choices instead of results.""" + return { + "request_id": "req-1", + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [ + {"image": "https://cdn.example.com/1.png"}, + ], + }, + }, + ], + }, + "usage": {"input_tokens": 10, "image_count": 1}, + } + + +def test_images_generate_submit_and_wait(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["path"] = request.url.path + captured["header"] = request.headers.get("x-dashscope-async") + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + {"results": [{"url": "https://cdn.example.com/1.png"}]}, + ), + ) + + client = _make_client(handler) + job = client.images.generate( + model="wanx2.1-t2i", + prompt="a cat", + negative_prompt="blurry", + n=1, + size="1024*1024", + ) + assert captured["path"] == IMAGE_PATH + assert captured["header"] == "enable" + assert captured["body"] == { + "model": "wanx2.1-t2i", + "input": {"prompt": "a cat", "negative_prompt": "blurry"}, + "parameters": {"n": 1, "size": "1024*1024"}, + } + assert job.succeeded is True + assert isinstance(job.output, dict) + assert job.output["results"][0]["url"] == "https://cdn.example.com/1.png" + + +def test_images_generate_wan26_uses_multimodal_endpoint(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["header"] = request.headers.get("x-dashscope-async") + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_multimodal_image_body()) + + client = _make_client(handler) + job = client.images.generate( + model="wan2.6-t2i", + prompt="a cat", + negative_prompt="blurry", + n=1, + size="1024x1024", + ) + assert captured["path"] == MULTIMODAL_IMAGE_PATH + # One-shot sync call: no async header, no polling. + assert captured["header"] is None + assert captured["body"] == { + "model": "wan2.6-t2i", + "input": { + "messages": [ + {"role": "user", "content": [{"text": "a cat"}]}, + ], + }, + "parameters": { + "negative_prompt": "blurry", + "n": 1, + "size": "1024*1024", + }, + } + assert job.succeeded is True + assert job.image_urls == ["https://cdn.example.com/1.png"] + assert job.usage.image_count == 1 + + +def test_images_generate_wan26_image_reference_urls(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_multimodal_image_body()) + + client = _make_client(handler) + client.images.generate( + model="wan2.6-image", + prompt="combine the references", + image_urls=[ + "https://example.com/a.png", + "https://example.com/b.png", + ], + ) + content = captured["body"]["input"]["messages"][0]["content"] + assert content == [ + {"text": "combine the references"}, + {"image": "https://example.com/a.png"}, + {"image": "https://example.com/b.png"}, + ] + + +def test_images_jobs_wan26_uses_image_generation_task_endpoint(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["path"] = request.url.path + captured["header"] = request.headers.get("x-dashscope-async") + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + { + "choices": [ + { + "message": { + "content": [ + {"image": "https://cdn.example.com/1.png"}, + ], + }, + }, + ], + }, + ), + ) + + client = _make_client(handler) + pending = client.images.jobs.create(model="wan2.6-t2i", prompt="a cat") + assert captured["path"] == TASK_IMAGE_PATH + assert captured["header"] == "enable" + assert captured["body"]["input"] == { + "messages": [{"role": "user", "content": [{"text": "a cat"}]}], + } + assert pending.status == "PENDING" + + final = client.images.jobs.wait("task-1", poll_interval=0.01) + assert final.succeeded is True + # choices-style task output is normalized into results/image_urls. + assert final.image_urls == ["https://cdn.example.com/1.png"] + + +def test_images_size_accepts_x_separator(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_client(handler) + client.images.jobs.create( + model="wanx2.1-t2i", + prompt="a cat", + size="1024x1024", + ) + assert captured["body"]["parameters"] == {"size": "1024*1024"} + + +def test_images_named_parameters_merge_and_win(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_client(handler) + client.images.jobs.create( + model="wanx2.1-t2i", + prompt="a cat", + seed=42, + style="", + prompt_extend=True, + watermark=False, + parameters={"seed": 1, "steps": 30}, + ) + # Named kwargs win over the ``parameters`` escape hatch. + assert captured["body"]["parameters"] == { + "seed": 42, + "style": "", + "prompt_extend": True, + "watermark": False, + "steps": 30, + } + + +def test_images_function_goes_to_input(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_client(handler) + client.images.jobs.create( + model="wanx2.1-t2i", + prompt="a cat", + function="stylization_all", + extra_input={"function": "stylization_local"}, + ) + # 1.x spells it inputs["function"]; the named kwarg wins over + # the ``extra_input`` escape hatch. + assert captured["body"]["input"]["function"] == "stylization_all" + + +def test_images_function_multimodal_goes_to_input(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_multimodal_image_body()) + + client = _make_client(handler) + client.images.generate( + model="wan2.6-t2i", + prompt="a cat", + function="stylization_all", + submit_timeout=1, + ) + assert captured["body"]["input"]["function"] == "stylization_all" + + +def test_images_jobs_cancel_and_list(): + captured = [] + + def handler(request): + captured.append((request.method, request.url.path)) + if request.method == "POST": + return httpx.Response(200, json=_task_body("CANCELED")) + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "total": 1, + "page_no": 1, + "page_size": 10, + "tasks": [{"task_id": "task-1", "task_status": "CANCELED"}], + }, + ) + + client = _make_client(handler) + canceled = client.images.jobs.cancel("task-1") + assert canceled.status == "CANCELED" + result = client.images.jobs.list() + assert result.total == 1 + assert result.tasks[0].task_id == "task-1" + assert captured == [ + ("POST", "/api/v1/tasks/task-1/cancel"), + ("GET", "/api/v1/tasks"), + ] + + +def test_images_jobs_create_fetch_wait_roundtrip(): + statuses = iter(["PENDING", "RUNNING", "RUNNING", "SUCCEEDED"]) + + def handler(request): + if request.method == "POST": + return httpx.Response(200, json=_task_body(next(statuses))) + return httpx.Response( + 200, + json=_task_body( + next(statuses), + {"results": [{"url": "https://cdn.example.com/1.png"}]}, + ), + ) + + client = _make_client(handler) + pending = client.images.jobs.create(model="wanx2.1-t2i", prompt="a cat") + assert pending.status == "PENDING" + assert pending.task_id == "task-1" + + snapshot = client.images.jobs.fetch("task-1") + assert snapshot.status == "RUNNING" + + delays = [] + final = client.images.jobs.wait( + "task-1", + poll_interval=1.0, + _sleep=delays.append, + ) + assert final.succeeded is True + assert delays == [1.0] + + +def test_videos_generate_with_prompt(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + {"video_url": "https://cdn.example.com/v.mp4"}, + ), + ) + + client = _make_client(handler) + job = client.videos.generate( + model="wanx2.1-t2v", + prompt="a running cat", + parameters={"duration": 5}, + ) + assert captured["path"] == VIDEO_PATH + assert captured["body"] == { + "model": "wanx2.1-t2v", + "input": {"prompt": "a running cat"}, + "parameters": {"duration": 5}, + } + assert job.succeeded is True + assert isinstance(job.output, dict) + assert job.output["video_url"] == "https://cdn.example.com/v.mp4" + + +def test_videos_named_parameters_merge_and_win(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + client = _make_client(handler) + client.videos.generate( + model="wanx2.1-t2v", + prompt="a running cat", + seed=7, + size="1280*720", + duration=10, + resolution="1080P", + ratio="16:9", + prompt_extend=True, + watermark=True, + parameters={"duration": 5}, + ) + # Named kwargs win over the ``parameters`` escape hatch. + assert captured["body"]["parameters"] == { + "seed": 7, + "size": "1280*720", + "duration": 10, + "resolution": "1080P", + "ratio": "16:9", + "prompt_extend": True, + "watermark": True, + } + + +def test_videos_function_goes_to_input(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_client(handler) + client.videos.jobs.create( + model="wanx2.1-t2v", + prompt="a running cat", + function="video_retalk", + ) + # 1.x spells it inputs["function"] (video_synthesis.py:219). + assert captured["body"]["input"]["function"] == "video_retalk" + + +def test_videos_require_prompt_or_image_url(): + client = _make_client( + lambda request: httpx.Response(200, json=_task_body("PENDING")), + ) + with pytest.raises(ValueError, match="prompt or image_url"): + client.videos.jobs.create(model="wanx2.1-t2v") + + +def test_videos_legacy_extra_input_only_is_accepted(): + # ``extra_input`` is the escape hatch for fields the typed params do + # not cover; a legacy model must accept it even without prompt/image_url. + captured = {} + + def handler(request): + if request.method == "POST": + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + client = _make_client(handler) + client.videos.generate( + model="wanx2.1-t2v", + extra_input={"img_url": "https://example.com/cat.png"}, + ) + assert captured["body"]["input"] == { + "img_url": "https://example.com/cat.png", + } + + +def test_videos_image_to_video_input(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + client = _make_client(handler) + client.videos.generate( + model="wanx2.1-i2v", + image_url="https://example.com/cat.png", + ) + # The service spells this key "img_url"; the SDK does not. + assert captured["body"]["input"] == { + "img_url": "https://example.com/cat.png", + } + + +def test_videos_generate_wan27_i2v_uses_media_protocol(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["path"] = request.url.path + captured["header"] = request.headers.get("x-dashscope-async") + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + {"video_url": "https://cdn.example.com/v.mp4"}, + ), + ) + + media = [ + {"type": "first_frame", "url": "https://example.com/first.png"}, + {"type": "driving_audio", "url": "https://example.com/rap.mp3"}, + ] + client = _make_client(handler) + job = client.videos.generate( + # Dated variants route the same way (prefix match). + model="wan2.7-i2v-2026-04-25", + prompt="a rapper comes to life", + media=media, + parameters={"resolution": "720P", "duration": 10}, + ) + # Same service and async envelope; only the input shape is new. + assert captured["path"] == VIDEO_PATH + assert captured["header"] == "enable" + assert captured["body"] == { + "model": "wan2.7-i2v-2026-04-25", + "input": {"prompt": "a rapper comes to life", "media": media}, + "parameters": {"resolution": "720P", "duration": 10}, + } + assert job.succeeded is True + assert job.video_url == "https://cdn.example.com/v.mp4" + + +def test_videos_wan27_i2v_image_url_becomes_first_frame(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + client = _make_client(handler) + client.videos.generate( + model="wan2.7-i2v", + image_url="https://example.com/cat.png", + media=[{"type": "driving_audio", "url": "https://example.com/a.mp3"}], + ) + # image_url is shorthand; it merges into the media list. + assert captured["body"]["input"] == { + "media": [ + {"type": "first_frame", "url": "https://example.com/cat.png"}, + {"type": "driving_audio", "url": "https://example.com/a.mp3"}, + ], + } + + +def test_videos_wan27_i2v_requires_media(): + client = _make_client( + lambda request: httpx.Response(200, json=_task_body("PENDING")), + ) + with pytest.raises(ValueError, match="media or image_url"): + client.videos.jobs.create(model="wan2.7-i2v", prompt="a cat") + + +def test_videos_wan27_i2v_image_url_conflicts_with_first_frame(): + client = _make_client( + lambda request: httpx.Response(200, json=_task_body("PENDING")), + ) + with pytest.raises(ValueError, match="first_frame"): + client.videos.jobs.create( + model="wan2.7-i2v", + image_url="https://example.com/a.png", + media=[ + {"type": "first_frame", "url": "https://example.com/b.png"}, + ], + ) + + +@pytest.mark.asyncio +async def test_async_images_generate(): + def handler(request): + if request.method == "POST": + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + {"results": [{"url": "https://cdn.example.com/1.png"}]}, + ), + ) + + client = _make_async_client(handler) + job = await client.images.generate( + model="wanx2.1-t2i", + prompt="a cat", + ) + assert job.succeeded is True + + +@pytest.mark.asyncio +async def test_async_images_generate_wan26(): + 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_async_client(handler) + job = await client.images.generate( + model="wan2.6-t2i", + prompt="a cat", + ) + assert captured["path"] == MULTIMODAL_IMAGE_PATH + assert captured["header"] is None + assert job.succeeded is True + assert job.image_urls == ["https://cdn.example.com/1.png"] + + +@pytest.mark.asyncio +async def test_async_videos_generate(): + def handler(request): + if request.method == "POST": + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + {"video_url": "https://cdn.example.com/v.mp4"}, + ), + ) + + client = _make_async_client(handler) + job = await client.videos.generate( + model="wanx2.1-t2v", + prompt="a running cat", + ) + assert job.succeeded is True + assert isinstance(job.output, dict) + assert job.output["video_url"] == "https://cdn.example.com/v.mp4" + + +@pytest.mark.asyncio +async def test_async_videos_generate_wan27_i2v(): + captured = {} + + def handler(request): + if request.method == "POST": + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + return httpx.Response( + 200, + json=_task_body( + "SUCCEEDED", + {"video_url": "https://cdn.example.com/v.mp4"}, + ), + ) + + client = _make_async_client(handler) + job = await client.videos.generate( + model="wan2.7-i2v", + prompt="a running cat", + media=[{"type": "first_frame", "url": "https://example.com/cat.png"}], + ) + assert captured["body"]["input"] == { + "prompt": "a running cat", + "media": [ + {"type": "first_frame", "url": "https://example.com/cat.png"}, + ], + } + assert job.succeeded is True + assert job.video_url == "https://cdn.example.com/v.mp4" + + +def test_image_jobs_create_uploads_with_the_per_call_api_key(monkeypatch): + seen = {} + + def fake_upload(model, content, api_key, cert, **kwargs): + seen["api_key"] = api_key + seen["base_address"] = kwargs.get("base_address") + return True, "oss://bucket/ref.png", {} + + monkeypatch.setattr( + "dashscope.v2.resources._oss.check_and_upload_local", + fake_upload, + ) + captured = {} + + def handler(request): + captured["auth"] = request.headers.get("Authorization") + captured["oss"] = request.headers.get("X-DashScope-OssResourceResolve") + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_task_body("PENDING")) + + client = _make_client(handler) + client.images.jobs.create( + model="wan2.6-image", + prompt="a cat", + image_urls=["/tmp/ref.png"], + api_key="sk-override", + ) + assert seen["api_key"] == "sk-override" + assert seen["base_address"] == client.config.base_url + assert captured["auth"] == "Bearer sk-override" + assert captured["oss"] == "enable" + content = captured["body"]["input"]["messages"][0]["content"] + assert {"image": "oss://bucket/ref.png"} in content diff --git a/tests/v2/test_jobs.py b/tests/v2/test_jobs.py new file mode 100644 index 00000000..192abe42 --- /dev/null +++ b/tests/v2/test_jobs.py @@ -0,0 +1,505 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the unified Job infrastructure (M3 task 3.2).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import DashScopeError +from dashscope.v2.resources._jobs import AsyncJobs, Job, JobList, Jobs + +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 _job_body(status="PENDING", task_id="task-1", **extra): + output = {"task_id": task_id, "task_status": status} + output.update(extra) + return {"request_id": "req-1", "output": output} + + +def test_job_from_payload(): + job = Job.from_payload( + { + "request_id": "req-1", + "output": { + "task_id": "task-1", + "task_status": "SUCCEEDED", + "submit_time": "2026-08-25 10:00:00", + "results": [{"url": "https://cdn.example.com/a.png"}], + }, + "usage": {"image_count": 1}, + }, + ) + assert job.task_id == "task-1" + assert job.succeeded is True + assert job.finished is True + assert job.request_id == "req-1" + assert isinstance(job.output, dict) + output = job.output + # pylint: disable-next=unsubscriptable-object + assert output["results"][0]["url"] == "https://cdn.example.com/a.png" + assert job.usage.image_count == 1 + + +def test_create_submits_async_task(): + captured = {} + + def handler(request): + captured["method"] = request.method + captured["path"] = request.url.path + captured["headers"] = dict(request.headers) + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_job_body("PENDING")) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + job = jobs.create( + model="wanx2.1-t2i", + input={"prompt": "a cat"}, + parameters={"n": 1, "size": "1024*1024"}, + ) + assert captured["method"] == "POST" + assert captured["path"] == "/api/v1/services/aigc" + assert captured["headers"]["x-dashscope-async"] == "enable" + assert captured["body"] == { + "model": "wanx2.1-t2i", + "input": {"prompt": "a cat"}, + "parameters": {"n": 1, "size": "1024*1024"}, + } + assert job.task_id == "task-1" + assert job.status == "PENDING" + assert job.finished is False + + +def test_wait_polls_with_exponential_backoff(): + statuses = iter(["RUNNING", "RUNNING", "SUCCEEDED"]) + calls = [] + + def handler(request): + assert request.method == "GET" + assert request.url.path == "/api/v1/tasks/task-1" + calls.append(request.url.path) + return httpx.Response( + 200, + json=_job_body( + next(statuses), + results=[{"url": "https://cdn.example.com/a.png"}], + ), + ) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + delays = [] + final = jobs.wait( + "task-1", + poll_interval=1.0, + max_interval=8.0, + _sleep=delays.append, + ) + assert final.succeeded is True + assert len(calls) == 3 + assert delays == [1.0, 2.0] + + +def test_wait_accepts_job_and_caps_interval(): + statuses = iter(["RUNNING", "RUNNING", "SUCCEEDED"]) + + def handler(request): + return httpx.Response(200, json=_job_body(next(statuses))) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + delays = [] + pending = Job(task_id="task-9", status="PENDING") + final = jobs.wait( + pending, + poll_interval=1.0, + max_interval=2.0, + _sleep=delays.append, + ) + assert final.succeeded is True + assert delays == [1.0, 2.0, 2.0] + + +def test_wait_times_out(): + def handler(request): + return httpx.Response(200, json=_job_body("RUNNING")) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + with pytest.raises(DashScopeError, match="timed out"): + jobs.wait("task-1", timeout=0.001, _sleep=lambda seconds: None) + + +def test_fetch_failed_raises_mapped_error(): + def handler(request): + return httpx.Response( + 200, + json=_job_body( + "FAILED", + code="InvalidParameter", + message="bad prompt", + ), + headers={"x-request-id": "req-h", "x-gw-region": "cn-hz"}, + ) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + with pytest.raises(DashScopeError, match="bad prompt") as exc_info: + jobs.fetch("task-1") + assert exc_info.value.code == "InvalidParameter" + # A task failure reported with HTTP 200 still carries the gateway + # headers of the response that reported it, matching what + # ``errors.response_error`` puts on a transport-level exception. + assert exc_info.value.headers["x-request-id"] == "req-h" + assert exc_info.value.headers["x-gw-region"] == "cn-hz" + + +def test_fetch_failed_returns_job_when_not_raising(): + def handler(request): + return httpx.Response( + 200, + json=_job_body("FAILED", code="InvalidParameter"), + ) + + client = _make_client(handler, raise_on_error=False) + jobs = Jobs(client, "aigc") + job = jobs.fetch("task-1") + assert job.status == "FAILED" + + +def test_wait_raises_on_canceled(): + def handler(request): + return httpx.Response(200, json=_job_body("CANCELED")) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + with pytest.raises(DashScopeError, match="canceled"): + jobs.wait("task-1", _sleep=lambda seconds: None) + + +def _list_body(**extra): + body = { + "request_id": "req-1", + "total": 11, + "page_no": 2, + "page_size": 5, + "tasks": [ + { + "task_id": "task-9", + "task_status": "SUCCEEDED", + "submit_time": "2026-08-25 10:00:00", + }, + ], + } + body.update(extra) + return body + + +def test_cancel_posts_to_task_cancel(): + captured = {} + + def handler(request): + captured["method"] = request.method + captured["path"] = request.url.path + return httpx.Response(200, json=_job_body("CANCELED")) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + job = jobs.cancel("task-1") + assert captured["method"] == "POST" + assert captured["path"] == "/api/v1/tasks/task-1/cancel" + assert job.task_id == "task-1" + assert job.status == "CANCELED" + + +def test_list_gets_tasks_with_pagination(): + captured = {} + + def handler(request): + captured["method"] = request.method + captured["path"] = request.url.path + captured["query"] = dict(request.url.params) + return httpx.Response(200, json=_list_body()) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + result = jobs.list( + model_name="wanx2.1-t2i", + status="SUCCEEDED", + page_no=2, + page_size=5, + ) + assert captured["method"] == "GET" + assert captured["path"] == "/api/v1/tasks" + assert captured["query"] == { + "page_no": "2", + "page_size": "5", + "model_name": "wanx2.1-t2i", + "status": "SUCCEEDED", + } + assert isinstance(result, JobList) + assert result.total == 11 + assert result.page_no == 2 + assert result.page_size == 5 + assert result.request_id == "req-1" + assert result.tasks[0].task_id == "task-9" + assert result.tasks[0].succeeded is True + assert result.tasks[0].submitted_at == "2026-08-25 10:00:00" + + +@pytest.mark.asyncio +async def test_async_cancel_and_list(): + captured = [] + + def handler(request): + captured.append((request.method, request.url.path)) + if request.method == "POST": + return httpx.Response(200, json=_job_body("CANCELED")) + return httpx.Response(200, json=_list_body()) + + client = _make_async_client(handler) + jobs = AsyncJobs(client, "aigc") + canceled = await jobs.cancel("task-1") + assert canceled.status == "CANCELED" + result = await jobs.list(status="FAILED") + assert isinstance(result, JobList) + assert result.tasks[0].task_id == "task-9" + assert captured == [ + ("POST", "/api/v1/tasks/task-1/cancel"), + ("GET", "/api/v1/tasks"), + ] + + +@pytest.mark.asyncio +async def test_async_create_fetch_wait(): + statuses = iter(["PENDING", "RUNNING", "SUCCEEDED"]) + captured = {} + + def handler(request): + if request.method == "POST": + captured["header"] = request.headers.get("x-dashscope-async") + captured["path"] = request.url.path + return httpx.Response(200, json=_job_body(next(statuses))) + return httpx.Response(200, json=_job_body(next(statuses))) + + client = _make_async_client(handler) + jobs = AsyncJobs(client, "videosynthesis") + pending = await jobs.create( + model="wanx2.1-t2v", + input={"prompt": "a running cat"}, + ) + assert captured["path"] == "/api/v1/services/videosynthesis" + assert captured["header"] == "enable" + assert pending.status == "PENDING" + + delays = [] + + async def fake_sleep(seconds): + delays.append(seconds) + + final = await jobs.wait(pending, _sleep=fake_sleep) + assert final.succeeded is True + assert delays == [1.0, 2.0] + + +@pytest.mark.asyncio +async def test_async_fetch_failed_carries_response_headers(): + """The async jobs plane threads headers exactly like the sync one.""" + + def handler(request): + return httpx.Response( + 200, + json=_job_body( + "FAILED", + code="InvalidParameter", + message="bad prompt", + ), + headers={"x-request-id": "req-h", "x-gw-region": "cn-hz"}, + ) + + client = _make_async_client(handler) + jobs = AsyncJobs(client, "aigc") + with pytest.raises(DashScopeError, match="bad prompt") as exc_info: + await jobs.fetch("task-1") + assert exc_info.value.headers["x-request-id"] == "req-h" + assert exc_info.value.headers["x-gw-region"] == "cn-hz" + + +def test_wait_returns_failed_job_when_not_raising(): + def handler(request): + return httpx.Response( + 200, + json=_job_body( + "FAILED", + code="InvalidParameter", + message="bad prompt", + ), + ) + + client = _make_client(handler, raise_on_error=False) + jobs = Jobs(client, "aigc") + job = jobs.wait("task-1", _sleep=lambda seconds: None) + assert job.status == "FAILED" + assert job.code == "InvalidParameter" + + +def test_wait_returns_canceled_job_when_not_raising(): + def handler(request): + return httpx.Response(200, json=_job_body("CANCELED")) + + client = _make_client(handler, raise_on_error=False) + jobs = Jobs(client, "aigc") + job = jobs.wait("task-1", _sleep=lambda seconds: None) + assert job.status == "CANCELED" + + +def test_wait_returns_error_envelope_when_not_raising(): + def handler(request): + return httpx.Response( + 401, + json={"code": "InvalidApiKey", "message": "bad key"}, + ) + + client = _make_client(handler, raise_on_error=False) + jobs = Jobs(client, "aigc") + sleeps = [] + job = jobs.wait( + "task-1", + timeout=60.0, + _sleep=sleeps.append, + ) + # The error envelope has no task status; wait must return it + # instead of polling until the timeout. + assert job.is_error is True + assert job.code == "InvalidApiKey" + assert sleeps == [] + + +@pytest.mark.asyncio +async def test_async_wait_returns_error_envelope_when_not_raising(): + def handler(request): + return httpx.Response( + 401, + json={"code": "InvalidApiKey", "message": "bad key"}, + ) + + client = _make_async_client(handler, raise_on_error=False) + jobs = AsyncJobs(client, "aigc") + sleeps = [] + + async def fake_sleep(seconds): + sleeps.append(seconds) + + job = await jobs.wait("task-1", timeout=60.0, _sleep=fake_sleep) + assert job.is_error is True + assert job.code == "InvalidApiKey" + assert sleeps == [] + + +def _fail_sleep(seconds): + """``_sleep`` seam that fails the test if wait() ever pauses. + + Raising rather than recording keeps a regression fast: without the + UNKNOWN terminal state the poll loop would spin against the 60s + deadline issuing requests, instead of stopping on the first one. + """ + raise AssertionError("wait() must not re-poll an UNKNOWN task") + + +async def _fail_sleep_async(seconds): + raise AssertionError("wait() must not re-poll an UNKNOWN task") + + +def test_unknown_status_is_terminal(): + """``UNKNOWN`` is the gateway's answer for a task id that is not there. + + 1.x lists ``TaskStatus.UNKNOWN`` alongside SUCCEEDED/FAILED/CANCELED + in ``client/base_api.py``, so ``finished`` must agree. + """ + job = Job(task_id="task-missing", status="UNKNOWN") + assert job.finished is True + assert job.succeeded is False + + +def test_wait_returns_unknown_job_without_polling(): + """A 200 + UNKNOWN must end the wait, not be read as "still running".""" + calls = [] + + def handler(request): + calls.append(request.url.path) + return httpx.Response(200, json=_job_body("UNKNOWN")) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + job = jobs.wait("task-missing", timeout=60.0, _sleep=_fail_sleep) + assert job.status == "UNKNOWN" + assert job.finished is True + # No business ``code`` arrives with it, so it is data, not an error. + assert job.is_error is False + assert calls == ["/api/v1/tasks/task-missing"] + + +@pytest.mark.asyncio +async def test_async_wait_returns_unknown_job_without_polling(): + calls = [] + + def handler(request): + calls.append(request.url.path) + return httpx.Response(200, json=_job_body("UNKNOWN")) + + client = _make_async_client(handler) + jobs = AsyncJobs(client, "aigc") + job = await jobs.wait( + "task-missing", + timeout=60.0, + _sleep=_fail_sleep_async, + ) + assert job.status == "UNKNOWN" + assert job.finished is True + assert job.is_error is False + assert calls == ["/api/v1/tasks/task-missing"] + + +def test_wait_still_polls_past_running_to_succeed(): + """Guard the fix's blast radius: non-terminal states keep polling.""" + statuses = iter(["PENDING", "RUNNING", "SUCCEEDED"]) + calls = [] + + def handler(request): + calls.append(request.url.path) + return httpx.Response(200, json=_job_body(next(statuses))) + + client = _make_client(handler) + jobs = Jobs(client, "aigc") + delays = [] + job = jobs.wait( + "task-1", + timeout=60.0, + _sleep=delays.append, + ) + assert job.succeeded is True + assert len(calls) == 3 + assert delays == [1.0, 2.0] diff --git a/tests/v2/test_logging.py b/tests/v2/test_logging.py new file mode 100644 index 00000000..38b67040 --- /dev/null +++ b/tests/v2/test_logging.py @@ -0,0 +1,181 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for dashscope 2.0 logging (M1 task 1.4).""" + +# pytest fixture injection plus intentional access to private state. +# pylint: disable=redefined-outer-name,unused-argument,protected-access + +from __future__ import annotations + +import io +import logging + +import httpx +import pytest + +from dashscope.v2 import _logging as logging_mod +from dashscope.v2._logging import ( + REDACTED, + SensitiveHeadersFilter, + get_logger, + redact_headers, + redact_text, +) +from dashscope.v2.transport import SyncTransport + + +@pytest.fixture +def reset_logging(monkeypatch): + root = logging.getLogger("dashscope.v2") + monkeypatch.setattr(logging_mod, "_configured", False) + yield + root.filters.clear() + root.handlers.clear() + root.setLevel(logging.NOTSET) + logging_mod._configured = False + + +def test_redact_headers(): + out = redact_headers( + { + "Authorization": "Bearer sk-abcdef1234567890", + "X-DashScope-WorkSpace": "ws-1", + "Cookie": "session=abc", + }, + ) + assert out["Authorization"] == REDACTED + assert out["Cookie"] == REDACTED + assert out["X-DashScope-WorkSpace"] == "ws-1" + assert redact_headers(None) is None + + +def test_redact_text(): + text = ( + "auth: Bearer sk-abcdef1234567890 key=sk-abcdef1234567890" + " keep=sk-short" + ) + out = redact_text(text) + assert "sk-abcdef1234567890" not in out + assert "Bearer " + REDACTED in out + assert "keep=sk-short" in out + + +def test_filter_redacts_record(reset_logging): + f = SensitiveHeadersFilter() + record = logging.LogRecord( + name="x", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="header authorization: Bearer sk-abcdef1234567890", + args=("Bearer sk-abcdef1234567890",), + exc_info=None, + ) + record.headers = {"Authorization": "Bearer sk-abcdef1234567890"} + assert f.filter(record) is True + assert "sk-abcdef1234567890" not in record.msg + assert "sk-abcdef1234567890" not in record.args[0] + assert record.headers["Authorization"] == REDACTED + + +def test_env_enables_debug_logging(reset_logging, monkeypatch): + monkeypatch.setenv("DASHSCOPE_LOG", "debug") + logger = get_logger("transport") + root = logging.getLogger("dashscope.v2") + assert root.level == logging.DEBUG + assert len(root.handlers) == 1 + assert isinstance( + root.handlers[0].filters[0], + SensitiveHeadersFilter, + ) + assert isinstance(logger.filters[0], SensitiveHeadersFilter) + assert logger.name == "dashscope.v2.transport" + + +def test_env_invalid_level_no_handler(reset_logging, monkeypatch): + monkeypatch.setenv("DASHSCOPE_LOG", "verbose") + logger = get_logger("transport") + root = logging.getLogger("dashscope.v2") + assert root.level == logging.NOTSET + assert root.handlers == [] + assert root.filters == [] + assert isinstance(logger.filters[0], SensitiveHeadersFilter) + + +def test_configured_once(reset_logging, monkeypatch): + monkeypatch.setenv("DASHSCOPE_LOG", "info") + get_logger("a") + get_logger("a") + get_logger("b") + root = logging.getLogger("dashscope.v2") + assert len(root.handlers) == 1 + for name in ("dashscope.v2.a", "dashscope.v2.b"): + child = logging.getLogger(name) + assert len(child.filters) == 1 + + +def test_transport_logs_retry_and_request_id( + reset_logging, + monkeypatch, + caplog, +): + monkeypatch.setattr( + "dashscope.v2.transport.http._sleep", + lambda s: None, + ) + calls = {"n": 0} + + def handler(request): + calls["n"] += 1 + if calls["n"] == 1: + return httpx.Response( + 429, + headers={"retry-after": "0", "x-request-id": "req-42"}, + ) + return httpx.Response(200, headers={"x-request-id": "req-43"}) + + mock = httpx.MockTransport(handler) + http_client = httpx.Client( + transport=mock, + base_url="https://api.test.local", + ) + t = SyncTransport( + base_url="https://api.test.local", + http_client=http_client, + ) + with caplog.at_level(logging.DEBUG, logger="dashscope.v2"): + resp = t.request("POST", "/chat") + assert resp.status_code == 200 + messages = [r.getMessage() for r in caplog.records] + assert any("retrying" in m and "req-42" in m for m in messages) + assert any("request_id=req-43" in m for m in messages) + assert not any("sk-" in m and len(m) > 20 for m in messages) + + +def test_child_logger_output_redacted_via_sdk_handler( + reset_logging, + monkeypatch, +): + monkeypatch.setenv("DASHSCOPE_LOG", "debug") + logger = get_logger("transport") + root = logging.getLogger("dashscope.v2") + stream = io.StringIO() + root.handlers[0].stream = stream + logger.info("token: Bearer sk-abcdef1234567890") + out = stream.getvalue() + assert "sk-abcdef1234567890" not in out + assert "Bearer " + REDACTED in out + + +def test_child_logger_output_redacted_via_user_handler( + reset_logging, + caplog, +): + # No DASHSCOPE_LOG: records propagate to the user's own handlers + # (e.g. logging.basicConfig) and must still be redacted. + logger = get_logger("transport") + with caplog.at_level(logging.DEBUG, logger="dashscope.v2"): + logger.info("token: Bearer sk-abcdef1234567890") + messages = [r.getMessage() for r in caplog.records] + assert any("Bearer " + REDACTED in m for m in messages) + assert not any("sk-abcdef1234567890" in m for m in messages) diff --git a/tests/v2/test_multimodal.py b/tests/v2/test_multimodal.py new file mode 100644 index 00000000..54cb41e4 --- /dev/null +++ b/tests/v2/test_multimodal.py @@ -0,0 +1,591 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.multimodal.chat (M3 task 3.1).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client + +BASE_URL = "https://api.test.local/api/v1" +MULTIMODAL_PATH = "/api/v1/services/aigc/multimodal-generation/generation" + + +def _sse(events): + out = [] + for i, data in enumerate(events, 1): + payload = data if isinstance(data, str) else json.dumps(data) + out.append(f"id: {i}\ndata: {payload}\n\n") + return "".join(out) + + +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 _vl_response(text="A cat on a mat."): + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + # Multimodal endpoints return list-form content. + "content": [{"text": text}], + }, + }, + ], + }, + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + "image_tokens": 128, + }, + "request_id": "req-vl", + }, + ) + + +def _openai_response(text="A cat on a mat."): + # OpenAI-compatible chat envelope: top-level choices with string + # content. Pairing an openai-protocol test with the native-shaped + # _vl_response would parse to an empty output_text. + return httpx.Response( + 200, + json={ + "id": "resp-1", + "model": "qwen3.8-omni-flash", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": text}, + }, + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 4, + "total_tokens": 14, + }, + }, + ) + + +def test_create_non_streaming_native(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return _vl_response() + + client = _make_client(handler) + resp = client.multimodal.chat.create( + model="qwen-vl-max", + messages=[ + { + "role": "user", + "content": [ + {"image": "https://example.com/cat.png"}, + {"text": "describe"}, + ], + }, + ], + ) + assert captured["path"] == MULTIMODAL_PATH + body = captured["body"] + assert body["model"] == "qwen-vl-max" + assert body["input"]["messages"] == [ + { + "role": "user", + "content": [ + {"image": "https://example.com/cat.png"}, + {"text": "describe"}, + ], + }, + ] + assert body["parameters"]["result_format"] == "message" + assert resp.output_text == "A cat on a mat." + assert resp.usage.total_tokens == 14 + assert resp.request_id == "req-vl" + + +def test_text_only_still_uses_the_multimodal_endpoint(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + return _vl_response() + + client = _make_client(handler) + # chat.completions.create would keep this on the text endpoint; + # this resource is the explicit escape hatch. + client.multimodal.chat.create( + model="qwen-vl-max", + messages=[{"role": "user", "content": [{"text": "hi"}]}], + ) + assert captured["path"] == MULTIMODAL_PATH + + +def test_raise_on_error_false_error_envelope(): + def handler(request): + return httpx.Response( + 400, + json={"code": "InvalidParameter", "message": "bad image"}, + ) + + client = _make_client(handler, raise_on_error=False) + resp = client.multimodal.chat.create( + model="qwen-vl-max", + messages=[{"role": "user", "content": [{"text": "hi"}]}], + ) + assert resp.is_error is True + assert resp.status_code == 400 + assert resp.code == "InvalidParameter" + assert resp.message == "bad image" + assert resp.choices == [] + + +def test_video_parts_url_and_frames(): + captured = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return _vl_response() + + client = _make_client(handler) + client.multimodal.chat.create( + model="qwen-vl-max", + messages=[ + { + "role": "user", + "content": [ + {"video": "https://example.com/clip.mp4"}, + {"text": "what happens?"}, + ], + }, + { + "role": "user", + "content": [ + { + "video": [ + "https://example.com/f1.png", + "https://example.com/f2.png", + ], + }, + ], + }, + ], + ) + messages = captured["body"]["input"]["messages"] + assert messages[0]["content"][0] == { + "video": "https://example.com/clip.mp4", + } + assert messages[1]["content"][0] == { + "video": [ + "https://example.com/f1.png", + "https://example.com/f2.png", + ], + } + + +def test_streaming_native_flattens_delta_content(): + def handler(request): + assert request.url.path == MULTIMODAL_PATH + assert request.headers.get("X-DashScope-SSE") == "enable" + body = json.loads(request.content) + assert body["parameters"]["incremental_output"] is True + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + content=_sse( + [ + { + "output": { + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": [{"text": "A cat"}], + }, + }, + ], + }, + "request_id": "r", + }, + { + "output": { + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"text": " on a mat."}], + }, + }, + ], + }, + "usage": { + "input_tokens": 10, + "output_tokens": 4, + "total_tokens": 14, + }, + "request_id": "r", + }, + ], + ), + ) + + client = _make_client(handler) + stream = client.multimodal.chat.create( + model="qwen-vl-max", + messages=[ + { + "role": "user", + "content": [ + {"image": "https://example.com/cat.png"}, + {"text": "describe"}, + ], + }, + ], + stream=True, + ) + assert "".join(stream.text_stream) == "A cat on a mat." + final = stream.get_final_message() + assert final.output_text == "A cat on a mat." + assert final.usage.total_tokens == 14 + + +def test_openai_protocol_translates_parts(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "resp-1", + "model": "qwen-vl-max", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "A cat on a mat.", + }, + }, + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 4, + "total_tokens": 14, + }, + }, + ) + + client = _make_client(handler, protocol="openai") + resp = client.multimodal.chat.create( + model="qwen-vl-max", + messages=[ + { + "role": "user", + "content": [ + {"image": "https://example.com/cat.png"}, + {"text": "describe"}, + ], + }, + ], + ) + # Compatible protocols keep their own chat path. + assert captured["path"] == "/api/v1/chat/completions" + assert captured["body"]["messages"] == [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "https://example.com/cat.png"}, + }, + {"type": "text", "text": "describe"}, + ], + }, + ] + assert resp.output_text == "A cat on a mat." + + +def test_openai_protocol_translates_audio_parts(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return _openai_response("OK") + + client = _make_client(handler, protocol="openai") + resp = client.multimodal.chat.create( + model="qwen3.8-omni-flash", + messages=[ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": {"data": "QUJD", "format": "mp3"}, + }, + {"audio": "https://example.com/a.wav"}, + {"text": "transcribe"}, + ], + }, + ], + ) + assert captured["path"] == "/api/v1/chat/completions" + content = captured["body"]["messages"][0]["content"] + assert content[0] == { + "type": "input_audio", + "input_audio": {"data": "QUJD", "format": "mp3"}, + } + assert content[1] == { + "type": "input_audio", + "input_audio": {"data": "https://example.com/a.wav"}, + } + assert content[2] == {"type": "text", "text": "transcribe"} + assert resp.output_text == "OK" + + +def test_native_omni_content_items_wire(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return _vl_response() + + client = _make_client(handler) + client.multimodal.chat.create( + model="qwen3.8-omni-flash", + messages=[ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": {"data": "QUJD", "format": "mp3"}, + }, + {"audio": "https://example.com/a.wav"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/a.png", + "min_pixels": 64, + "max_pixels": 1024, + }, + }, + { + "type": "video_url", + "video_url": {"url": "https://example.com/v.mp4"}, + "fps": 2, + "min_pixels": 64, + "max_pixels": 2048, + "total_pixels": 4096, + }, + {"text": "describe"}, + ], + }, + ], + ) + assert captured["path"] == MULTIMODAL_PATH + content = captured["body"]["input"]["messages"][0]["content"] + assert content[0] == {"audio": "data:audio/mp3;base64,QUJD"} + assert content[1] == {"audio": "https://example.com/a.wav"} + assert content[2] == { + "image": "https://example.com/a.png", + "min_pixels": 64, + "max_pixels": 1024, + } + assert content[3] == { + "video": "https://example.com/v.mp4", + "fps": 2, + "min_pixels": 64, + "max_pixels": 2048, + "total_pixels": 4096, + } + assert content[4] == {"text": "describe"} + + +def test_native_inline_audio_requires_format(): + client = _make_client(lambda request: _vl_response()) + with pytest.raises(ValueError, match="'format' is required"): + client.multimodal.chat.create( + model="qwen3.8-omni-flash", + messages=[ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": {"data": "QUJD"}, + }, + ], + }, + ], + ) + + +def test_openai_protocol_translates_omni_content_items(): + """Omni content items map to their OpenAI-compatible wire shapes.""" + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return _openai_response("OK") + + client = _make_client(handler, protocol="openai") + resp = client.multimodal.chat.create( + model="qwen3.8-omni-flash", + messages=[ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": {"data": "QUJD", "format": "mp3"}, + }, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/a.png", + "min_pixels": 64, + "max_pixels": 1024, + }, + }, + { + "type": "video_url", + "video_url": {"url": "https://example.com/v.mp4"}, + "fps": 2, + "total_pixels": 4096, + }, + ], + }, + ], + ) + assert captured["path"] == "/api/v1/chat/completions" + content = captured["body"]["messages"][0]["content"] + assert content[0] == { + "type": "input_audio", + "input_audio": {"data": "QUJD", "format": "mp3"}, + } + assert content[1] == { + "type": "image_url", + "image_url": {"url": "https://example.com/a.png"}, + "min_pixels": 64, + "max_pixels": 1024, + } + assert content[2] == { + "type": "video_url", + "video_url": {"url": "https://example.com/v.mp4"}, + "fps": 2.0, + "total_pixels": 4096, + } + assert resp.output_text == "OK" + + +@pytest.mark.asyncio +async def test_async_create_non_streaming(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + return _vl_response() + + client = _make_async_client(handler) + resp = await client.multimodal.chat.create( + model="qwen-vl-max", + messages=[ + { + "role": "user", + "content": [ + {"image": "https://example.com/cat.png"}, + {"text": "describe"}, + ], + }, + ], + ) + assert captured["path"] == MULTIMODAL_PATH + assert resp.output_text == "A cat on a mat." + + +@pytest.mark.asyncio +async def test_async_streaming(): + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + content=_sse( + [ + { + "output": { + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": [{"text": "A cat"}], + }, + }, + ], + }, + "request_id": "r", + }, + { + "output": { + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"text": "."}], + }, + }, + ], + }, + "request_id": "r", + }, + ], + ), + ) + + client = _make_async_client(handler) + stream = await client.multimodal.chat.create( + model="qwen-vl-max", + messages=[{"role": "user", "content": [{"text": "hi"}]}], + stream=True, + ) + parts = [text async for text in stream.text_stream] + assert "".join(parts) == "A cat." diff --git a/tests/v2/test_package_root.py b/tests/v2/test_package_root.py new file mode 100644 index 00000000..f363ed69 --- /dev/null +++ b/tests/v2/test_package_root.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the 2.0 package-root surface (M1 task 1.9).""" + +# Tests intentionally inspect the quickstart singleton internals. +# pylint: disable=protected-access + +from __future__ import annotations + +import json + +import httpx +import pytest + +import dashscope +from dashscope.v2 import _quickstart +from dashscope.v2 import AsyncClient as V2AsyncClient +from dashscope.v2 import Client as V2Client + +BASE_URL = "https://api.test.local/api/v1" + +_COMPLETION_PAYLOAD = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "A joke"}, + }, + ], + }, + "usage": {"input_tokens": 3, "output_tokens": 2, "total_tokens": 5}, + "request_id": "req-root-1", +} + + +def test_root_exports_are_v2(): + assert dashscope.Client is V2Client + assert dashscope.AsyncClient is V2AsyncClient + assert callable(dashscope.chat) + for name in ("AsyncClient", "Client", "chat"): + assert name in dashscope.__all__ + + +def test_legacy_exports_untouched(): + # 1.x names must keep resolving from the same package root. + assert hasattr(dashscope, "Generation") + assert hasattr(dashscope, "TextEmbedding") + assert hasattr(dashscope, "Files") + assert hasattr(dashscope, "Models") + assert hasattr(dashscope, "save_api_key") + + +def test_v2_namespace_reexports_1x_offline_helpers(): + # The 2.0 namespace delegates the offline helpers to 1.x instead of + # re-implementing them (same objects the package root exposes). + from dashscope import v2 + from dashscope.common.api_key import save_api_key as save_1x + from dashscope.tokenizers import get_tokenizer as get_1x + from dashscope.tokenizers import list_tokenizers as list_1x + + assert v2.save_api_key is save_1x + assert v2.get_tokenizer is get_1x + assert v2.list_tokenizers is list_1x + for name in ("get_tokenizer", "list_tokenizers", "save_api_key"): + assert name in v2.__all__ + + +def test_v2_get_tokenizer_encodes_locally(): + from dashscope import v2 + from dashscope.common.error import UnsupportedModel + + tokenizer = v2.get_tokenizer("qwen-turbo") + ids = tokenizer.encode("hello world") + assert ids and all(isinstance(i, int) for i in ids) + assert tokenizer.decode(ids) == "hello world" + assert "qwen-turbo" in v2.list_tokenizers() + with pytest.raises(UnsupportedModel): + v2.get_tokenizer("not-a-qwen-model") + + +def test_chat_quickstart_uses_singleton_client(monkeypatch): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_COMPLETION_PAYLOAD) + + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + client = dashscope.Client( + api_key="sk-test", + base_url=BASE_URL, + http_client=http_client, + ) + monkeypatch.setattr(_quickstart, "_client", client) + + resp = dashscope.chat("qwen-plus", "Tell me a joke", temperature=0.2) + + assert ( + captured["path"] == "/api/v1/services/aigc/text-generation/generation" + ) + body = captured["body"] + assert body["model"] == "qwen-plus" + assert body["input"]["messages"] == [ + {"role": "user", "content": "Tell me a joke"}, + ] + assert body["parameters"]["temperature"] == 0.2 + assert resp.output_text == "A joke" + assert resp.request_id == "req-root-1" + + # Second call reuses the same singleton client. + dashscope.chat("qwen-plus", "Another one") + assert _quickstart._get_client() is client diff --git a/tests/v2/test_protocols.py b/tests/v2/test_protocols.py new file mode 100644 index 00000000..469af0cf --- /dev/null +++ b/tests/v2/test_protocols.py @@ -0,0 +1,1713 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the protocol layer (M2 tasks 2.1/2.2/2.3).""" + +import json + +import httpx +import pytest + +from dashscope.v2 import Client +from dashscope.v2.errors import BadRequestError, DashScopeError +from dashscope.v2.protocols import ( + AnthropicProtocol, + NativeProtocol, + OpenAIProtocol, + get_protocol, +) +from dashscope.v2.protocols.base import ChatRequest + +BASE_URL = "https://api.test.local/api/v1" +CHAT_PATH = "/api/v1/services/aigc/text-generation/generation" +OPENAI_PATH = "/api/v1/chat/completions" +# BASE_URL "/api/v1" + adapter PATH "/v1/messages" +ANTHROPIC_PATH = "/api/v1/v1/messages" + +_MESSAGES = [{"role": "user", "content": "Hello"}] + + +def test_native_build_request_non_streaming(): + request = NativeProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + temperature=0.5, + enable_thinking=True, + ) + assert isinstance(request, ChatRequest) + assert request.path == NativeProtocol.PATH + assert request.headers == {} + body = request.body + assert body["model"] == "qwen-plus" + assert body["input"]["messages"] == _MESSAGES + params = body["parameters"] + assert params["result_format"] == "message" + assert params["temperature"] == 0.5 + assert params["enable_thinking"] is True + assert "incremental_output" not in params + + +def test_native_build_request_streaming(): + request = NativeProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + stream=True, + ) + assert request.headers == {"X-DashScope-SSE": "enable"} + assert request.body["parameters"]["incremental_output"] is True + + +def test_native_parse_completion_and_chunk(): + protocol = NativeProtocol() + payload = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "Hi"}, + }, + ], + }, + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "request_id": "req-1", + } + completion = protocol.parse_completion(payload) + assert completion.output_text == "Hi" + assert completion.request_id == "req-1" + + chunk_payload = { + "output": { + "choices": [ + {"delta": {"role": "assistant", "content": "He"}}, + ], + }, + "request_id": "req-2", + } + chunk = protocol.parse_chunk(chunk_payload) + assert chunk.delta is not None + assert chunk.delta.content == "He" + + +def test_native_extra_parameters_passthrough(): + request = NativeProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + extra_parameters={"top_k": 1}, + ) + assert request.body["parameters"]["top_k"] == 1 + + +def test_native_translates_media_parts_with_hints(): + request = NativeProtocol().build_chat_request( + model="qwen3.8-omni-flash", + messages=[ + { + "role": "user", + "content": [ + {"type": "audio", "data": "QUJD", "format": "mp3"}, + {"type": "audio", "url": "https://example.com/a.wav"}, + { + "type": "image", + "url": "https://example.com/a.png", + "min_pixels": 64, + "max_pixels": 1024, + }, + { + "type": "video", + "url": "https://example.com/v.mp4", + "fps": 2, + "min_pixels": 64, + "max_pixels": 2048, + "total_pixels": 4096, + }, + ], + }, + ], + ) + content = request.body["input"]["messages"][0]["content"] + assert content[0] == {"audio": "data:audio/mp3;base64,QUJD"} + assert content[1] == {"audio": "https://example.com/a.wav"} + assert content[2] == { + "image": "https://example.com/a.png", + "min_pixels": 64, + "max_pixels": 1024, + } + assert content[3] == { + "video": "https://example.com/v.mp4", + "fps": 2, + "min_pixels": 64, + "max_pixels": 2048, + "total_pixels": 4096, + } + + +def test_native_cache_control_marker_rides_content_blocks(): + # Official explicit-cache wire shape: the marker is a part-level + # sibling on content blocks (text and multimodal alike). + request = NativeProtocol().build_chat_request( + model="qwen3.8-max", + messages=[ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + ) + content = request.body["input"]["messages"][0]["content"] + assert content == [ + { + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ] + + request = NativeProtocol().build_chat_request( + model="qwen3-vl-plus", + messages=[ + { + "role": "user", + "content": [ + { + "type": "image", + "url": "https://example.com/a.png", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + ) + content = request.body["input"]["messages"][0]["content"] + assert content == [ + { + "image": "https://example.com/a.png", + "cache_control": {"type": "ephemeral"}, + }, + ] + + +def test_native_inline_audio_requires_format(): + with pytest.raises(ValueError, match="'format' is required"): + NativeProtocol().build_chat_request( + model="qwen3.8-omni-flash", + messages=[ + { + "role": "user", + "content": [{"type": "audio", "data": "QUJD"}], + }, + ], + ) + + +def test_get_protocol_registry(): + protocol = get_protocol("native") + assert isinstance(protocol, NativeProtocol) + assert protocol.name == "native" + openai = get_protocol("openai") + assert isinstance(openai, OpenAIProtocol) + assert openai.name == "openai" + anthropic = get_protocol("anthropic") + assert isinstance(anthropic, AnthropicProtocol) + assert anthropic.name == "anthropic" + + +def test_get_protocol_unknown(): + with pytest.raises(ValueError): + get_protocol("bogus") + + +def test_client_resolves_protocol(): + client = Client(api_key="sk-test", protocol="native", base_url=BASE_URL) + assert isinstance(client.protocol, NativeProtocol) + + +def test_get_protocol_passes_options(): + protocol = get_protocol("openai", {"strict": True}) + assert isinstance(protocol, OpenAIProtocol) + assert protocol.strict is True + + +def test_get_protocol_rejects_unknown_option(): + with pytest.raises(ValueError, match="protocol_options"): + get_protocol("openai", {"bogus_option": 1}) + + +def test_client_protocol_options_enable_strict(): + client = Client( + api_key="sk-test", + protocol="openai", + base_url=BASE_URL, + protocol_options={"strict": True}, + ) + assert client.protocol.strict is True + with pytest.raises(DashScopeError, match="strict"): + client.chat.completions.create( + model="qwen-plus", + messages=_MESSAGES, + enable_thinking=True, + ) + + +def test_native_strict_flag_is_inert(): + client = Client( + api_key="sk-test", + protocol="native", + base_url=BASE_URL, + protocol_options={"strict": True}, + ) + request = client.protocol.build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + enable_thinking=True, + ) + assert request.body["parameters"]["enable_thinking"] is True + + +def test_client_routes_chat_through_protocol(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "Hi there", + }, + }, + ], + }, + "request_id": "req-route", + }, + ) + + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + client = Client( + api_key="sk-test", + protocol="native", + base_url=BASE_URL, + http_client=http_client, + ) + resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + assert captured["path"] == CHAT_PATH + assert captured["body"]["parameters"]["result_format"] == "message" + assert resp.output_text == "Hi there" + + +def test_openai_build_request_non_streaming(): + request = OpenAIProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + temperature=0.5, + top_p=0.9, + max_tokens=100, + stop="END", + seed=7, + presence_penalty=0.1, + ) + assert isinstance(request, ChatRequest) + assert request.path == OpenAIProtocol.PATH + assert request.headers == {} + body = request.body + assert body["model"] == "qwen-plus" + assert body["messages"] == _MESSAGES + assert body["temperature"] == 0.5 + assert body["top_p"] == 0.9 + assert body["max_tokens"] == 100 + assert body["stop"] == "END" + assert body["seed"] == 7 + assert body["presence_penalty"] == 0.1 + assert "stream" not in body + + +def test_openai_build_request_streaming_and_tools(): + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object"}, + }, + }, + ] + request = OpenAIProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + stream=True, + tools=tools, + ) + assert request.body["stream"] is True + assert request.body["tools"] == tools + assert request.body["stream_options"] == {"include_usage": True} + + +def test_openai_stream_options_overridable(): + request = OpenAIProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + stream=True, + extra_parameters={"stream_options": {"include_usage": False}}, + ) + assert request.body["stream_options"] == {"include_usage": False} + + +def test_openai_non_streaming_omits_stream_options(): + request = OpenAIProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + ) + assert "stream_options" not in request.body + + +def test_openai_translates_content_parts(): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image", + "url": "https://example.com/a.png", + "detail": "low", + }, + ], + }, + ] + request = OpenAIProtocol().build_chat_request( + model="qwen-vl-plus", + messages=messages, + ) + content = request.body["messages"][0]["content"] + assert content[0] == {"type": "text", "text": "What is in this image?"} + assert content[1] == { + "type": "image_url", + "image_url": {"url": "https://example.com/a.png", "detail": "low"}, + } + + +def test_openai_translates_audio_parts(): + messages = [ + { + "role": "user", + "content": [ + {"type": "audio", "data": "QUJD", "format": "mp3"}, + {"type": "audio", "url": "https://example.com/a.wav"}, + ], + }, + ] + request = OpenAIProtocol().build_chat_request( + model="qwen3.8-omni-flash", + messages=messages, + ) + content = request.body["messages"][0]["content"] + assert content[0] == { + "type": "input_audio", + "input_audio": {"data": "QUJD", "format": "mp3"}, + } + assert content[1] == { + "type": "input_audio", + "input_audio": {"data": "https://example.com/a.wav"}, + } + + +def test_openai_translates_video_parts(): + messages = [ + { + "role": "user", + "content": [ + { + "type": "video", + "url": "https://example.com/v.mp4", + "fps": 2, + "min_pixels": 64, + "max_pixels": 2048, + "total_pixels": 4096, + }, + { + "type": "video", + "frames": [ + "https://example.com/f1.jpg", + "https://example.com/f2.jpg", + ], + "fps": 4, + }, + ], + }, + ] + request = OpenAIProtocol().build_chat_request( + model="qwen3.8-omni-flash", + messages=messages, + ) + content = request.body["messages"][0]["content"] + assert content[0] == { + "type": "video_url", + "video_url": {"url": "https://example.com/v.mp4"}, + "fps": 2.0, + "min_pixels": 64, + "max_pixels": 2048, + "total_pixels": 4096, + } + assert content[1] == { + "type": "video", + "video": [ + "https://example.com/f1.jpg", + "https://example.com/f2.jpg", + ], + "fps": 4.0, + } + + +def test_openai_cache_control_marker_rides_content_blocks(): + # Official compatible-mode wire shape: the marker is a part-level + # sibling of the text content item. + request = OpenAIProtocol().build_chat_request( + model="qwen3.8-max", + messages=[ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + ) + content = request.body["messages"][0]["content"] + assert content == [ + { + "type": "text", + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ] + + +def test_openai_image_pixel_hints(): + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "url": "https://example.com/a.png", + "min_pixels": 64, + "max_pixels": 1024, + }, + ], + }, + ] + request = OpenAIProtocol().build_chat_request( + model="qwen-vl-plus", + messages=messages, + ) + content = request.body["messages"][0]["content"] + assert content[0] == { + "type": "image_url", + "image_url": {"url": "https://example.com/a.png"}, + "min_pixels": 64, + "max_pixels": 1024, + } + + +def test_openai_keeps_tool_call_messages(): + messages = [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}, + ] + request = OpenAIProtocol().build_chat_request( + model="qwen-plus", + messages=messages, + ) + assistant, tool = request.body["messages"] + assert assistant["content"] is None + assert assistant["tool_calls"] == messages[0]["tool_calls"] + assert tool == messages[1] + + +def test_openai_echoes_reasoning_content_on_assistant_turns(): + request = OpenAIProtocol().build_chat_request( + model="qwen-plus", + messages=[ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "let me think", + }, + {"role": "user", "content": "and then?"}, + ], + ) + user, assistant, follow_up = request.body["messages"] + assert assistant["reasoning_content"] == "let me think" + assert "reasoning_content" not in user + assert "reasoning_content" not in follow_up + + +def test_openai_unknown_params_passthrough_by_default(): + request = OpenAIProtocol().build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + enable_thinking=True, + repetition_penalty=1.1, + extra_parameters={"top_k": 1}, + ) + body = request.body + assert body["enable_thinking"] is True + assert body["repetition_penalty"] == 1.1 + assert body["top_k"] == 1 + + +def test_openai_strict_rejects_unknown_params(): + with pytest.raises(DashScopeError, match="strict"): + OpenAIProtocol(strict=True).build_chat_request( + model="qwen-plus", + messages=_MESSAGES, + enable_thinking=True, + ) + + +def test_openai_parse_completion_and_chunk(): + protocol = OpenAIProtocol() + payload = { + "id": "chatcmpl-1", + "model": "qwen-plus", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "Hi", + "reasoning_content": "thought", + }, + }, + ], + "usage": { + "prompt_tokens": 3, + "completion_tokens": 1, + "total_tokens": 4, + }, + } + completion = protocol.parse_completion(payload) + assert completion.output_text == "Hi" + assert completion.request_id == "chatcmpl-1" + message = completion.choices[0].message + assert message.reasoning_content == "thought" + assert completion.usage is not None + assert completion.usage.input_tokens == 3 + assert completion.usage.output_tokens == 1 + + chunk_payload = { + "id": "chatcmpl-1", + "choices": [{"index": 0, "delta": {"content": "He"}}], + } + chunk = protocol.parse_chunk(chunk_payload) + assert chunk.delta is not None + assert chunk.delta.content == "He" + + final = protocol.parse_chunk({"id": "chatcmpl-1", "choices": []}) + assert not final.choices + + +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 + # differently, and nothing else in the response carries them. + protocol = OpenAIProtocol() + completion = protocol.parse_completion( + { + "id": "chatcmpl-2", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_tokens_details": {"cached_tokens": 64}, + "completion_tokens_details": {"reasoning_tokens": 12}, + }, + }, + ) + usage = completion.usage + assert usage is not None + assert usage.input_tokens == 100 + assert usage.output_tokens == 20 + # The wire total wins over the derived one. + assert usage.total_tokens == 120 + assert usage.prompt_tokens_details == {"cached_tokens": 64} + assert usage.completion_tokens_details == {"reasoning_tokens": 12} + # Renamed keys are not readable a second time under their wire spelling. + assert "prompt_tokens" not in usage.model_extra + assert "completion_tokens" not in usage.model_extra + + +def test_openai_stream_usage_details_survive_translation(): + protocol = OpenAIProtocol() + chunk = protocol.parse_chunk( + { + "id": "chatcmpl-3", + "choices": [], + "usage": { + "prompt_tokens": 7, + "completion_tokens": 3, + "prompt_tokens_details": {"cached_tokens": 5}, + }, + }, + ) + usage = chunk.usage + assert usage is not None + assert usage.input_tokens == 7 + assert usage.output_tokens == 3 + # Absent on the wire, so it is derived from the canonical counters. + assert usage.total_tokens == 10 + assert usage.prompt_tokens_details == {"cached_tokens": 5} + + +def test_client_routes_chat_through_openai_protocol(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "chatcmpl-route", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "Hi there", + }, + }, + ], + }, + ) + + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + client = Client( + api_key="sk-test", + base_url=BASE_URL, + protocol="openai", + http_client=http_client, + ) + resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + assert captured["path"] == OPENAI_PATH + assert captured["body"]["messages"] == _MESSAGES + assert "input" not in captured["body"] + assert resp.output_text == "Hi there" + + +def test_anthropic_build_request_with_system(): + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=[ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ], + temperature=0.5, + top_p=0.9, + stop="END", + ) + assert isinstance(request, ChatRequest) + assert request.path == AnthropicProtocol.PATH + assert request.headers == {"anthropic-version": "2023-06-01"} + body = request.body + assert body["model"] == "qwen3-max" + assert body["system"] == "You are helpful." + assert body["messages"] == [{"role": "user", "content": "Hello"}] + assert body["max_tokens"] == 4096 + assert body["temperature"] == 0.5 + assert body["top_p"] == 0.9 + assert body["stop_sequences"] == ["END"] + assert "stream" not in body + + +def test_anthropic_streaming_flag_and_max_tokens(): + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + stream=True, + max_tokens=64, + ) + assert request.body["stream"] is True + assert request.body["max_tokens"] == 64 + + +def test_anthropic_translates_tools(): + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + }, + ] + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + tools=tools, + ) + assert request.body["tools"] == [ + { + "name": "get_weather", + "description": "Get weather", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, + ] + + +def test_anthropic_translates_tool_call_round_trip(): + messages = [ + { + "role": "assistant", + "content": "Checking.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Hangzhou"}', + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}, + ] + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=messages, + ) + assistant, tool_result = request.body["messages"] + assert assistant["role"] == "assistant" + assert assistant["content"] == [ + {"type": "text", "text": "Checking."}, + { + "type": "tool_use", + "id": "call_1", + "name": "get_weather", + "input": {"city": "Hangzhou"}, + }, + ] + assert tool_result == { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "call_1", + "content": "sunny", + }, + ], + } + + +def test_anthropic_translates_content_parts(): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image", "url": "https://example.com/a.png"}, + ], + }, + ] + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=messages, + ) + assert request.body["messages"][0]["content"] == [ + {"type": "text", "text": "What is in this image?"}, + { + "type": "image", + "source": {"type": "url", "url": "https://example.com/a.png"}, + }, + ] + + +def test_anthropic_cache_control_marker_rides_blocks(): + # cache_control is Anthropic's own block-level field: user-message + # parts keep it as a sibling, and a marked system prompt switches + # ``system`` to its block-list form (the canonical caching + # placement); an unmarked system prompt stays a joined string. + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=[ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + ) + assert request.body["system"] == [ + { + "type": "text", + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ] + assert request.body["messages"][0]["content"] == [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral"}, + }, + ] + + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=[{"role": "system", "content": "plain"}], + ) + assert request.body["system"] == "plain" + + +def test_anthropic_rejects_audio_parts(): + messages = [ + { + "role": "user", + "content": [{"type": "audio", "url": "https://example.com/a.mp3"}], + }, + ] + with pytest.raises(ValueError, match="no Anthropic equivalent"): + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=messages, + ) + + +def test_anthropic_rejects_video_parts(): + messages = [ + { + "role": "user", + "content": [{"type": "video", "url": "https://example.com/v.mp4"}], + }, + ] + with pytest.raises(ValueError, match="no Anthropic equivalent"): + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=messages, + ) + + +def test_anthropic_rejects_image_pixel_hints(): + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "url": "https://example.com/a.png", + "max_pixels": 1024, + }, + ], + }, + ] + with pytest.raises(ValueError, match="pixel hints"): + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=messages, + ) + + +def test_anthropic_extra_parameters_strict_switch(): + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + extra_parameters={"top_k": 1}, + ) + assert request.body["top_k"] == 1 + with pytest.raises(DashScopeError, match="strict"): + AnthropicProtocol(strict=True).build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + extra_parameters={"top_k": 1}, + ) + + +def test_anthropic_rejects_unsupported_canonical_params(): + for kwargs in ( + {"seed": 7}, + {"repetition_penalty": 1.1}, + {"presence_penalty": 0.5}, + {"enable_thinking": True}, + {"response_format": {"type": "json_object"}}, + ): + with pytest.raises(BadRequestError, match="anthropic"): + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + **kwargs, + ) + + +def test_anthropic_response_format_error_details(): + with pytest.raises(BadRequestError) as exc_info: + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + response_format={"type": "json_object"}, + ) + error = exc_info.value + assert error.status_code == 400 + assert error.code == "InvalidParameter" + assert "response_format" in str(error) + + +def test_anthropic_rejects_unsupported_params_via_extra_parameters(): + # Unsupported canonical params must not slip through silently when + # smuggled in via extra_parameters (non-strict mode). + for name in ( + "seed", + "repetition_penalty", + "presence_penalty", + "enable_thinking", + "response_format", + ): + with pytest.raises(BadRequestError, match=name): + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + extra_parameters={name: {"type": "json_object"}}, + ) + + +def test_anthropic_translates_tool_choice(): + # The Bailian Anthropic-compatible endpoint documents tool_choice + # (auto/any/none/tool); canonical values map onto that object form. + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object", "properties": {}}, + }, + }, + ] + cases = [ + ("auto", {"type": "auto"}), + ("required", {"type": "any"}), + ("none", {"type": "none"}), + ( + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "tool", "name": "get_weather"}, + ), + # Anthropic-native shapes pass through unchanged, extra keys + # (disable_parallel_tool_use) riding along. + ({"type": "any"}, {"type": "any"}), + ( + {"type": "tool", "name": "get_weather"}, + {"type": "tool", "name": "get_weather"}, + ), + ( + {"type": "any", "disable_parallel_tool_use": True}, + {"type": "any", "disable_parallel_tool_use": True}, + ), + ] + for tool_choice, expected in cases: + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + tools=tools, + tool_choice=tool_choice, + ) + assert request.body["tool_choice"] == expected + + +def test_anthropic_tool_choice_via_extra_parameters(): + # Wire-native shape passes through the escape hatch, and the hatch + # wins over the named kwarg (chat adapters: hatch wins). + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + extra_parameters={"tool_choice": {"type": "any"}}, + ) + assert request.body["tool_choice"] == {"type": "any"} + + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + tool_choice="auto", + extra_parameters={"tool_choice": {"type": "none"}}, + ) + assert request.body["tool_choice"] == {"type": "none"} + + +def test_anthropic_translates_canonical_tool_choice_via_extra_parameters(): + # The hatch is applied verbatim, so a canonical/OpenAI-shaped + # tool_choice smuggled through extra_parameters must be translated + # too; otherwise a bare "required" reaches the wire and the gateway + # silently ignores it. Native shapes still pass through unchanged. + cases = [ + ("required", {"type": "any"}), + ("auto", {"type": "auto"}), + ( + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "tool", "name": "get_weather"}, + ), + ] + for tool_choice, expected in cases: + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + extra_parameters={"tool_choice": tool_choice}, + ) + assert request.body["tool_choice"] == expected + + # An untranslatable shape fails fast through the hatch too, instead + # of being smuggled onto the wire raw. + with pytest.raises(ValueError, match="tool_choice"): + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + extra_parameters={"tool_choice": "banana"}, + ) + + +def test_anthropic_rejects_untranslatable_tool_choice(): + # Forwarding an unknown shape verbatim would be silently ignored by + # the gateway, so the adapter fails fast instead. + for bad in ("banana", {"type": "banana"}, {"type": "function"}, [1]): + with pytest.raises(ValueError, match="tool_choice"): + AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=_MESSAGES, + tool_choice=bad, + ) + + +def test_anthropic_parse_completion(): + protocol = AnthropicProtocol() + payload = { + "id": "msg-1", + "model": "qwen3-max", + "role": "assistant", + "content": [ + {"type": "text", "text": "Hi"}, + { + "type": "tool_use", + "id": "call_1", + "name": "get_weather", + "input": {"city": "Hangzhou"}, + }, + ], + "stop_reason": "tool_use", + "usage": {"input_tokens": 3, "output_tokens": 5}, + } + completion = protocol.parse_completion(payload) + assert completion.request_id == "msg-1" + assert completion.output_text == "Hi" + choice = completion.choices[0] + assert choice.finish_reason == "tool_calls" + tool_call = choice.message.tool_calls[0] + assert tool_call.function.name == "get_weather" + assert json.loads(tool_call.function.arguments) == { + "city": "Hangzhou", + } + assert completion.usage is not None + assert completion.usage.input_tokens == 3 + assert completion.usage.output_tokens == 5 + assert completion.usage.total_tokens == 8 + + +def test_anthropic_usage_cache_counters_survive(): + # Parity with the streaming parser, which accumulates the whole usage + # bag across events; the non-streaming path must not reduce it. + protocol = AnthropicProtocol() + completion = protocol.parse_completion( + { + "id": "msg-3", + "content": [{"type": "text", "text": "Hi"}], + "usage": { + "input_tokens": 3, + "output_tokens": 5, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 64, + "service_tier": "standard", + }, + }, + ) + usage = completion.usage + assert usage is not None + assert usage.input_tokens == 3 + assert usage.output_tokens == 5 + assert usage.total_tokens == 8 + assert usage.cache_creation_input_tokens == 100 + assert usage.cache_read_input_tokens == 64 + assert usage.service_tier == "standard" + + +def test_anthropic_stream_usage_keeps_cache_counters(): + # Pins the accumulation contract the non-streaming path mirrors: + # message_delta only reports the output side, so the counters seen at + # message_start have to survive to the end of the stream. + parse = AnthropicProtocol().make_chunk_parser() + started = parse( + { + "type": "message_start", + "message": { + "id": "msg-4", + "usage": { + "input_tokens": 3, + "cache_read_input_tokens": 64, + }, + }, + }, + ) + assert started is not None + assert started.usage is not None + assert started.usage.cache_read_input_tokens == 64 + + ended = parse( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 5}, + }, + ) + assert ended is not None + assert ended.usage is not None + assert ended.usage.input_tokens == 3 + assert ended.usage.output_tokens == 5 + assert ended.usage.total_tokens == 8 + assert ended.usage.cache_read_input_tokens == 64 + + +def test_anthropic_parse_completion_thinking(): + # Parity with the streaming thinking_delta path: thinking blocks + # surface as reasoning_content instead of being silently dropped, + # with the block signature kept as reasoning_signature so the + # turn can be echoed back; redacted_thinking carries nothing + # user-facing. + protocol = AnthropicProtocol() + payload = { + "id": "msg-7210f2f3", + "model": "qwen3-max", + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "hmm", + "signature": "sig", + }, + {"type": "redacted_thinking", "data": "..."}, + {"type": "text", "text": "Hi"}, + ], + "stop_reason": "end_turn", + "usage": {"input_tokens": 3, "output_tokens": 5}, + } + message = protocol.parse_completion(payload).choices[0].message + assert message.reasoning_content == "hmm" + assert message.reasoning_signature == "sig" + assert message.content == "Hi" + + no_thinking = protocol.parse_completion( + {"id": "msg-2", "content": [{"type": "text", "text": "Hi"}]}, + ) + assert no_thinking.choices[0].message.reasoning_content is None + assert no_thinking.choices[0].message.reasoning_signature is None + + +def test_anthropic_echoes_signed_thinking_blocks_on_assistant_turns(): + protocol = AnthropicProtocol() + request = protocol.build_chat_request( + model="qwen3-max", + messages=[ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "answer", + "reasoning_content": "hmm", + "reasoning_signature": "sig", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "fn", "arguments": "{}"}, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "ok"}, + ], + ) + _, assistant, _ = request.body["messages"] + assert assistant["content"][0] == { + "type": "thinking", + "thinking": "hmm", + "signature": "sig", + } + assert assistant["content"][1] == {"type": "text", "text": "answer"} + assert assistant["content"][2]["type"] == "tool_use" + + # Unsigned reasoning would be a 400 on strict endpoints, so it + # stays dropped instead of being echoed as a thinking block. + unsigned = protocol.build_chat_request( + model="qwen3-max", + messages=[ + { + "role": "assistant", + "content": "answer", + "reasoning_content": "hmm", + }, + {"role": "user", "content": "next"}, + ], + ) + assert unsigned.body["messages"][0]["content"] == "answer" + + +def test_anthropic_replay_keeps_multimodal_parts_with_signed_thinking(): + # Entering the block branch for a signed thinking block must not + # collapse list content to text and silently drop image parts. + request = AnthropicProtocol().build_chat_request( + model="qwen3-max", + messages=[ + { + "role": "assistant", + "content": [ + {"type": "text", "text": "look"}, + {"type": "image", "url": "https://x/y.png"}, + ], + "reasoning_content": "hmm", + "reasoning_signature": "sig", + }, + {"role": "user", "content": "next"}, + ], + ) + assistant = request.body["messages"][0] + assert assistant["content"][0] == { + "type": "thinking", + "thinking": "hmm", + "signature": "sig", + } + assert assistant["content"][1] == {"type": "text", "text": "look"} + assert assistant["content"][2] == { + "type": "image", + "source": {"type": "url", "url": "https://x/y.png"}, + } + + +def test_anthropic_parse_chunk_sequence(): + protocol = AnthropicProtocol() + start = protocol.parse_chunk( + { + "type": "message_start", + "message": { + "id": "msg-1", + "model": "qwen3-max", + "usage": {"input_tokens": 3}, + }, + }, + ) + assert start.request_id == "msg-1" + assert start.model == "qwen3-max" + + text = protocol.parse_chunk( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hi"}, + }, + ) + assert text.delta is not None + assert text.delta.content == "Hi" + + thinking = protocol.parse_chunk( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "hmm"}, + }, + ) + assert thinking.delta is not None + assert thinking.delta.reasoning_content == "hmm" + + tool_start = protocol.parse_chunk( + { + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "call_1", + "name": "get_weather", + }, + }, + ) + calls = tool_start.choices[0].delta.tool_calls + assert calls[0].id == "call_1" + assert calls[0].function.arguments == "" + + tool_delta = protocol.parse_chunk( + { + "type": "content_block_delta", + "index": 1, + "delta": { + "type": "input_json_delta", + "partial_json": '{"city": "Hangzhou"}', + }, + }, + ) + calls = tool_delta.choices[0].delta.tool_calls + assert calls[0].function.arguments == '{"city": "Hangzhou"}' + + end = protocol.parse_chunk( + { + "type": "message_delta", + "delta": {"stop_reason": "tool_use"}, + "usage": {"output_tokens": 5}, + }, + ) + assert end.choices[0].finish_reason == "tool_calls" + assert end.usage is not None + assert end.usage.input_tokens == 3 + assert end.usage.output_tokens == 5 + assert end.usage.total_tokens == 8 + + +def test_anthropic_parse_chunk_skips_no_content_events(): + protocol = AnthropicProtocol() + assert protocol.parse_chunk({"type": "ping"}) is None + assert ( + protocol.parse_chunk({"type": "content_block_stop", "index": 0}) + is None + ) + assert protocol.parse_chunk({"type": "message_stop"}) is None + assert protocol.parse_chunk({"type": "some_future_event"}) is None + # Text block starts carry no content yet. + assert ( + protocol.parse_chunk( + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + ) + is None + ) + # signature_delta carries the thinking-block signature verbatim. + signature = protocol.parse_chunk( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "sig"}, + }, + ) + assert signature.delta is not None + assert signature.delta.reasoning_signature == "sig" + # Unknown delta types (e.g. citations_delta) carry nothing. + assert ( + protocol.parse_chunk( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "citations_delta", "citations": []}, + }, + ) + is None + ) + # message_stop still resets tool state even though it is skipped. + parser = protocol.make_chunk_parser() + parser( + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "call_1", + "name": "fn", + }, + }, + ) + assert parser({"type": "message_stop"}) is None + assert ( + parser( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": "{}"}, + }, + ) + is None + ) + + +def test_client_routes_chat_through_anthropic_protocol(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["anthropic_version"] = request.headers.get( + "anthropic-version", + ) + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg-route", + "role": "assistant", + "content": [{"type": "text", "text": "Hi there"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 2}, + }, + ) + + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + client = Client( + api_key="sk-test", + base_url=BASE_URL, + protocol="anthropic", + http_client=http_client, + ) + resp = client.chat.completions.create( + model="qwen3-max", + messages=[ + {"role": "system", "content": "You are helpful."}, + {"role": "user", "content": "Hello"}, + ], + ) + assert captured["path"] == ANTHROPIC_PATH + assert captured["anthropic_version"] == "2023-06-01" + assert captured["body"]["system"] == "You are helpful." + assert resp.output_text == "Hi there" + assert resp.choices[0].finish_reason == "stop" + + +def test_anthropic_make_chunk_parser_isolates_concurrent_streams(): + protocol = AnthropicProtocol() + parser_a = protocol.make_chunk_parser() + parser_b = protocol.make_chunk_parser() + + parser_a({"type": "message_start", "message": {"id": "A", "model": "m"}}) + parser_a( + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_A", + "name": "get_weather", + }, + }, + ) + # A second stream starting must not disturb the first one. + parser_b({"type": "message_start", "message": {"id": "B", "model": "m"}}) + parser_b( + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": "toolu_B", + "name": "get_time", + }, + }, + ) + + chunk_a = parser_a( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '{"city"'}, + }, + ) + calls_a = chunk_a.choices[0].delta.tool_calls + assert calls_a[0].id == "toolu_A" + assert calls_a[0].function.arguments == '{"city"' + + chunk_b = parser_b( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": '{"tz"'}, + }, + ) + calls_b = chunk_b.choices[0].delta.tool_calls + assert calls_b[0].id == "toolu_B" + assert calls_b[0].function.arguments == '{"tz"' + + +def _sse(events): + out = "".join(f"data: {json.dumps(e)}\n\n" for e in events) + return out + "data: [DONE]\n\n" + + +def test_anthropic_interleaved_streams_stay_isolated(): + def events_for(model): + return [ + { + "type": "message_start", + "message": {"id": f"msg-{model}", "model": model}, + }, + { + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": f"toolu_{model}", + "name": f"fn_{model}", + }, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": "{}"}, + }, + {"type": "message_stop"}, + ] + + def handler(request): + body = json.loads(request.content) + return httpx.Response( + 200, + text=_sse(events_for(body["model"])), + headers={"content-type": "text/event-stream"}, + ) + + mock = httpx.MockTransport(handler) + client = Client( + api_key="sk-test", + protocol="anthropic", + base_url=BASE_URL, + http_client=httpx.Client(transport=mock, base_url=BASE_URL), + ) + stream_a = client.chat.completions.create( + model="a", + messages="hi", + stream=True, + ) + stream_b = client.chat.completions.create( + model="b", + messages="hi", + stream=True, + ) + iter_a, iter_b = iter(stream_a), iter(stream_b) + + next(iter_a) # A: message_start + start_a = next(iter_a) # A: tool_use block start + next(iter_b) # B: message_start; must not reset A's state + next(iter_b) # B: tool_use block start + delta_a = next(iter_a) # A: arguments delta + delta_b = next(iter_b) # B: arguments delta + + assert start_a.choices[0].delta.tool_calls[0].id == "toolu_a" + assert delta_a.choices[0].delta.tool_calls[0].id == "toolu_a" + assert delta_b.choices[0].delta.tool_calls[0].id == "toolu_b" + + +def test_openai_stream_usage_details_reach_the_caller(): + # End to end: adapter translation, stream aggregation and the model + # layer must all leave the gateway's usage detail intact. Cached and + # reasoning tokens are billed differently, and the usage bag is the + # caller's only view of what the request cost. + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + text=_sse( + [ + { + "id": "chatcmpl-e2e", + "choices": [ + {"index": 0, "delta": {"content": "Hi"}}, + ], + }, + { + "id": "chatcmpl-e2e", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_tokens_details": {"cached_tokens": 64}, + "completion_tokens_details": { + "reasoning_tokens": 12, + }, + }, + }, + ], + ), + headers={"content-type": "text/event-stream"}, + ) + + mock = httpx.MockTransport(handler) + client = Client( + api_key="sk-test", + protocol="openai", + base_url=BASE_URL, + http_client=httpx.Client(transport=mock, base_url=BASE_URL), + ) + stream = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=True, + ) + final = stream.get_final_message() + assert final.output_text == "Hi" + # The premise of the whole case: a stream only reports usage at all + # because the adapter asked for it. + assert captured["path"] == OPENAI_PATH + assert captured["body"]["stream_options"] == {"include_usage": True} + usage = final.usage + assert usage is not None + assert usage.input_tokens == 100 + assert usage.output_tokens == 20 + assert usage.total_tokens == 120 + assert usage.prompt_tokens_details == {"cached_tokens": 64} + assert usage.completion_tokens_details == {"reasoning_tokens": 12} diff --git a/tests/v2/test_rl.py b/tests/v2/test_rl.py new file mode 100644 index 00000000..29e189b8 --- /dev/null +++ b/tests/v2/test_rl.py @@ -0,0 +1,805 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.rl thin mount (M3 3.7).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import httpx +import pytest + +from dashscope.finetune import agentic_rl as agentic_rl_module +from dashscope.finetune.reinforcement.common.errors import ( + AgenticRLError, + InstanceQueryError, + OSSUploadError, + RuntimeErrorWithCode, +) +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import DashScopeError, SdkError +from dashscope.v2.types.common import JobStatus + +BASE_URL = "https://api.test.local/api/v1" + +# RL job verbs use the v2 client's own base_url like every other v2 +# resource; 1.x's forced management-plane host is a 1.x config quirk. +BASE_HOST = httpx.URL(BASE_URL).host + + +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 FakeTuning: + def __init__(self): + self.functions = None + self.added = [] + + def add_function_components(self, functype, classpaths, workspace_dir): + self.added.append((functype, classpaths)) + + +class FakeFineTuneOutput: + """Stands in for the 1.x ``FineTuneOutput`` nested under ``output``.""" + + def __init__(self): + self.job_id = "ft-run-1" + self.status = "RUNNING" + self.create_time = "2026-01-02 03:04:05" + + +class FakeFineTune: + """Stands in for the 1.x ``FineTune`` dataclass ``rl.run`` returns.""" + + def __init__(self): + self.request_id = "req-run" + self.status_code = 200 + self.output = FakeFineTuneOutput() + + +class FakeAgenticRL: + calls = {} + + def __init__(self, api_key=None): + self.api_key = api_key + self.tuning = FakeTuning() + self.inited = None + FakeAgenticRL.instances.append(self) + + def init(self, config_path=None, **kwargs): + self.inited = (config_path, kwargs) + return self + + async def register_functions(self, lazy_load=True): + return (["ro-e"], ["rw-e"], [], ["ro-i"], ["rw-i"], []) + + async def upload_datasets( + self, + training_files=None, + validation_files=None, + ): + return ["train-1"], ["val-1"] + + async def run(self): + return FakeFineTune() + + @classmethod + async def test_functions( + cls, + instance_id, + functype, + input_data, + api_key=None, + pull_logs=False, + log_page_size=100, + start_time=None, + end_time=None, + ): + cls.calls["test_functions"] = { + "instance_id": instance_id, + "functype": functype, + "input_data": input_data, + "api_key": api_key, + "pull_logs": pull_logs, + "log_page_size": log_page_size, + "start_time": start_time, + "end_time": end_time, + } + return {"ok": True, "instance_id": instance_id} + + @classmethod + async def query_function_instance_logs( + cls, + function_instance_id, + page_number=1, + page_size=100, + start_time=None, + end_time=None, + keywords=None, + api_key=None, + ): + cls.calls["query_function_instance_logs"] = { + "function_instance_id": function_instance_id, + "page_number": page_number, + "page_size": page_size, + "start_time": start_time, + "end_time": end_time, + "keywords": keywords, + "api_key": api_key, + } + return {"logs": ["line-1"], "page_number": page_number} + + @classmethod + async def query_all_function_instance_logs( + cls, + function_instance_id, + page_size=100, + start_time=None, + end_time=None, + keywords=None, + api_key=None, + ): + cls.calls["query_all_function_instance_logs"] = { + "function_instance_id": function_instance_id, + "page_size": page_size, + "start_time": start_time, + "end_time": end_time, + "keywords": keywords, + "api_key": api_key, + } + return ["line-1", "line-2"] + + @classmethod + async def delete_function_instance( + cls, + function_instance_id, + api_key=None, + ): + cls.calls["delete_function_instance"] = { + "function_instance_id": function_instance_id, + "api_key": api_key, + } + return {"data": {"status": "deleted"}} + + +@pytest.fixture(name="fake_agentic_rl") +def fixture_fake_agentic_rl(monkeypatch): + FakeAgenticRL.instances = [] + FakeAgenticRL.calls = {} + monkeypatch.setattr( + agentic_rl_module, + "AgenticRL", + FakeAgenticRL, + ) + return FakeAgenticRL + + +def test_rl_mounted_on_clients(): + client = _make_client(lambda request: httpx.Response(200, json={})) + async_client = _make_async_client( + lambda request: httpx.Response(200, json={}), + ) + assert hasattr(client, "rl") + assert hasattr(async_client, "rl") + + +def test_register_functions_delegates_to_1x(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + result = client.rl.register_functions( + rollout_classpaths=["rollout.py:MyRollout"], + reward_classpaths=["reward.py:MyReward"], + ) + assert result.rollout_entity_ids == ["ro-e"] + assert result.reward_entity_ids == ["rw-e"] + assert result.group_reward_entity_ids == [] + assert result.rollout_instance_ids == ["ro-i"] + assert result.reward_instance_ids == ["rw-i"] + assert result.group_reward_instance_ids == [] + + +def test_upload_data_delegates_to_1x(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + result = client.rl.upload_data( + training_files=["train.jsonl"], + validation_files=["val.jsonl"], + ) + assert result.training_file_ids == ["train-1"] + assert result.validation_file_ids == ["val-1"] + + +def test_jobs_create_uses_v2_api_key_and_init(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + job = client.rl.jobs.create(config_path="init.yaml", job_name="my-job") + created = fake_agentic_rl.instances[0] + assert created.api_key == "sk-test" + assert created.inited == ("init.yaml", {"job_name": "my-job"}) + assert job.job_id == "ft-run-1" + assert job.status == "RUNNING" + assert job.created_at == "2026-01-02 03:04:05" + assert job.request_id == "req-run" + assert job.status_code == 200 + assert job.finished is False + + +def test_jobs_wait_polls_until_terminal(): + statuses = ["RUNNING", "SUCCEEDED"] + + def handler(request): + # wait polls through fetch, same client endpoint as the verbs. + assert request.url.host == BASE_HOST + assert request.url.path == "/api/v1/fine-tunes/ft-1" + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": {"job_id": "ft-1", "status": statuses.pop(0)}, + }, + ) + + client = _make_client(handler) + job = client.rl.jobs.wait("ft-1", _sleep=lambda _: None) + assert job.succeeded is True + assert not statuses + + +def test_jobs_wait_does_not_stop_on_unknown(): + # RL reuses ``FineTune.finished``, whose terminal set deliberately + # excludes UNKNOWN (the 1.x finetune-plane rule), so an UNKNOWN + # snapshot keeps polling rather than ending the wait early. + statuses = ["UNKNOWN", "SUCCEEDED"] + + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": {"job_id": "ft-1", "status": statuses.pop(0)}, + }, + ) + + client = _make_client(handler) + job = client.rl.jobs.wait("ft-1", _sleep=lambda _: None) + assert job.succeeded is True + assert not statuses + + +def test_jobs_wait_failed_raises_with_headers_and_clean_body(): + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": { + "job_id": "ft-1", + "status": "FAILED", + "code": "InternalError", + "message": "rollout crashed", + }, + }, + headers={"x-request-id": "req-h", "x-gw-region": "cn-hz"}, + ) + + client = _make_client(handler) + with pytest.raises(DashScopeError) as exc: + client.rl.jobs.wait("ft-1", _sleep=lambda _: None) + error = exc.value + assert error.code == "InternalError" + assert error.message == "rollout crashed" + assert error.headers["x-request-id"] == "req-h" + assert error.headers["x-gw-region"] == "cn-hz" + # ``headers`` is transport metadata: it rides on the exception, not + # inside ``body``, which callers log verbatim. + assert "headers" not in error.body + assert error.body["status"] == "FAILED" + + +def test_jobs_wait_returns_error_envelope_in_compat_mode(): + def handler(request): + return httpx.Response( + 401, + json={ + "request_id": "req-x", + "code": "InvalidApiKey", + "message": "bad key", + }, + ) + + client = _make_client(handler, raise_on_error=False) + job = client.rl.jobs.wait("ft-1", _sleep=lambda _: None) + # Compat mode: the first failed poll comes back as the envelope + # instead of spinning to the timeout and raising there. + assert job.is_error is True + assert job.code == "InvalidApiKey" + assert job.request_id == "req-x" + + +@pytest.mark.asyncio +async def test_async_jobs_wait_returns_error_envelope_in_compat_mode(): + def handler(request): + return httpx.Response( + 401, + json={ + "request_id": "req-x", + "code": "InvalidApiKey", + "message": "bad key", + }, + ) + + async def _no_sleep(_): + return None + + client = _make_async_client(handler, raise_on_error=False) + job = await client.rl.jobs.wait("ft-1", _sleep=_no_sleep) + assert job.is_error is True + assert job.code == "InvalidApiKey" + assert job.request_id == "req-x" + + +def test_test_functions_resolves_string_functype(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + result = client.rl.test_functions( + instance_id="ro-ins-1", + func_type="rollout", + input_data={"prompt": "hi"}, + ) + assert result["ok"] is True + assert result["instance_id"] == "ro-ins-1" + + +def test_test_functions_forwards_log_params(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + client.rl.test_functions( + instance_id="ro-ins-1", + func_type="rollout", + input_data={"prompt": "hi"}, + pull_logs=True, + log_page_size=20, + start_time=10, + end_time=20, + ) + call = fake_agentic_rl.calls["test_functions"] + assert call["pull_logs"] is True + assert call["log_page_size"] == 20 + assert call["start_time"] == 10 + assert call["end_time"] == 20 + assert call["api_key"] == "sk-test" + + +def test_query_function_instance_logs_delegates_to_1x(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + result = client.rl.query_function_instance_logs( + "ro-ins-1", + page_number=2, + page_size=50, + start_time=100, + end_time=200, + keywords=["err"], + ) + assert result["logs"] == ["line-1"] + assert result["page_number"] == 2 + assert fake_agentic_rl.calls["query_function_instance_logs"] == { + "function_instance_id": "ro-ins-1", + "page_number": 2, + "page_size": 50, + "start_time": 100, + "end_time": 200, + "keywords": ["err"], + "api_key": "sk-test", + } + + +def test_query_all_function_instance_logs_delegates_to_1x(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + result = client.rl.query_all_function_instance_logs( + "ro-ins-1", + page_size=25, + start_time=100, + end_time=200, + keywords=["warn"], + ) + assert result == ["line-1", "line-2"] + assert fake_agentic_rl.calls["query_all_function_instance_logs"] == { + "function_instance_id": "ro-ins-1", + "page_size": 25, + "start_time": 100, + "end_time": 200, + "keywords": ["warn"], + "api_key": "sk-test", + } + + +def test_delete_function_instance_delegates_to_1x(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + result = client.rl.delete_function_instance("ro-ins-1") + assert result["data"]["status"] == "deleted" + assert fake_agentic_rl.calls["delete_function_instance"] == { + "function_instance_id": "ro-ins-1", + "api_key": "sk-test", + } + + +def test_job_management_uses_client_base_url(): + # RL job verbs ride the client endpoint like every other v2 resource. + seen = [] + + def handler(request): + assert request.url.host == BASE_HOST + seen.append((request.method, request.url.path)) + if request.url.path.endswith("/logs"): + assert dict(request.url.params) == {"offset": "3", "line": "50"} + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": {"logs": "line1\nline2"}, + }, + ) + if request.method == "POST": + return httpx.Response( + 200, + json={ + "request_id": "req-2", + "output": {"status": "canceled"}, + }, + ) + if request.method == "DELETE": + return httpx.Response( + 200, + json={ + "request_id": "req-4", + "output": {"status": "deleted"}, + }, + ) + if request.url.path.endswith("/fine-tunes/ft-1"): + return httpx.Response( + 200, + json={ + "request_id": "req-3", + "output": {"job_id": "ft-1", "status": "RUNNING"}, + }, + ) + return httpx.Response( + 200, + json={ + "request_id": "req-3", + "output": { + "page_no": 1, + "page_size": 10, + "total": 1, + "jobs": [{"job_id": "ft-1", "status": "RUNNING"}], + }, + }, + ) + + client = _make_client(handler) + job = client.rl.jobs.fetch("ft-1") + assert job.status == "RUNNING" + listed = client.rl.jobs.list() + assert listed.jobs[0].job_id == "ft-1" + canceled = client.rl.jobs.cancel("ft-1") + assert canceled.status == "canceled" + logs = client.rl.jobs.logs("ft-1", offset=3, lines=50) + assert logs.content == "line1\nline2" + deleted = client.rl.jobs.delete("ft-1") + assert isinstance(deleted, JobStatus) + assert deleted.status == "deleted" + assert seen == [ + ("GET", "/api/v1/fine-tunes/ft-1"), + ("GET", "/api/v1/fine-tunes"), + ("POST", "/api/v1/fine-tunes/ft-1/cancel"), + ("GET", "/api/v1/fine-tunes/ft-1/logs"), + ("DELETE", "/api/v1/fine-tunes/ft-1"), + ] + + +def test_jobs_verbs_thread_api_key_and_workspace(): + def handler(request): + assert request.url.host == BASE_HOST + assert request.headers["Authorization"] == "Bearer sk-override" + assert request.headers["X-DashScope-WorkSpace"] == "ws-9" + return httpx.Response( + 200, + json={ + "request_id": "req-9", + "output": {"job_id": "ft-1", "status": "RUNNING"}, + }, + ) + + client = _make_client(handler) + job = client.rl.jobs.fetch( + "ft-1", + api_key="sk-override", + workspace="ws-9", + ) + assert job.job_id == "ft-1" + + +@pytest.mark.asyncio +async def test_async_rl_upload_and_jobs(fake_agentic_rl): + def handler(request): + assert request.url.host == BASE_HOST + assert request.url.path == "/api/v1/fine-tunes/ft-1/logs" + return httpx.Response( + 200, + json={"request_id": "req-1", "output": {"logs": "ok"}}, + ) + + client = _make_async_client(handler) + uploaded = await client.rl.upload_data(training_files=["train.jsonl"]) + assert uploaded.training_file_ids == ["train-1"] + job = await client.rl.jobs.create(config_path="init.yaml") + assert job.job_id == "ft-run-1" + assert job.status == "RUNNING" + logs = await client.rl.jobs.logs("ft-1") + assert logs.content == "ok" + + +@pytest.mark.asyncio +async def test_async_jobs_hit_rl_management_host(): + seen = [] + + def handler(request): + assert request.url.host == BASE_HOST + seen.append((request.method, request.url.path)) + if request.method == "DELETE": + return httpx.Response( + 200, + json={ + "request_id": "req-4", + "output": {"status": "deleted"}, + }, + ) + if request.method == "POST": + return httpx.Response( + 200, + json={ + "request_id": "req-2", + "output": {"status": "canceled"}, + }, + ) + return httpx.Response( + 200, + json={ + "request_id": "req-3", + "output": {"job_id": "ft-1", "status": "RUNNING"}, + }, + ) + + client = _make_async_client(handler) + job = await client.rl.jobs.fetch("ft-1") + assert job.status == "RUNNING" + canceled = await client.rl.jobs.cancel("ft-1") + assert isinstance(canceled, JobStatus) + assert canceled.status == "canceled" + deleted = await client.rl.jobs.delete("ft-1") + assert isinstance(deleted, JobStatus) + assert deleted.status == "deleted" + assert seen == [ + ("GET", "/api/v1/fine-tunes/ft-1"), + ("POST", "/api/v1/fine-tunes/ft-1/cancel"), + ("DELETE", "/api/v1/fine-tunes/ft-1"), + ] + + +@pytest.mark.asyncio +async def test_async_test_functions_forwards_log_params(fake_agentic_rl): + client = _make_async_client(lambda request: httpx.Response(200, json={})) + await client.rl.test_functions( + instance_id="ro-ins-1", + func_type="reward", + input_data={"x": 1}, + pull_logs=True, + log_page_size=5, + start_time=1, + end_time=2, + ) + call = fake_agentic_rl.calls["test_functions"] + assert call["pull_logs"] is True + assert call["log_page_size"] == 5 + assert call["start_time"] == 1 + assert call["end_time"] == 2 + + +@pytest.mark.asyncio +async def test_async_function_instance_ops_delegate(fake_agentic_rl): + client = _make_async_client(lambda request: httpx.Response(200, json={})) + page = await client.rl.query_function_instance_logs( + "ro-ins-1", + page_number=3, + ) + assert page["page_number"] == 3 + all_logs = await client.rl.query_all_function_instance_logs( + "ro-ins-1", + page_size=25, + ) + assert all_logs == ["line-1", "line-2"] + deleted = await client.rl.delete_function_instance("ro-ins-1") + assert deleted["data"]["status"] == "deleted" + calls = fake_agentic_rl.calls + assert calls["query_function_instance_logs"]["page_number"] == 3 + assert calls["query_function_instance_logs"]["api_key"] == "sk-test" + assert calls["query_all_function_instance_logs"]["page_size"] == 25 + assert calls["delete_function_instance"] == { + "function_instance_id": "ro-ins-1", + "api_key": "sk-test", + } + + +def test_rl_long_form_alias(): + client = _make_client(lambda r: httpx.Response(200)) + assert client.reinforcement_learning is client.rl + async_client = _make_async_client(lambda r: httpx.Response(200)) + assert async_client.reinforcement_learning is async_client.rl + + +# -- 1.x exceptions translated into the v2 error family ----------------- + + +@pytest.fixture(name="fail_step") +def fixture_fail_step(fake_agentic_rl, monkeypatch): + """Make one named step of the 1.x fake raise ``exc``.""" + + # Steps the mount layer invokes as ``AgenticRL`` classmethods. + classmethod_steps = { + "test_functions", + "query_function_instance_logs", + "query_all_function_instance_logs", + "delete_function_instance", + } + + def _set(step, exc): + async def _afail(first, **kwargs): + raise exc + + def _sfail(first, **kwargs): + raise exc + + if step in classmethod_steps: + replacement = classmethod(_afail) + elif step == "init": + replacement = _sfail + else: + replacement = _afail + monkeypatch.setattr(fake_agentic_rl, step, replacement) + + return _set + + +def test_jobs_create_translates_1x_failure(fail_step): + fail_step("run", RuntimeErrorWithCode("submit blew up", error_code=3002)) + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(SdkError) as ei: + client.rl.jobs.create(config_path="init.yaml") + err = ei.value + assert err.code == "sdk.agentic_rl.RuntimeErrorWithCode" + # SDK-side failures have no HTTP semantics, so no invented status. + assert err.status_code is None + assert "submit blew up" in err.message + assert isinstance(err.__cause__, RuntimeErrorWithCode) + + +def test_translated_error_is_catchable_as_dashscope_error(fail_step): + fail_step("upload_datasets", OSSUploadError("bucket denied")) + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(DashScopeError) as ei: + client.rl.upload_data(training_files=["t.jsonl"]) + assert isinstance(ei.value, SdkError) + + +def test_exact_catalog_hit_carries_its_own_solution(fail_step): + fail_step("upload_datasets", OSSUploadError("bucket denied")) + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(SdkError) as ei: + client.rl.upload_data(training_files=["t.jsonl"]) + err = ei.value + assert err.code == "sdk.agentic_rl.OSSUploadError" + assert err.allow_retry is True + assert "OSS bucket permissions" in err.solution + + +def test_uncatalogued_rl_class_falls_back_to_the_family_entry(fail_step): + # AgenticRLError is the 1.x base but has no catalog entry of its own, + # so it must land on the family fallback rather than escape raw. + fail_step("register_functions", AgenticRLError("something odd")) + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(SdkError) as ei: + client.rl.register_functions(rollout_classpaths=["r.py:R"]) + assert ei.value.code == "sdk.agentic_rl.Error" + + +def test_test_functions_translates(fail_step): + fail_step("test_functions", InstanceQueryError("no instance")) + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(SdkError) as ei: + client.rl.test_functions( + instance_id="ro-ins-1", + func_type="rollout", + input_data={"prompt": "hi"}, + ) + assert ei.value.code == "sdk.agentic_rl.InstanceQueryError" + + +def test_function_instance_delete_translates(fail_step): + fail_step("delete_function_instance", InstanceQueryError("no instance")) + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(SdkError) as ei: + client.rl.delete_function_instance("ro-ins-1") + assert ei.value.code == "sdk.agentic_rl.InstanceQueryError" + + +def test_non_rl_exceptions_pass_through(fail_step): + # A bare ValueError is not part of the rl family, so relabelling it + # as an SDK error would hide bugs in the mount layer itself. + fail_step("init", ValueError("plain python error")) + client = _make_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(ValueError) as ei: + client.rl.jobs.create(config_path="init.yaml") + assert not isinstance(ei.value, SdkError) + + +@pytest.mark.asyncio +async def test_async_jobs_create_translates(fail_step): + fail_step("run", RuntimeErrorWithCode("submit blew up", error_code=3002)) + client = _make_async_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(SdkError) as ei: + await client.rl.jobs.create(config_path="init.yaml") + assert ei.value.code == "sdk.agentic_rl.RuntimeErrorWithCode" + + +@pytest.mark.asyncio +async def test_async_register_functions_translates(fail_step): + fail_step("register_functions", OSSUploadError("layer upload failed")) + client = _make_async_client(lambda request: httpx.Response(200, json={})) + with pytest.raises(SdkError) as ei: + await client.rl.register_functions(rollout_classpaths=["r.py:R"]) + assert ei.value.code == "sdk.agentic_rl.OSSUploadError" + assert isinstance(ei.value, DashScopeError) + + +def test_workflow_steps_forward_the_per_call_api_key(fake_agentic_rl): + client = _make_client(lambda request: httpx.Response(200, json={})) + client.rl.jobs.create(api_key="sk-override") + client.rl.register_functions(api_key="sk-override") + client.rl.upload_data( + training_files=["train.jsonl"], + api_key="sk-override", + ) + assert [instance.api_key for instance in fake_agentic_rl.instances] == [ + "sk-override", + "sk-override", + "sk-override", + ] + client.rl.test_functions( + instance_id="inst-1", + func_type="rollout", + input_data={}, + api_key="sk-override", + ) + assert fake_agentic_rl.calls["test_functions"]["api_key"] == "sk-override" + client.rl.query_function_instance_logs("inst-1", api_key="sk-override") + assert ( + fake_agentic_rl.calls["query_function_instance_logs"]["api_key"] + == "sk-override" + ) + client.rl.query_all_function_instance_logs("inst-1", api_key="sk-override") + assert ( + fake_agentic_rl.calls["query_all_function_instance_logs"]["api_key"] + == "sk-override" + ) + client.rl.delete_function_instance("inst-1", api_key="sk-override") + assert ( + fake_agentic_rl.calls["delete_function_instance"]["api_key"] + == "sk-override" + ) diff --git a/tests/v2/test_settings.py b/tests/v2/test_settings.py new file mode 100644 index 00000000..2fe0e55a --- /dev/null +++ b/tests/v2/test_settings.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the global Settings facade (M3 3.11).""" + +# pytest fixture injection looks like unused/redefined names to pylint. +# pylint: disable=redefined-outer-name,unused-argument + +from __future__ import annotations + +import logging + +import pytest + +from dashscope.v2 import settings +from dashscope.v2._config import ( + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + resolve_config, +) +from dashscope.v2._logging import ROOT_NAME + + +@pytest.fixture +def clean(monkeypatch): + for name in ( + "DASHSCOPE_API_KEY", + "DASHSCOPE_BASE_URL", + "DASHSCOPE_HTTP_BASE_URL", + "DASHSCOPE_WORKSPACE", + "DASHSCOPE_REGION", + "DASHSCOPE_WEBSOCKET_BASE_URL", + "DASHSCOPE_PROTOCOL", + "DASHSCOPE_LOG", + "DASHSCOPE_CONFIG", + ): + monkeypatch.delenv(name, raising=False) + settings.clear() + yield monkeypatch + settings.clear() + + +def test_lazy_env_fallback(clean): + assert settings.api_key is None + clean.setenv("DASHSCOPE_API_KEY", "env-key") + assert settings.api_key == "env-key" + settings.api_key = "set-key" + assert settings.api_key == "set-key" + settings.clear() + assert settings.api_key == "env-key" + + +def test_defaults(clean): + # No workspace and no explicit base_url -> the facade falls back to + # the legacy shared gateway (the 1.x endpoint) with a warning. + with pytest.warns(UserWarning, match="legacy shared gateway"): + assert settings.base_url == "https://dashscope.aliyuncs.com/api/v1" + assert settings.timeout == DEFAULT_TIMEOUT + assert settings.max_retries == DEFAULT_MAX_RETRIES + assert settings.protocol == "native" + assert settings.websocket_base_url is None + assert settings.log_level is None + + +def test_base_url_default_is_native_regardless_of_protocol(clean): + # The protocol-dependent default endpoint lives on + # Config.protocol_base_url; settings.base_url stays native. + settings.workspace = "ws-123" + native = "https://ws-123.cn-beijing.maas.aliyuncs.com/api/v1" + settings.protocol = "openai" + assert settings.base_url == native + settings.protocol = "anthropic" + assert settings.base_url == native + settings.base_url = "https://set.example.com" + assert settings.base_url == "https://set.example.com" + settings.clear() + # Without a workspace the legacy shared gateway is the fallback. + with pytest.warns(UserWarning, match="legacy shared gateway"): + assert settings.base_url == "https://dashscope.aliyuncs.com/api/v1" + + +def test_base_url_falls_back_to_workspace_gateway(clean): + settings.workspace = "ws-123" + assert ( + settings.base_url + == "https://ws-123.cn-beijing.maas.aliyuncs.com/api/v1" + ) + settings.base_url = "https://set.example.com" + assert settings.base_url == "https://set.example.com" + settings.clear() + # Without a workspace the legacy shared gateway is the fallback. + with pytest.warns(UserWarning, match="legacy shared gateway"): + assert settings.base_url == "https://dashscope.aliyuncs.com/api/v1" + + +def test_settings_override_env_in_resolve(clean, tmp_path): + clean.setenv("DASHSCOPE_API_KEY", "env-key") + settings.api_key = "settings-key" + settings.base_url = "https://settings.example.com" + settings.timeout = 42.0 + settings.max_retries = 5 + settings.protocol = "openai" + cfg = resolve_config(config_path=tmp_path / "absent.toml") + assert cfg.api_key == "settings-key" + assert cfg.base_url == "https://settings.example.com" + assert cfg.timeout == 42.0 + assert cfg.max_retries == 5 + assert cfg.protocol == "openai" + + +def test_explicit_param_beats_settings(clean, tmp_path): + settings.api_key = "settings-key" + cfg = resolve_config( + api_key="param-key", + workspace="ws-123", + config_path=tmp_path / "absent.toml", + ) + assert cfg.api_key == "param-key" + + +def test_settings_beat_config_file(clean, tmp_path): + path = tmp_path / "config.toml" + path.write_text('api_key = "file-key"\nworkspace = "ws-file"\n') + settings.api_key = "settings-key" + cfg = resolve_config(config_path=path) + assert cfg.api_key == "settings-key" + assert cfg.workspace == "ws-file" + + +def test_region_lazy_env_and_setter(clean): + assert settings.region == "cn-beijing" + clean.setenv("DASHSCOPE_REGION", "ap-southeast-1") + assert settings.region == "ap-southeast-1" + settings.region = "eu-central-1" + assert settings.region == "eu-central-1" + settings.clear() + assert settings.region == "ap-southeast-1" + + +def test_region_setter_validation(clean): + with pytest.raises(ValueError, match="invalid region"): + settings.region = "moon-1" + + +def test_base_url_uses_settings_region(clean): + settings.workspace = "ws-123" + settings.region = "ap-southeast-1" + assert settings.base_url == ( + "https://ws-123.ap-southeast-1.maas.aliyuncs.com/api/v1" + ) + + +def test_setter_validation(clean): + with pytest.raises(ValueError, match="protocol"): + settings.protocol = "grpc" + with pytest.raises(ValueError, match="timeout"): + settings.timeout = 0 + with pytest.raises(ValueError, match="max_retries"): + settings.max_retries = -1 + + +def test_log_level_setter_applies_immediately(clean): + logger = logging.getLogger(ROOT_NAME) + settings.log_level = "debug" + assert logger.level == logging.DEBUG + assert settings.log_level == "debug" + with pytest.raises(ValueError, match="invalid log level"): + settings.log_level = "verbose" + + +def test_clear_reverts_log_level(clean): + logger = logging.getLogger(ROOT_NAME) + settings.log_level = "debug" + assert logger.level == logging.DEBUG + settings.clear() + assert logger.level == logging.WARNING + clean.setenv("DASHSCOPE_LOG", "info") + settings.log_level = "debug" + settings.clear() + assert logger.level == logging.INFO + + +def test_websocket_base_url_lazy_env(clean): + clean.setenv("DASHSCOPE_WEBSOCKET_BASE_URL", "wss://env.example.com/ws") + assert settings.websocket_base_url == "wss://env.example.com/ws" + settings.websocket_base_url = "wss://set.example.com/ws" + assert settings.websocket_base_url == "wss://set.example.com/ws" + + +def test_ws_session_uses_settings_url(clean): + from dashscope.v2.transport.ws import WsSession + + captured = {} + + class FakeSocket: + def settimeout(self, seconds): + pass + + def connector(url, headers): + captured["url"] = url + return FakeSocket() + + settings.websocket_base_url = "wss://set.example.com/inference" + WsSession(connector=connector) + assert captured["url"] == "wss://set.example.com/inference" + settings.clear() + # No override -> derived from the workspace-scoped MaaS gateway. + WsSession(workspace="ws-123", connector=connector) + assert captured["url"] == ( + "wss://ws-123.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference" + ) + # region= selects the regional gateway host. + WsSession( + workspace="ws-123", + region="ap-southeast-1", + connector=connector, + ) + assert captured["url"] == ( + "wss://ws-123.ap-southeast-1.maas.aliyuncs.com/api-ws/v1/inference" + ) + # No workspace and no override -> legacy shared gateway fallback. + with pytest.warns(UserWarning, match="legacy shared gateway"): + WsSession(connector=connector) + assert captured["url"] == ( + "wss://dashscope.aliyuncs.com/api-ws/v1/inference" + ) diff --git a/tests/v2/test_sse.py b/tests/v2/test_sse.py new file mode 100644 index 00000000..377955fe --- /dev/null +++ b/tests/v2/test_sse.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for unified SSE parsing (M2 task 2.4).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import ( + ContentFilterError, + DashScopeError, + RateLimitError, +) +from dashscope.v2.transport.sse import ( + parse_event_data, + stream_error, +) + +BASE_URL = "https://api.test.local/api/v1" + + +def _sse(events): + out = [] + for i, data in enumerate(events, 1): + payload = data if isinstance(data, str) else json.dumps(data) + out.append(f"id: {i}\ndata: {payload}\n\n") + return "".join(out) + + +def test_parse_event_data_rejects_non_object(): + with pytest.raises(DashScopeError, match="unexpected SSE payload"): + parse_event_data("[1, 2, 3]") + + +def test_parse_event_data_rejects_malformed_json(): + with pytest.raises(DashScopeError, match="malformed SSE payload"): + parse_event_data('{"code": "Throttling", "mess') + + +def test_stream_error_native_shape(): + error = stream_error({"code": "Throttling", "message": "slow down"}) + assert isinstance(error, RateLimitError) + assert error.code == "Throttling" + + +def test_stream_error_openai_shape(): + error = stream_error( + { + "error": { + "message": "bad request", + "type": "invalid_request_error", + "code": "model_not_found", + }, + }, + ) + assert isinstance(error, DashScopeError) + assert error.message == "bad request" + assert error.code == "model_not_found" + + +def test_stream_error_anthropic_shape(): + error = stream_error( + { + "type": "error", + "error": {"type": "overloaded_error", "message": "overloaded"}, + }, + ) + assert isinstance(error, DashScopeError) + assert error.message == "overloaded" + assert error.code == "overloaded_error" + + +def test_stream_error_normal_payload(): + assert stream_error({"choices": []}) is None + + +def test_stream_error_request_id_header_fallback(): + error = stream_error( + {"error": {"message": "boom", "type": "server_error"}}, + headers={"x-request-id": "req-from-header"}, + ) + assert error.request_id == "req-from-header" + + +def test_stream_error_body_request_id_wins_over_header(): + error = stream_error( + { + "code": "Throttling", + "message": "slow down", + "request_id": "req-from-body", + }, + headers={"x-request-id": "req-from-header"}, + ) + assert error.request_id == "req-from-body" + + +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 test_openai_stream_raises_on_error_event(): + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse( + [ + { + "id": "chatcmpl-1", + "choices": [ + {"index": 0, "delta": {"content": "Hel"}}, + ], + }, + { + "error": { + "message": "content filtered", + "type": "invalid_request_error", + "code": "data_inspection_failed", + }, + }, + ], + ), + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + iterator = iter(stream) + assert next(iterator).delta.content == "Hel" + with pytest.raises(DashScopeError, match="content filtered"): + next(iterator) + + +def test_anthropic_stream_raises_on_error_event(): + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse( + [ + { + "type": "message_start", + "message": {"id": "msg-1", "model": "qwen3-max"}, + }, + { + "type": "error", + "error": { + "type": "overloaded_error", + "message": "overloaded", + }, + }, + ], + ), + ) + + client = _make_client(handler, protocol="anthropic") + stream = client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + iterator = iter(stream) + assert next(iterator).request_id == "msg-1" + with pytest.raises(DashScopeError, match="overloaded"): + next(iterator) + + +def test_native_stream_business_error_maps_to_subclass(): + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse( + [ + {"code": "DataInspectionFailed", "message": "blocked"}, + ], + ), + ) + + client = _make_client(handler) + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + with pytest.raises(ContentFilterError): + next(iter(stream)) + + +def test_done_marker_ends_stream(): + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse( + [ + { + "id": "chatcmpl-1", + "choices": [ + {"index": 0, "delta": {"content": "Hi"}}, + ], + }, + "[DONE]", + ], + ), + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + assert list(stream.text_stream) == ["Hi"] + + +def test_stream_error_carries_response_request_id(): + def handler(request): + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "x-request-id": "req-stream-1", + }, + text=_sse( + [ + {"error": {"message": "boom", "type": "server_error"}}, + ], + ), + ) + + client = _make_client(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + with pytest.raises(DashScopeError) as exc_info: + next(iter(stream)) + assert exc_info.value.request_id == "req-stream-1" + + +_ANTHROPIC_KEEPALIVE_EVENTS = [ + {"type": "message_start", "message": {"id": "msg-1", "model": "m"}}, + {"type": "ping"}, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hi"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 1}, + }, + {"type": "message_stop"}, +] + + +def test_anthropic_stream_skips_keepalive_events(): + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse(_ANTHROPIC_KEEPALIVE_EVENTS), + ) + + client = _make_client(handler, protocol="anthropic") + stream = client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + chunks = list(stream) + # message_start, text delta, message_delta; ping / block start / + # block stop / message_stop carry nothing and are skipped. + assert len(chunks) == 3 + texts = [c.delta.content for c in chunks if c.delta and c.delta.content] + assert texts == ["Hi"] + + +_ANTHROPIC_THINKING_EVENTS = [ + {"type": "message_start", "message": {"id": "msg-1", "model": "m"}}, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "hmm"}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "sig"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "Hi"}, + }, + {"type": "content_block_stop", "index": 1}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 1}, + }, + {"type": "message_stop"}, +] + + +def test_anthropic_stream_aggregates_thinking_signature(): + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse(_ANTHROPIC_THINKING_EVENTS), + ) + + client = _make_client(handler, protocol="anthropic") + stream = client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + message = stream.get_final_message().choices[0].message + assert message.reasoning_content == "hmm" + assert message.reasoning_signature == "sig" + assert message.content == "Hi" + + +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) + + +@pytest.mark.asyncio +async def test_async_stream_error_carries_request_id_and_skips_ping(): + def handler(request): + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "x-request-id": "req-async-1", + }, + text=_sse( + [ + {"type": "ping"}, + { + "type": "error", + "error": { + "type": "overloaded_error", + "message": "overloaded", + }, + }, + ], + ), + ) + + client = _make_async_client(handler, protocol="anthropic") + stream = await client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + with pytest.raises(DashScopeError) as exc_info: + async for _ in stream: + pass + assert exc_info.value.request_id == "req-async-1" + await client.aclose() diff --git a/tests/v2/test_sse_comprehensive_verify.py b/tests/v2/test_sse_comprehensive_verify.py new file mode 100644 index 00000000..404b39b6 --- /dev/null +++ b/tests/v2/test_sse_comprehensive_verify.py @@ -0,0 +1,437 @@ +# -*- coding: utf-8 -*- +"""Comprehensive verification of SSE stream behavior (7 items + finding).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import ( + DashScopeError, + RateLimitError, +) + +BASE_URL = "https://api.test.local/api/v1" + + +def _sse(events, *, multiline=None): + """Build SSE text from event dicts. + + ``multiline`` is a list of (idx, raw_lines) tuples; for those event + indices the raw multi-line ``data:`` form is emitted instead. + """ + parts = [] + for i, data in enumerate(events): + if multiline and i in dict(multiline): + raw = dict(multiline)[i] + parts.append(f"id: {i}\n{raw}\n\n") + else: + payload = data if isinstance(data, str) else json.dumps(data) + parts.append(f"id: {i}\ndata: {payload}\n\n") + return "".join(parts) + + +def _make_sync(handler, protocol="openai"): + mock = httpx.MockTransport(handler) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + return Client( + api_key="sk-test", + protocol=protocol, + base_url=BASE_URL, + http_client=http_client, + ) + + +def _make_async(handler, protocol="openai"): + mock = httpx.MockTransport(handler) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + return AsyncClient( + api_key="sk-test", + protocol=protocol, + base_url=BASE_URL, + http_client=http_client, + ) + + +# ── 1. OpenAI stream: normal chunk + error event + x-request-id header ────── + + +def test_1_openai_stream_error_with_header_request_id(): + """Normal chunk delivered first; then DashScopeError with + request_id from x-request-id header, status_code=200.""" + + def handler(request): + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "x-request-id": "req-hdr-42", + }, + text=_sse( + [ + { + "id": "chatcmpl-1", + "choices": [ + {"index": 0, "delta": {"content": "Hello"}}, + ], + }, + { + "error": { + "message": "filtered", + "type": "invalid_request_error", + "code": "data_inspection_failed", + }, + }, + ], + ), + ) + + client = _make_sync(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + it = iter(stream) + + # First chunk delivered normally + first = next(it) + assert first.delta.content == "Hello" + + # Error event raises DashScopeError with header request_id + with pytest.raises(DashScopeError) as ei: + next(it) + err = ei.value + assert err.request_id == "req-hdr-42" + assert err.status_code == 200 + assert err.code == "data_inspection_failed" + assert "filtered" in err.message + + +# ── 2. Native stream: body request_id beats header x-request-id ───────────── + + +def test_2_native_stream_body_request_id_wins(): + """Throttling → RateLimitError; body request_id takes priority.""" + + def handler(request): + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "x-request-id": "req-hdr-99", + }, + text=_sse( + [ + { + "code": "Throttling", + "message": "slow down", + "request_id": "req-body-1", + }, + ], + ), + ) + + client = _make_sync(handler, protocol="native") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + with pytest.raises(RateLimitError) as ei: + next(iter(stream)) + err = ei.value + assert err.request_id == "req-body-1" + assert isinstance(err, RateLimitError) + + +# ── 3. Anthropic full event sequence: ping skip, 3 chunks, 0 empty ────────── + + +_ANTHROPIC_SEQUENCE = [ + { + "type": "message_start", + "message": { + "id": "msg-1", + "model": "qwen3-max", + "usage": {"input_tokens": 1}, + }, + }, + {"type": "ping"}, + {"type": "ping"}, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "Hi"}, + }, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"output_tokens": 1}, + }, + {"type": "message_stop"}, +] + + +def test_3_anthropic_full_sequence_3_chunks_no_empty(): + """Direct iteration yields exactly 3 chunks, 0 empty; + request_id preserved from message_start.""" + + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse(_ANTHROPIC_SEQUENCE), + ) + + client = _make_sync(handler, protocol="anthropic") + stream = client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + chunks = list(stream) + + # Exactly 3 chunks + assert len(chunks) == 3 + + # 0 empty chunks (all have either choices or usage or both) + for chunk in chunks: + has_content = bool(chunk.choices) or chunk.usage is not None + assert has_content, f"empty chunk found: {chunk!r}" + + # request_id preserved from message_start + assert chunks[0].request_id == "msg-1" + + # Chunk 0: message_start (empty choices, usage with input_tokens) + assert chunks[0].choices == [] + assert chunks[0].usage is not None + assert chunks[0].usage.input_tokens == 1 + + # Chunk 1: text_delta "Hi" + assert chunks[1].delta.content == "Hi" + + # Chunk 2: message_delta with finish_reason=stop (end_turn→stop) + assert chunks[2].choices[0].finish_reason == "stop" + assert chunks[2].usage.output_tokens == 1 + assert chunks[2].usage.total_tokens == 2 + + +# ── 4. text_stream + get_final_message() aggregation ──────────────────────── + + +def test_4_text_stream_and_get_final_message(): + """text_stream → ["Hi"]; get_final_message() aggregates correctly; + skipped events (ping, block_start, etc.) don't pollute.""" + + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse(_ANTHROPIC_SEQUENCE), + ) + + client = _make_sync(handler, protocol="anthropic") + + # -- text_stream -- + stream1 = client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + texts = list(stream1.text_stream) + assert texts == ["Hi"] + + # -- get_final_message() -- + stream2 = client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + final = stream2.get_final_message() + assert final.output_text == "Hi" + assert final.choices[0].finish_reason == "stop" + assert final.request_id == "msg-1" + assert final.usage is not None + assert final.usage.total_tokens == 2 + + +# ── 5. Truncated JSON → DashScopeError with JSONDecodeError as __cause__ ─── + + +def test_5_truncated_json_raises_dashscope_error(): + """Malformed SSE payload → DashScopeError; __cause__ is JSONDecodeError.""" + + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=_sse( + [ + # Truncated JSON (raw string, not dict — _sse will + # wrap it as-is since it's a str) + '{"code": "Throttling", "mess', + ], + ), + ) + + client = _make_sync(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + with pytest.raises(DashScopeError, match="malformed SSE payload") as ei: + next(iter(stream)) + # __cause__ preserved as JSONDecodeError + assert isinstance(ei.value.__cause__, json.JSONDecodeError) + + +# ── 6. Multi-line data: probe ─────────────────────────────────────────────── + + +def test_6a_multiline_data_valid_split_between_tokens(): + """Split between JSON tokens → valid after \\n join → parsed normally.""" + + def handler(request): + # Simulate proxy splitting between tokens: + # data: {"code": "Throttling" + # data: , "message": "slow"} + # After SSE join: {"code": "Throttling"\n, "message": "slow"} + # json.loads accepts \n between tokens. + raw_sse = ( + "id: 1\n" + 'data: {"code": "Throttling"\n' + 'data: , "message": "slow"}\n' + "\n" + ) + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=raw_sse, + ) + + client = _make_sync(handler, protocol="native") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + # Should raise RateLimitError (not DashScopeError for malformed) + with pytest.raises(RateLimitError) as ei: + next(iter(stream)) + assert ei.value.code == "Throttling" + + +def test_6b_multiline_data_bare_control_char_in_string(): + """Split inside a JSON string → bare \\x0a in string → malformed.""" + + def handler(request): + # Split inside a string value: + # data: {"code": "Thrott + # data: ling", "message": "slow"} + # After SSE join: {"code": "Thrott\nling", "message": "slow"} + # Bare \x0a inside a JSON string → JSONDecodeError + raw_sse = ( + "id: 1\n" + 'data: {"code": "Thrott\n' + 'data: ling", "message": "slow"}\n' + "\n" + ) + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=raw_sse, + ) + + client = _make_sync(handler, protocol="openai") + stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + with pytest.raises(DashScopeError, match="malformed SSE payload"): + next(iter(stream)) + + +# ── 7. Async path: ping + error event ─────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_7_async_stream_ping_skip_and_error(): + """0 chunks delivered (ping skipped, error raises); + request_id from header preserved.""" + + def handler(request): + return httpx.Response( + 200, + headers={ + "Content-Type": "text/event-stream", + "x-request-id": "req-async-7", + }, + text=_sse( + [ + {"type": "ping"}, + { + "error": { + "message": "overloaded", + "type": "overloaded_error", + }, + }, + ], + ), + ) + + client = _make_async(handler, protocol="anthropic") + stream = await client.chat.completions.create( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + with pytest.raises(DashScopeError) as ei: + async for chunk in stream: + # Should never yield — ping is skipped, error raises first + pytest.fail(f"unexpected chunk: {chunk!r}") + err = ei.value + assert err.request_id == "req-async-7" + assert err.message == "overloaded" + assert err.code == "overloaded_error" + + +# ── Finding: package root missing errors export ───────────────────────────── + + +def test_finding_package_root_errors_not_exported(): + """dashscope.errors is NOT directly accessible from the package root. + + Design doc §2.2 says ``dashscope.errors.ErrorCode`` should work, + but currently only ``dashscope.v2.errors`` is populated. + """ + import dashscope + + # v2 subpackage has errors — this works + assert hasattr(dashscope.v2, "errors") + assert hasattr(dashscope.v2.errors, "ErrorCode") + + # Package root does NOT export errors directly + assert not hasattr(dashscope, "errors"), ( + "dashscope.errors unexpectedly exists — " + "the finding may already be fixed" + ) + + # But ErrorCode IS available via v2.errors + from dashscope.v2.errors import ErrorCode + + assert ErrorCode.INVALID_API_KEY.value == "InvalidApiKey" diff --git a/tests/v2/test_tingwu.py b/tests/v2/test_tingwu.py new file mode 100644 index 00000000..d0853129 --- /dev/null +++ b/tests/v2/test_tingwu.py @@ -0,0 +1,356 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for client.tingwu (offline call + realtime session).""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.errors import DashScopeError + +BASE_URL = "https://api.test.local/api/v1" +CALL_PATH = "/api/v1/services/aigc/multimodal-generation/generation" + + +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_bare_client(**kwargs): + kwargs.setdefault("base_url", BASE_URL) + # WsSession derives the websocket endpoint from the workspace (the + # base_url escape hatch only covers HTTP), so the WS-based tests + # need a workspace too. + kwargs.setdefault("workspace", "ws-test") + return Client(api_key="sk-test", **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 FakeWebSocket: + """Scripted websocket stub: records sends, replays a queued inbox.""" + + def __init__(self, inbox=None): + self.sent = [] + self.inbox = list(inbox or []) + self.closed = False + + def send(self, payload, opcode=1): + assert opcode == (2 if isinstance(payload, bytes) else 1) + self.sent.append(payload) + + def recv(self): + if not self.inbox: + return None + return self.inbox.pop(0) + + def close(self): + self.closed = True + + def settimeout(self, seconds): + pass + + +def _event(name, payload=None): + return json.dumps( + { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload or {}, + }, + ) + + +def _sent_frames(ws): + return [json.loads(item) for item in ws.sent if isinstance(item, str)] + + +def test_call_posts_and_parses(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "output": {"TaskId": "t-1", "Result": {"summary": "好"}}, + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + client = _make_client(handler) + result = client.tingwu.call( + model="tingwu-turbo", + input={"source_language": "cn", "task": "summarization"}, + parameters={"maxEndSilence": 800}, + ) + assert captured["path"] == CALL_PATH + assert captured["body"] == { + "model": "tingwu-turbo", + "input": {"source_language": "cn", "task": "summarization"}, + "parameters": {"maxEndSilence": 800}, + } + assert result.request_id == "req-1" + assert result.is_error is False + assert result.output["Result"]["summary"] == "好" + assert result.usage.input_tokens == 10 + + +def test_call_business_error_flags_is_error(): + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-1", + "code": "InvalidParameter", + "message": "bad input", + }, + ) + + client = _make_client(handler, raise_on_error=False) + result = client.tingwu.call(model="tingwu-turbo", input={}) + assert result.is_error is True + assert result.code == "InvalidParameter" + + +def test_call_raises_by_default(): + def handler(request): + return httpx.Response( + 200, + json={"code": "InvalidParameter", "message": "bad input"}, + ) + + client = _make_client(handler) + with pytest.raises(DashScopeError): + client.tingwu.call(model="tingwu-turbo", input={}) + + +@pytest.mark.asyncio +async def test_async_call(): + def handler(request): + return httpx.Response( + 200, + json={"request_id": "req-1", "output": {"TaskId": "t-1"}}, + ) + + client = _make_async_client(handler) + result = await client.tingwu.call(model="tingwu-turbo", input={}) + assert result.output["TaskId"] == "t-1" + + +def test_realtime_session_lifecycle(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + { + "output": { + "action": "recognize-result", + "sentence": {"text": "你好"}, + }, + }, + ), + _event( + "result-generated", + {"output": {"action": "speech-end"}}, + ), + ], + ) + captured = {} + + def connector(url, headers): + captured["url"] = url + captured["headers"] = headers + return ws + + client = _make_bare_client() + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="app-1", + terminology="term-1", + connector=connector, + ) as session: + assert captured["url"].startswith( + "wss://ws-test.cn-beijing.maas.aliyuncs.com/", + ) + assert captured["headers"]["Authorization"] == "Bearer sk-test" + session.send_audio_frame(b"\x00\x01pcm") + session.stop() + events = list(session.events()) + + frames = _sent_frames(ws) + start = frames[0] + assert start["header"]["action"] == "run-task" + assert start["payload"]["task_group"] == "aigc" + assert start["payload"]["task"] == "multimodal-generation" + assert start["payload"]["function"] == "generation" + assert start["payload"]["model"] == "tingwu-turbo" + assert start["payload"]["input"] == { + "appId": "app-1", + "directive": "start", + } + assert start["payload"]["parameters"] == { + "format": "pcm", + "sampleRate": 16000, + "terminology": "term-1", + } + assert b"\x00\x01pcm" in ws.sent + finish = frames[-1] + assert finish["header"]["action"] == "finish-task" + assert finish["payload"]["input"] == { + "appId": "app-1", + "directive": "stop", + } + assert len(events) == 2 + assert events[0]["payload"]["output"]["action"] == "recognize-result" + + +def test_realtime_events_end_at_speech_end(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event("result-generated", {"output": {"action": "speech-end"}}), + ], + ) + client = _make_bare_client() + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="app-1", + connector=lambda url, headers: ws, + ) as session: + session.stop() + events = list(session.events()) + assert len(events) == 1 + + +def test_realtime_payload_task_failed_raises(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + { + "output": { + "action": "task-failed", + "errorCode": "InvalidParameter", + "errorMessage": "bad format", + }, + }, + ), + ], + ) + client = _make_bare_client() + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="app-1", + connector=lambda url, headers: ws, + ) as session: + with pytest.raises(DashScopeError, match="bad format") as exc_info: + list(session.events()) + # The request_id rides the frame header, like header-level task-failed. + assert exc_info.value.request_id == "r" + + +def test_realtime_parameters_escape_hatch(): + ws = FakeWebSocket( + [ + _event("task-started"), + _event("result-generated", {"output": {"action": "speech-end"}}), + ], + ) + client = _make_bare_client() + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="app-1", + terminology="term-1", + parameters={"format": "opus", "custom_key": 1}, + connector=lambda url, headers: ws, + ) as session: + session.stop() + list(session.events()) + start = _sent_frames(ws)[0] + # Base keys yield to the escape hatch; named kwargs win (asr rules). + assert start["payload"]["parameters"] == { + "format": "opus", + "sampleRate": 16000, + "custom_key": 1, + "terminology": "term-1", + } + + +def test_realtime_header_task_failed_raises(): + ws = FakeWebSocket( + [ + _event("task-started"), + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": "t", + "error_code": "InvalidParameter", + "error_message": "bad format", + }, + "payload": {}, + }, + ), + ], + ) + client = _make_bare_client() + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="app-1", + connector=lambda url, headers: ws, + ) as session: + with pytest.raises(DashScopeError, match="bad format"): + list(session.events()) + + +def test_realtime_enter_failure_closes_socket(): + # A task-failed first frame makes run_task raise inside __enter__; + # Python then skips __exit__, so the socket must already be closed + # and the with-body never entered. Guards against a leaked connection + # on the most common failure path (bad api_key / app_id / model). + ws = FakeWebSocket( + [ + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": "t", + "request_id": "r", + "error_code": "InvalidParameter", + "error_message": "bad app_id", + }, + "payload": {}, + }, + ), + ], + ) + client = _make_bare_client() + entered = False + with pytest.raises(DashScopeError, match="bad app_id"): + with client.tingwu.realtime( + model="tingwu-turbo", + app_id="app-1", + connector=lambda url, headers: ws, + ): + entered = True + assert entered is False + assert ws.closed is True diff --git a/tests/v2/test_tools_resources.py b/tests/v2/test_tools_resources.py new file mode 100644 index 00000000..94e33102 --- /dev/null +++ b/tests/v2/test_tools_resources.py @@ -0,0 +1,605 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for embeddings/rerank/tokenization (M1 task 1.7).""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from dashscope.v2 import AsyncClient, Client +from dashscope.v2.resources.embeddings import parse_embedding_response + +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 test_embeddings_create(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "output": { + "embeddings": [ + {"text_index": 0, "embedding": [0.1, 0.2]}, + {"text_index": 1, "embedding": [0.3, 0.4]}, + ], + }, + "usage": {"total_tokens": 6}, + "request_id": "req-e", + }, + ) + + client = _make_client(handler) + resp = client.embeddings.create( + model="text-embedding-v4", + input=["a", "b"], + text_type="document", + dimension=1024, + ) + assert ( + captured["path"] + == "/api/v1/services/embeddings/text-embedding/text-embedding" + ) + body = captured["body"] + assert body["input"]["texts"] == ["a", "b"] + assert body["parameters"]["text_type"] == "document" + assert body["parameters"]["dimension"] == 1024 + assert len(resp.embeddings) == 2 + assert resp.embeddings[1].embedding == [0.3, 0.4] + assert resp.usage.total_tokens == 6 + assert resp.request_id == "req-e" + + +def test_embeddings_accepts_single_string(): + def handler(request): + body = json.loads(request.content) + assert body["input"]["texts"] == ["only"] + return httpx.Response(200, json={"output": {"embeddings": []}}) + + client = _make_client(handler) + client.embeddings.create(model="m", input="only") + + +def test_embeddings_empty_input_rejected(): + client = _make_client(lambda r: httpx.Response(200)) + with pytest.raises(ValueError, match="input"): + client.embeddings.create(model="m", input=[]) + + +def test_multimodal_embeddings_create(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + captured["oss"] = request.headers.get("X-DashScope-OssResourceResolve") + return httpx.Response( + 200, + json={ + "output": { + "embeddings": [ + {"index": 0, "type": "text", "embedding": [0.1]}, + {"index": 1, "type": "image", "embedding": [0.2]}, + ], + }, + "usage": {"input_tokens": 42}, + "request_id": "req-mm", + }, + ) + + client = _make_client(handler) + resp = client.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[ + {"text": "hello", "factor": 1.0}, + {"image": "oss://bucket/img.png"}, + ], + dimension=1024, + fps=0.5, + ) + assert captured["path"] == ( + "/api/v1/services/embeddings/" + "multimodal-embedding/multimodal-embedding" + ) + # An oss:// input opts the request into server-side resolution. + assert captured["oss"] == "enable" + body = captured["body"] + assert body["input"]["contents"][0] == {"text": "hello", "factor": 1.0} + assert body["input"]["contents"][1] == {"image": "oss://bucket/img.png"} + assert body["parameters"]["dimension"] == 1024 + assert body["parameters"]["fps"] == 0.5 + assert len(resp.embeddings) == 2 + assert resp.embeddings[1].type == "image" + assert resp.usage is not None and resp.usage.input_tokens == 42 + assert resp.request_id == "req-mm" + + +def test_multimodal_embeddings_plain_urls_skip_the_oss_header(): + captured = {} + + def handler(request): + captured["oss"] = request.headers.get("X-DashScope-OssResourceResolve") + return httpx.Response( + 200, + json={ + "output": { + "embeddings": [{"index": 0, "embedding": [0.1]}], + }, + "request_id": "req-mm", + }, + ) + + client = _make_client(handler) + client.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[{"image": "https://example.test/img.png"}], + ) + assert captured["oss"] is None + + +def test_multimodal_embeddings_one_peace_singular_vector(): + # one-peace-v1 predates the plural list: one fused vector arrives as + # output.embedding and must surface as item 0 of response.embeddings. + def handler(request): + return httpx.Response( + 200, + json={ + "output": {"embedding": [0.1, 0.2, 0.3]}, + "usage": {"total_tokens": 10}, + "request_id": "req-one-peace", + }, + ) + + client = _make_client(handler) + resp = client.multimodal.embeddings.create( + model="multimodal-embedding-one-peace-v1", + input=[{"image": "https://example.test/img.png"}], + ) + assert len(resp.embeddings) == 1 + assert resp.embeddings[0].index == 0 + assert resp.embeddings[0].embedding == [0.1, 0.2, 0.3] + assert resp.usage is not None and resp.usage.total_tokens == 10 + assert resp.request_id == "req-one-peace" + + +def test_embedding_response_plural_wins_over_singular(): + body = { + "output": { + "embeddings": [{"index": 0, "type": "text", "embedding": [0.5]}], + "embedding": [0.9], + }, + "request_id": "req-both", + } + resp = parse_embedding_response(body) + assert len(resp.embeddings) == 1 + assert resp.embeddings[0].embedding == [0.5] + + +def test_multimodal_embeddings_validation(): + client = _make_client(lambda r: httpx.Response(200)) + with pytest.raises(ValueError, match="input"): + client.multimodal.embeddings.create(model="m", input=[]) + with pytest.raises(ValueError, match="text/image/audio/video"): + client.multimodal.embeddings.create(model="m", input=[{"factor": 1}]) + + +def test_embeddings_batch_jobs_cycle(): + calls = [] + + def handler(request): + calls.append((request.method, request.url.path)) + if request.method == "POST": + assert request.headers.get("X-DashScope-Async") == "enable" + body = json.loads(request.content) + assert body["input"]["url"] == "https://example.test/data.jsonl" + assert body["parameters"]["text_type"] == "query" + return httpx.Response( + 200, + json={ + "output": {"task_id": "task-b", "task_status": "PENDING"}, + "request_id": "req-b", + }, + ) + status = "RUNNING" if len(calls) < 3 else "SUCCEEDED" + return httpx.Response( + 200, + json={ + "output": { + "task_id": "task-b", + "task_status": status, + "results": {"url": "https://example.test/out.jsonl"}, + }, + "usage": {"total_tokens": 9}, + }, + ) + + client = _make_client(handler) + job = client.embeddings.jobs.create( + model="text-embedding-async-v2", + file_url="https://example.test/data.jsonl", + text_type="query", + ) + assert job.task_id == "task-b" + delays = [] + done = client.embeddings.jobs.wait( + job, + poll_interval=0.01, + _sleep=delays.append, + ) + assert done.succeeded + assert done.output["results"]["url"] == "https://example.test/out.jsonl" + assert delays == [0.01, 0.02] + assert calls[0] == ( + "POST", + "/api/v1/services/embeddings/text-embedding/text-embedding", + ) # noqa: E501 + assert calls[1] == ("GET", "/api/v1/tasks/task-b") + + +def test_embeddings_batch_jobs_require_file_url(): + client = _make_client(lambda r: httpx.Response(200)) + with pytest.raises(ValueError, match="file_url"): + client.embeddings.jobs.create(model="m", file_url="") + + +def test_embeddings_batch_jobs_cancel_and_list(): + calls = [] + + def handler(request): + calls.append((request.method, request.url.path)) + if request.method == "POST": + return httpx.Response( + 200, + json={ + "output": {"task_id": "task-b", "task_status": "CANCELED"}, + "request_id": "req-b", + }, + ) + return httpx.Response( + 200, + json={ + "request_id": "req-b", + "total": 1, + "page_no": 1, + "page_size": 10, + "tasks": [{"task_id": "task-b", "task_status": "CANCELED"}], + }, + ) + + client = _make_client(handler) + canceled = client.embeddings.jobs.cancel("task-b") + assert canceled.status == "CANCELED" + result = client.embeddings.jobs.list(status="CANCELED") + assert result.total == 1 + assert result.tasks[0].task_id == "task-b" + assert calls == [ + ("POST", "/api/v1/tasks/task-b/cancel"), + ("GET", "/api/v1/tasks"), + ] + + +def test_rerank_create(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "output": { + "results": [ + {"index": 1, "relevance_score": 0.9}, + {"index": 0, "relevance_score": 0.2}, + ], + }, + "usage": {"total_tokens": 12}, + "request_id": "req-r", + }, + ) + + client = _make_client(handler) + resp = client.rerank.create( + model="gte-rerank-v2", + query="What is text retrieval", + documents=["Document one", "Document two"], + top_n=2, + return_documents=False, + ) + assert ( + captured["path"] == "/api/v1/services/rerank/text-rerank/text-rerank" + ) + assert captured["body"]["input"]["query"] == "What is text retrieval" + assert captured["body"]["parameters"]["top_n"] == 2 + assert resp.results[0].index == 1 + assert resp.results[0].relevance_score == 0.9 + assert resp.usage.total_tokens == 12 + + +def test_rerank_validation(): + client = _make_client(lambda r: httpx.Response(200)) + with pytest.raises(ValueError, match="query"): + client.rerank.create(model="m", query="", documents=["a"]) + with pytest.raises(ValueError, match="documents"): + client.rerank.create(model="m", query="q", documents=[]) + + +def test_tokenization_count_prompt(): + captured = {} + + def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={"usage": {"input_tokens": 7}, "request_id": "req-t"}, + ) + + client = _make_client(handler) + resp = client.tokenization.count(model="qwen-plus", prompt="hello world") + assert captured["path"] == "/api/v1/tokenizer" + assert captured["body"]["input"]["prompt"] == "hello world" + assert resp.input_tokens == 7 + assert resp.request_id == "req-t" + + +def test_tokenization_count_messages(): + def handler(request): + body = json.loads(request.content) + assert body["input"]["messages"] == [{"role": "user", "content": "hi"}] + return httpx.Response(200, json={"usage": {"input_tokens": 3}}) + + client = _make_client(handler) + resp = client.tokenization.count( + model="qwen-plus", + messages=[{"role": "user", "content": "hi"}], + ) + assert resp.input_tokens == 3 + + +def test_tokenization_count_returns_token_ids_and_tokens(): + def handler(request): + return httpx.Response( + 200, + json={ + "output": { + "token_ids": [115798, 198], + "tokens": ["hello", "\n"], + }, + "usage": {"input_tokens": 2}, + "request_id": "req-tok", + }, + ) + + client = _make_client(handler) + resp = client.tokenization.count(model="qwen-plus", prompt="hello\n") + assert resp.input_tokens == 2 + assert resp.token_ids == [115798, 198] + assert resp.tokens == ["hello", "\n"] + assert resp.request_id == "req-tok" + + +def test_tokenization_requires_input(): + client = _make_client(lambda r: httpx.Response(200)) + with pytest.raises(ValueError, match="prompt or messages"): + client.tokenization.count(model="m") + + +def test_embeddings_instruct(): + def handler(request): + body = json.loads(request.content) + assert body["parameters"]["instruct"] == "given a query" + return httpx.Response(200, json={"output": {"embeddings": []}}) + + client = _make_client(handler) + client.embeddings.create(model="m", input="x", instruct="given a query") + + +def test_rerank_instruct(): + def handler(request): + body = json.loads(request.content) + assert body["parameters"]["instruct"] == "rank by recency" + return httpx.Response(200, json={"output": {"results": []}}) + + client = _make_client(handler) + client.rerank.create( + model="m", + query="q", + documents=["a"], + instruct="rank by recency", + ) + + +def test_tokenization_merges_prompt_into_messages(): + def handler(request): + body = json.loads(request.content) + assert body["input"]["messages"] == [ + {"role": "user", "content": "hi"}, + {"role": "user", "content": "and this prompt"}, + ] + return httpx.Response(200, json={"usage": {"input_tokens": 9}}) + + client = _make_client(handler) + messages = [{"role": "user", "content": "hi"}] + resp = client.tokenization.count( + model="qwen-plus", + messages=messages, + prompt="and this prompt", + ) + assert resp.input_tokens == 9 + assert messages == [{"role": "user", "content": "hi"}] + + +@pytest.mark.asyncio +async def test_async_resources(): + def handler(request): + if request.url.path.endswith("text-embedding"): + return httpx.Response( + 200, + json={ + "output": { + "embeddings": [{"text_index": 0, "embedding": [1.0]}], + }, + }, + ) + if request.url.path.endswith("text-rerank"): + return httpx.Response( + 200, + json={ + "output": { + "results": [{"index": 0, "relevance_score": 0.5}], + }, + }, + ) + return httpx.Response(200, json={"usage": {"input_tokens": 2}}) + + client = _make_async_client(handler) + emb = await client.embeddings.create(model="m", input="x") + assert emb.embeddings[0].embedding == [1.0] + rr = await client.rerank.create(model="m", query="q", documents=["d"]) + assert rr.results[0].relevance_score == 0.5 + tok = await client.tokenization.count(model="m", prompt="p") + assert tok.input_tokens == 2 + await client.aclose() + + +@pytest.mark.asyncio +async def test_async_embeddings_multimodal_and_batch(): + def handler(request): + if request.url.path.endswith("multimodal-embedding"): + body = json.loads(request.content) + assert body["input"]["contents"] == [{"text": "hi"}] + return httpx.Response( + 200, + json={ + "output": { + "embeddings": [ + {"index": 0, "type": "text", "embedding": [0.5]}, + ], + }, + }, + ) + if request.method == "POST": + return httpx.Response( + 200, + json={ + "output": {"task_id": "task-a", "task_status": "PENDING"}, + }, + ) + return httpx.Response( + 200, + json={ + "output": {"task_id": "task-a", "task_status": "SUCCEEDED"}, + }, + ) + + client = _make_async_client(handler) + mm = await client.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[{"text": "hi"}], + ) + assert mm.embeddings[0].embedding == [0.5] + job = await client.embeddings.jobs.create( + model="text-embedding-async-v2", + file_url="https://example.test/data.jsonl", + ) + delays = [] + + async def fake_sleep(delay): + delays.append(delay) + + done = await client.embeddings.jobs.wait( + job, + poll_interval=0.01, + _sleep=fake_sleep, + ) + assert done.succeeded + assert delays == [0.01] + await client.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 + # same thing here as it does on chat and apps. + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-x", + "code": "InvalidParameter", + "message": "bad param", + "output": {}, + "usage": {}, + }, + ) + + client = _make_client(handler, raise_on_error=False) + resp = client.embeddings.create(model="m", input=["a"]) + assert resp.is_error + assert resp.code == "InvalidParameter" + assert resp.request_id == "req-x" + + resp = client.multimodal.embeddings.create( + model="m", + input=[{"text": "a"}], + ) + assert resp.is_error + assert resp.code == "InvalidParameter" + + resp = client.rerank.create(model="m", query="q", documents=["d"]) + assert resp.is_error + assert resp.code == "InvalidParameter" + + resp = client.tokenization.count(model="m", prompt="p") + assert resp.is_error + assert resp.code == "InvalidParameter" + + +@pytest.mark.asyncio +async def test_async_business_code_reaches_tool_responses(): + def handler(request): + return httpx.Response( + 200, + json={ + "request_id": "req-x", + "code": "InvalidParameter", + "message": "bad param", + "output": {}, + "usage": {}, + }, + ) + + client = _make_async_client(handler, raise_on_error=False) + resp = await client.embeddings.create(model="m", input="a") + assert resp.is_error and resp.code == "InvalidParameter" + resp = await client.multimodal.embeddings.create( + model="m", + input=[{"text": "a"}], + ) + assert resp.is_error and resp.code == "InvalidParameter" + resp = await client.rerank.create(model="m", query="q", documents=["d"]) + assert resp.is_error and resp.code == "InvalidParameter" + resp = await client.tokenization.count(model="m", prompt="p") + assert resp.is_error and resp.code == "InvalidParameter" + await client.aclose() diff --git a/tests/v2/test_transport.py b/tests/v2/test_transport.py new file mode 100644 index 00000000..88eff8bf --- /dev/null +++ b/tests/v2/test_transport.py @@ -0,0 +1,585 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the dashscope 2.0 transport layer (M1 task 1.2).""" + +# pytest fixture injection looks like unused/redefined names to pylint. +# pylint: disable=redefined-outer-name,unused-argument + +from __future__ import annotations + +import httpx +import pytest + +from dashscope.v2.errors import ( + APIConnectionError, + APITimeoutError, + BadRequestError, + RateLimitError, +) +from dashscope.v2.transport import AsyncTransport, SyncTransport +from dashscope.v2.transport import http as transport_http +from dashscope.v2.transport._ident import SDK_CLIENT_VALUE +from dashscope.v2.transport._retry import backoff_delay, retry_after_seconds + +BASE_URL = "https://api.test.local" + + +class _Recorder: + def __init__(self): + self.calls = 0 + self.sleeps = [] + self.requests = [] + + def handler(self, responses): + def _handle(request: httpx.Request) -> httpx.Response: + self.requests.append(request) + self.calls += 1 + if self.calls <= len(responses): + item = responses[self.calls - 1] + if isinstance(item, Exception): + raise item + status, headers = item + return httpx.Response(status, headers=headers) + return httpx.Response(200) + + return _handle + + +def _make_sync(recorder, responses, **kwargs): + mock = httpx.MockTransport(recorder.handler(responses)) + http_client = httpx.Client(transport=mock, base_url=BASE_URL) + return SyncTransport(base_url=BASE_URL, http_client=http_client, **kwargs) + + +@pytest.fixture +def no_sleep(monkeypatch): + sleeps = [] + monkeypatch.setattr(transport_http, "_sleep", sleeps.append) + monkeypatch.setattr( + transport_http, + "_asleep", + _fake_asleep(sleeps), + ) + return sleeps + + +def _fake_asleep(sleeps): + async def _asleep(s): + sleeps.append(s) + + return _asleep + + +def test_success_first_try(no_sleep): + rec = _Recorder() + t = _make_sync(rec, []) + resp = t.request("POST", "/chat", json={"a": 1}) + assert resp.status_code == 200 + assert rec.calls == 1 + assert no_sleep == [] + + +def test_retry_on_429_then_success(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [(429, {}), (503, {})]) + resp = t.request("GET", "/models") + assert resp.status_code == 200 + assert rec.calls == 3 + assert len(no_sleep) == 2 + + +def test_retry_after_header_honored(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [(429, {"retry-after": "3"})]) + resp = t.request("GET", "/models") + assert resp.status_code == 200 + assert no_sleep == [3.0] + + +def test_retry_after_ms_header(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [(429, {"retry-after-ms": "250"})]) + t.request("GET", "/models") + assert no_sleep == [0.25] + + +def test_non_retryable_status_not_retried(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [(400, {})]) + resp = t.request("GET", "/x") + assert resp.status_code == 400 + assert rec.calls == 1 + + +def test_x_should_retry_override(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [(500, {"x-should-retry": "false"})]) + resp = t.request("GET", "/x") + assert resp.status_code == 500 + assert rec.calls == 1 + + rec2 = _Recorder() + t2 = _make_sync(rec2, [(400, {"x-should-retry": "true"}), (400, {})]) + resp2 = t2.request("GET", "/x") + assert resp2.status_code == 400 + assert rec2.calls == 2 + + +def test_connection_error_retried_then_raises(no_sleep): + rec = _Recorder() + errors = [httpx.ConnectError("boom")] * 3 + t = _make_sync(rec, errors, max_retries=2) + with pytest.raises(APIConnectionError) as exc_info: + t.request("GET", "/x") + assert isinstance(exc_info.value.__cause__, httpx.ConnectError) + assert rec.calls == 3 + assert len(no_sleep) == 2 + + +def test_timeout_maps_to_api_timeout_error(no_sleep): + rec = _Recorder() + t = _make_sync( + rec, + [httpx.ConnectTimeout("slow")] * 3, + max_retries=2, + ) + with pytest.raises(APITimeoutError) as exc_info: + t.request("GET", "/x") + assert isinstance(exc_info.value, APIConnectionError) + assert isinstance(exc_info.value.__cause__, httpx.ConnectTimeout) + assert rec.calls == 3 + + +def test_open_stream_transport_error_mapped(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [httpx.ConnectError("boom")], max_retries=2) + with pytest.raises(APIConnectionError): + t.open_stream("POST", "/x") + # Streams are never retried. + assert rec.calls == 1 + + +def test_open_stream_retryable_status_not_retried(no_sleep): + # Contract relied on by examples/04_real_world_scenarios/ + # production_ready_rag_harness.py: a retryable status on a streaming + # call raises after a single attempt -- the application, not the + # SDK, owns stream backoff and failover. + rec = _Recorder() + responses = [(429, {"retry-after": "3"}), (200, {})] + t = _make_sync(rec, responses, max_retries=3) + with pytest.raises(RateLimitError): + t.open_stream("POST", "/x", json={"a": 1}) + assert rec.calls == 1 + assert no_sleep == [] + + +@pytest.mark.asyncio +async def test_open_stream_retryable_status_not_retried_async(no_sleep): + rec = _Recorder() + responses = [(429, {"retry-after": "3"}), (200, {})] + mock = httpx.MockTransport(rec.handler(responses)) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + t = AsyncTransport( + base_url=BASE_URL, + http_client=http_client, + max_retries=3, + ) + with pytest.raises(RateLimitError): + await t.open_stream("POST", "/x", json={"a": 1}) + assert rec.calls == 1 + assert no_sleep == [] + await t.aclose() + + +_SSE_ERROR_BODY = ( + "event:error\n" + 'data:{"code":"InvalidParameter","message":"bad combo",' + '"request_id":"sse-req-1"}' +) + + +def _sse_400_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + content=_SSE_ERROR_BODY.encode("utf-8"), + headers={"content-type": "text/event-stream"}, + ) + + +def test_open_stream_sse_error_body_parsed(no_sleep): + # A rejected streaming request returns the business error as an + # SSE frame on the non-2xx response; open_stream must surface the + # parsed error, not the raw frame text. + http_client = httpx.Client( + transport=httpx.MockTransport(_sse_400_handler), + base_url=BASE_URL, + ) + t = SyncTransport(base_url=BASE_URL, http_client=http_client) + with pytest.raises(BadRequestError) as ei: + t.open_stream("POST", "/x", json={"a": 1}) + assert ei.value.code == "InvalidParameter" + assert ei.value.message == "bad combo" + assert ei.value.request_id == "sse-req-1" + + +@pytest.mark.asyncio +async def test_open_stream_sse_error_body_parsed_async(no_sleep): + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(_sse_400_handler), + base_url=BASE_URL, + ) + t = AsyncTransport(base_url=BASE_URL, http_client=http_client) + with pytest.raises(BadRequestError) as ei: + await t.open_stream("POST", "/x", json={"a": 1}) + assert ei.value.code == "InvalidParameter" + assert ei.value.message == "bad combo" + assert ei.value.request_id == "sse-req-1" + await http_client.aclose() + + +def test_max_retries_zero(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [(503, {})], max_retries=0) + resp = t.request("GET", "/x") + assert resp.status_code == 503 + assert rec.calls == 1 + + +def test_exhausted_retries_returns_last_response(no_sleep): + rec = _Recorder() + t = _make_sync(rec, [(502, {}), (502, {})], max_retries=1) + resp = t.request("GET", "/x") + assert resp.status_code == 502 + assert rec.calls == 2 + + +def test_default_headers(no_sleep): + rec = _Recorder() + mock = httpx.MockTransport(rec.handler([])) + http_client = httpx.Client( + transport=mock, + base_url=BASE_URL, + headers={ + "Authorization": "Bearer sk-test", + "X-DashScope-WorkSpace": "ws-1", + "User-Agent": "ua-test", + }, + ) + t = SyncTransport(base_url=BASE_URL, http_client=http_client) + t.request("GET", "/x") + sent = rec.requests[0].headers + assert sent["authorization"] == "Bearer sk-test" + assert sent["x-dashscope-workspace"] == "ws-1" + assert sent["user-agent"] == "ua-test" + + +def test_build_headers_and_timeout(): + from dashscope.v2.transport.http import _build_headers, _resolve_timeout + + headers = _build_headers("sk-1", "ws-1", "ua") + assert headers == { + "Authorization": "Bearer sk-1", + "X-DashScope-WorkSpace": "ws-1", + "User-Agent": "ua", + } + timeout = _resolve_timeout(30.0, 300.0) + assert timeout.read == 30.0 + assert timeout.connect == 5.0 + default = _resolve_timeout(None, 300.0) + assert default.read == 300.0 + + +def test_sdk_client_header_format(): + # client/version/module, matching the 1.x x-dashscope-sdk-client form + # but with the v2 generation marker ("2") suffixed on the module so v2 + # traffic (dashscope2) stays separable from 1.x (dashscope) in one package. + from dashscope.version import __version__ + + client, version, module = SDK_CLIENT_VALUE.split("/") + assert client == "python-sdk" + assert version == __version__ + assert module == "dashscope2" + + +def test_sdk_headers_disabled(monkeypatch): + # DASHSCOPE_DISABLE_SDK_HEADERS omits both headers (1.x parity); the + # env var is re-read on every request. + monkeypatch.setenv("DASHSCOPE_DISABLE_SDK_HEADERS", "1") + rec = _Recorder() + t = _make_sync(rec, []) + t.request("GET", "/x") + sent = rec.requests[0].headers + assert "x-dashscope-sdk-client" not in sent + assert "x-dashscope-sdk-session-id" not in sent + + +def test_sdk_identification_headers(no_sleep): + # Every request identifies the SDK and the process session so + # server-side access logs can group traffic; the headers ride along + # even through a caller-provided http_client. + rec = _Recorder() + t = _make_sync(rec, []) + t.request("GET", "/x") + sent = rec.requests[0].headers + assert sent["x-dashscope-sdk-client"] == SDK_CLIENT_VALUE + session_id = sent["x-dashscope-sdk-session-id"] + assert len(session_id) == 32 # uuid4().hex + + # Same process: another transport reuses the same session id. + rec2 = _Recorder() + t2 = _make_sync(rec2, []) + t2.request("GET", "/x") + assert rec2.requests[0].headers["x-dashscope-sdk-session-id"] == session_id + + +def test_sdk_headers_overridden_by_caller(no_sleep): + rec = _Recorder() + t = _make_sync(rec, []) + t.request( + "GET", + "/x", + headers={"x-dashscope-sdk-session-id": "my-session"}, + ) + sent = rec.requests[0].headers + assert sent["x-dashscope-sdk-session-id"] == "my-session" + assert sent["x-dashscope-sdk-client"] == SDK_CLIENT_VALUE + + +def test_sdk_module_in_client_value(no_sleep): + # Resources tag requests with their functional area; the tag becomes + # the third segment of x-dashscope-sdk-client, suffixed with the v2 + # generation marker ("audio" -> "audio2"). + from dashscope.version import __version__ + + rec = _Recorder() + t = _make_sync(rec, []) + t.request("GET", "/x", sdk_module="audio") + sent = rec.requests[0].headers + assert sent["x-dashscope-sdk-client"] == f"python-sdk/{__version__}/audio2" + + +def test_sdk_headers_overridden_case_insensitively(no_sleep): + # A caller header wins regardless of case, without emitting a + # duplicate x-dashscope-sdk-* entry. + rec = _Recorder() + t = _make_sync(rec, []) + t.request( + "GET", + "/x", + headers={"X-DashScope-SDK-Session-Id": "my-session"}, + ) + sent = rec.requests[0].headers + assert sent["x-dashscope-sdk-session-id"] == "my-session" + assert len(sent.get_list("x-dashscope-sdk-session-id")) == 1 + assert sent["x-dashscope-sdk-client"] == SDK_CLIENT_VALUE + + +def test_realtime_session_sends_sdk_headers(monkeypatch): + # Omni realtime sessions emit SDK identification (tagged "audio") on + # the websocket handshake; the kill switch applies here too. + from types import SimpleNamespace + + from dashscope.v2.resources.audio.realtime import RealtimeSession + from dashscope.v2.transport._ident import ( + sdk_client_value, + sdk_session_id, + ) + + captured = {} + + class FakeSocket: + def settimeout(self, seconds): + pass + + def close(self): + pass + + def connector(url, headers): + captured.clear() + captured.update(headers) + return FakeSocket() + + client = SimpleNamespace( + config=SimpleNamespace( + api_key="sk-1", + workspace=None, + region=None, + timeout=None, + ), + ) + with RealtimeSession( + client, + model="m-1", + ws_url="wss://api.test.local/realtime", + connector=connector, + ): + pass + assert captured["x-dashscope-sdk-client"] == sdk_client_value("audio") + # Same process-wide id as HTTP/websocket requests, not a fresh uuid. + assert captured["x-dashscope-sdk-session-id"] == sdk_session_id() + + monkeypatch.setenv("DASHSCOPE_DISABLE_SDK_HEADERS", "1") + with RealtimeSession( + client, + model="m-1", + ws_url="wss://api.test.local/realtime", + connector=connector, + ): + pass + assert "x-dashscope-sdk-client" not in captured + assert "x-dashscope-sdk-session-id" not in captured + + +def test_sdk_headers_on_streams(no_sleep): + rec = _Recorder() + t = _make_sync(rec, []) + t.open_stream("GET", "/x").close() + sent = rec.requests[0].headers + assert sent["x-dashscope-sdk-client"] == SDK_CLIENT_VALUE + assert "x-dashscope-sdk-session-id" in sent + + +@pytest.mark.asyncio +async def test_async_sdk_identification_headers(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") + first = rec.requests[0].headers + assert first["x-dashscope-sdk-client"] == SDK_CLIENT_VALUE + assert len(first["x-dashscope-sdk-session-id"]) == 32 + + response = await t.open_stream("GET", "/x") + await response.aclose() + second = rec.requests[1].headers + assert ( + second["x-dashscope-sdk-session-id"] + == first["x-dashscope-sdk-session-id"] + ) + await t.aclose() + + +def test_sdk_session_id_cached_per_process(): + from dashscope.common import _sdk_session + from dashscope.v2.transport import _ident + + original = _sdk_session._session_id + try: + first = _ident.sdk_session_id() + assert _ident.sdk_session_id() == first + # A fresh interpreter run gets a new id; simulate it by clearing + # the process cache. + _sdk_session._session_id = None + assert _ident.sdk_session_id() != first + finally: + # Restore so later tests in the same run keep one stable id. + _sdk_session._session_id = original + + +def test_ws_session_sends_sdk_headers(): + from dashscope.v2.transport._ident import sdk_session_id + from dashscope.v2.transport.ws import WsSession + + captured = {} + + class FakeSocket: + def settimeout(self, seconds): + pass + + def connector(url, headers): + captured.clear() + captured.update(headers) + return FakeSocket() + + WsSession(api_key="sk-1", workspace="ws-1", connector=connector) + assert captured["x-dashscope-sdk-client"] == SDK_CLIENT_VALUE + # Websockets share the process session id used by HTTP requests. + assert captured["x-dashscope-sdk-session-id"] == sdk_session_id() + + # Caller-provided headers win over the SDK defaults. + WsSession( + api_key="sk-1", + workspace="ws-1", + headers={"x-dashscope-sdk-session-id": "my-session"}, + connector=connector, + ) + assert captured["x-dashscope-sdk-session-id"] == "my-session" + assert captured["x-dashscope-sdk-client"] == SDK_CLIENT_VALUE + + +def test_request_keeps_client_default_timeout(no_sleep): + captured = [] + + def handler(request): + captured.append(request.extensions.get("timeout")) + return httpx.Response(200) + + mock = httpx.MockTransport(handler) + http_client = httpx.Client( + transport=mock, + base_url=BASE_URL, + timeout=httpx.Timeout(30.0, connect=5.0), + ) + t = SyncTransport(base_url=BASE_URL, http_client=http_client) + + t.request("GET", "/x") + assert captured[0] == { + "connect": 5.0, + "pool": 30.0, + "read": 30.0, + "write": 30.0, + } + + t.request("GET", "/x", timeout=1.0) + assert captured[1]["read"] == 1.0 + + t.open_stream("GET", "/x").close() + assert captured[2]["read"] == 30.0 + + +@pytest.mark.asyncio +async def test_async_retry(no_sleep): + rec = _Recorder() + mock = httpx.MockTransport(rec.handler([(429, {}), (500, {})])) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + t = AsyncTransport(base_url=BASE_URL, http_client=http_client) + resp = await t.request("GET", "/models") + assert resp.status_code == 200 + assert rec.calls == 3 + await t.aclose() + + +@pytest.mark.asyncio +async def test_async_connection_error(no_sleep): + rec = _Recorder() + mock = httpx.MockTransport( + rec.handler([httpx.ConnectError("boom")] * 3), + ) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + t = AsyncTransport( + base_url=BASE_URL, + http_client=http_client, + max_retries=1, + ) + with pytest.raises(APIConnectionError) as exc_info: + await t.request("GET", "/x") + assert isinstance(exc_info.value.__cause__, httpx.ConnectError) + assert rec.calls == 2 + await t.aclose() + + +def test_retry_after_parsing(): + assert retry_after_seconds({"retry-after": "2"}) == 2.0 + assert retry_after_seconds({"retry-after-ms": "1500"}) == 1.5 + assert retry_after_seconds({"retry-after": "not-a-number"}) is None + assert retry_after_seconds({}) is None + + +def test_backoff_delay_bounds(): + d0 = backoff_delay(0, None) + assert 0.5 <= d0 <= 0.5 * 1.25 + d10 = backoff_delay(10, None) + assert d10 <= 8.0 * 1.25 + assert backoff_delay(0, {"retry-after": "2"}) == 2.0 + assert backoff_delay(0, {"retry-after": "999"}) == 8.0 diff --git a/tests/v2/test_types_messages.py b/tests/v2/test_types_messages.py new file mode 100644 index 00000000..e28c0cc1 --- /dev/null +++ b/tests/v2/test_types_messages.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the canonical message model (M1 task 1.5).""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from dashscope.v2.types import ( + AudioPart, + ImagePart, + Message, + TextPart, + VideoPart, + extract_text, + normalize_messages, +) +from dashscope.v2.types.messages import has_media_parts + + +def test_plain_string_message(): + (msg,) = normalize_messages("Hello") + assert msg.role == "user" + assert msg.content == "Hello" + assert extract_text(msg) == "Hello" + + +def test_dict_messages(): + msgs = normalize_messages( + [ + {"role": "system", "content": "you are helpful"}, + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": "hello", + "reasoning_content": "r", + }, + ], + ) + assert [m.role for m in msgs] == ["system", "user", "assistant"] + assert msgs[2].reasoning_content == "r" + + +def test_message_model_passthrough(): + original = Message(role="user", content="x") + (msg,) = normalize_messages([original]) + assert msg is original + + +def test_native_multimodal_parts(): + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + {"text": "describe"}, + {"image": "https://example.com/a.png"}, + {"audio": "https://example.com/a.wav"}, + { + "video": [ + "https://example.com/f1.jpg", + "https://example.com/f2.jpg", + ], + }, + ], + }, + ], + ) + parts = msg.content + assert isinstance(parts[0], TextPart) and parts[0].text == "describe" + assert isinstance(parts[1], ImagePart) and parts[1].url.endswith("a.png") + assert isinstance(parts[2], AudioPart) + assert isinstance(parts[3], VideoPart) and len(parts[3].frames) == 2 + assert extract_text(msg) == "describe" + + +def test_openai_style_parts(): + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is here?"}, + { + "type": "image_url", + "image_url": { + "url": "https://example.com/b.png", + "detail": "low", + }, + }, + ], + }, + ], + ) + parts = msg.content + assert isinstance(parts[0], TextPart) + assert isinstance(parts[1], ImagePart) + assert parts[1].url.endswith("b.png") + assert parts[1].detail == "low" + + +def test_video_url_form(): + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [{"video": "https://example.com/v.mp4"}], + }, + ], + ) + part = msg.content[0] + assert isinstance(part, VideoPart) + assert part.url.endswith("v.mp4") + assert part.frames is None + + +def test_input_audio_data_format(): + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + { + "type": "input_audio", + "input_audio": {"data": "QUJD", "format": "mp3"}, + }, + ], + }, + ], + ) + part = msg.content[0] + assert isinstance(part, AudioPart) + assert part.url is None + assert part.data == "QUJD" + assert part.format == "mp3" + + +def test_audio_part_requires_a_source(): + with pytest.raises(ValidationError, match="url or data"): + AudioPart() + # The flat canonical dump round-trips through normalization. + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + {"type": "audio", "url": "https://example.com/a.wav"}, + ], + }, + ], + ) + assert msg.content[0].url.endswith("a.wav") + + +def test_image_part_pixel_hints(): + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": "https://example.com/a.png", + "min_pixels": 64, + "max_pixels": 1024, + }, + }, + ], + }, + ], + ) + part = msg.content[0] + assert isinstance(part, ImagePart) + assert part.min_pixels == 64 + assert part.max_pixels == 1024 + # An explicit part-level key wins over the nested placement. + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": "https://example.com/a.png", + "min_pixels": 64, + }, + "min_pixels": 128, + }, + ], + }, + ], + ) + assert msg.content[0].min_pixels == 128 + + +def test_cache_control_marker_accepted(): + # 1.x passed content blocks through unvalidated, so a context-cache + # marker must survive normalization on every part type instead of + # raising ValidationError (a ValueError subclass) pre-flight. + (msg,) = normalize_messages( + [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + ) + part = msg.content[0] + assert isinstance(part, TextPart) + assert part.cache_control == {"type": "ephemeral"} + + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + { + "type": "image", + "url": "https://example.com/a.png", + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "audio", + "url": "https://example.com/a.wav", + "cache_control": {"type": "ephemeral"}, + }, + { + "type": "video", + "url": "https://example.com/v.mp4", + "cache_control": {"type": "ephemeral"}, + }, + ], + }, + ], + ) + for part in msg.content: + assert part.cache_control == {"type": "ephemeral"} + + +def test_video_url_part_with_hints(): + (msg,) = normalize_messages( + [ + { + "role": "user", + "content": [ + { + "type": "video_url", + "video_url": {"url": "https://example.com/v.mp4"}, + "fps": 2, + "min_pixels": 64, + "max_pixels": 2048, + "total_pixels": 4096, + }, + ], + }, + ], + ) + part = msg.content[0] + assert isinstance(part, VideoPart) + assert part.url.endswith("v.mp4") + assert part.fps == 2 + assert part.min_pixels == 64 + assert part.max_pixels == 2048 + assert part.total_pixels == 4096 + + +def test_tool_messages(): + (assistant, tool) = normalize_messages( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "get_weather", + "arguments": '{"city": "hz"}', + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "sunny"}, + ], + ) + assert assistant.content is None + assert assistant.tool_calls[0].function.name == "get_weather" + assert tool.tool_call_id == "call_1" + + +def test_extra_fields_preserved(): + (msg,) = normalize_messages( + [{"role": "user", "content": "x", "custom_field": 1}], + ) + assert msg.model_extra.get("custom_field") == 1 + + +def test_bare_dict_message(): + (msg,) = normalize_messages({"role": "user", "content": "hi"}) + assert msg.role == "user" + assert msg.content == "hi" + + +def test_invalid_inputs(): + with pytest.raises(ValueError, match="must not be empty"): + normalize_messages([]) + with pytest.raises(ValueError, match="unsupported content part"): + normalize_messages([{"role": "user", "content": [{"unknown": 1}]}]) + with pytest.raises(ValidationError, match="url or data"): + normalize_messages([{"role": "user", "content": [{"type": "audio"}]}]) + with pytest.raises(ValueError, match="unsupported message"): + normalize_messages([123]) + with pytest.raises(ValidationError): + Message(role="user", content=[{"type": "text"}]) + + +def test_roundtrip_serialization(): + msgs = normalize_messages( + [ + { + "role": "user", + "content": [ + {"text": "hi"}, + {"image": "https://example.com/a.png"}, + ], + }, + ], + ) + dumped = msgs[0].model_dump(exclude_none=True) + assert dumped["content"] == [ + {"type": "text", "text": "hi"}, + {"type": "image", "url": "https://example.com/a.png"}, + ] + + +def test_has_media_parts(): + assert not has_media_parts(normalize_messages("hi")) + assert not has_media_parts( + normalize_messages([{"role": "user", "content": [{"text": "hi"}]}]), + ) + for content in ( + [{"image": "https://example.com/a.png"}], + [{"audio": "https://example.com/a.wav"}], + [{"video": "https://example.com/a.mp4"}], + [{"text": "describe"}, {"image": "https://example.com/a.png"}], + ): + assert has_media_parts( + normalize_messages([{"role": "user", "content": content}]), + ) diff --git a/tests/v2/test_ws_connection.py b/tests/v2/test_ws_connection.py new file mode 100644 index 00000000..9a6c3b6f --- /dev/null +++ b/tests/v2/test_ws_connection.py @@ -0,0 +1,318 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""WsConnection: multi-task demux over one shared duplex socket.""" + +# pylint: disable=protected-access + +import json +import queue + +import pytest + +from dashscope.v2.errors import APIConnectionError, DashScopeError +from dashscope.v2.transport.ws import WsEventError +from dashscope.v2.transport.ws_connection import WsConnection + +URL = "wss://ws.test.local/api-ws/v1/inference" + + +class QueueWebSocket: + """Blocking websocket stub: records sends, replays a queued inbox.""" + + def __init__(self): + self.sent = [] + self.inbox = queue.SimpleQueue() + self.closed = False + self.timeouts = [] + + def send(self, payload, opcode=1): + assert opcode == (2 if isinstance(payload, bytes) else 1) + self.sent.append(payload) + + def recv(self): + item = self.inbox.get(timeout=5) + if isinstance(item, BaseException): + raise item + return item + + def settimeout(self, seconds): + self.timeouts.append(seconds) + + def close(self): + self.closed = True + # Wake the reader thread so connection.close() can join it. + self.inbox.put(None) + + +def _make_connection(**kwargs): + connects = [] + + def connector(url, headers): + ws = QueueWebSocket() + connects.append({"url": url, "headers": headers, "ws": ws}) + return ws + + kwargs.setdefault("url", URL) + conn = WsConnection(connector=connector, **kwargs) + return conn, connects + + +def _event(name, task_id, payload=None, request_id="r"): + return json.dumps( + { + "header": { + "event": name, + "task_id": task_id, + "request_id": request_id, + }, + "payload": payload or {}, + }, + ) + + +def _started(conn, task, **run_kwargs): + """Feed task-started, then run the task (reader routes async).""" + task_id = task.task_id + conn._ws.inbox.put(_event("task-started", task_id)) + kwargs = { + "task_group": "audio", + "task": "asr", + "function": "recognition", + "model": "paraformer-realtime-v2", + } + kwargs.update(run_kwargs) + return task.run_task(**kwargs) + + +def _sent_frames(ws): + return [json.loads(item) for item in ws.sent if isinstance(item, str)] + + +def test_concurrent_json_tasks_demux_by_task_id(): + conn, connects = _make_connection(api_key="sk-test", timeout=5) + assert connects[0]["url"] == URL + assert connects[0]["headers"]["Authorization"] == "Bearer sk-test" + # The reader thread owns socket reads; the socket must block forever. + assert connects[0]["ws"].timeouts[-1] is None + + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + + # Events arrive out of order; each task gets only its own. + conn._ws.inbox.put(_event("result-generated", t2.task_id, {"n": 2})) + conn._ws.inbox.put(_event("result-generated", t1.task_id, {"n": 1})) + assert t2.recv_event()["payload"] == {"n": 2} + assert t1.recv_event()["payload"] == {"n": 1} + + conn._ws.inbox.put(_event("task-finished", t1.task_id)) + conn._ws.inbox.put(_event("task-finished", t2.task_id)) + t1.recv_event() + t2.recv_event() + assert t1.finished and t2.finished + + run_tasks = [ + f + for f in _sent_frames(conn._ws) + if f["header"]["action"] == "run-task" + ] + assert {f["header"]["task_id"] for f in run_tasks} == { + t1.task_id, + t2.task_id, + } + conn.close() + assert len(connects) == 1 + + +def test_sequential_reuse_single_handshake(): + conn, connects = _make_connection(timeout=5) + t1 = conn.open_task() + _started(conn, t1) + t1.finish_task() + conn._ws.inbox.put(_event("task-finished", t1.task_id)) + t1.recv_event() + t1.close() + + t2 = conn.open_task() + assert t2.task_id != t1.task_id + _started(conn, t2) + conn._ws.inbox.put(_event("task-finished", t2.task_id)) + t2.recv_event() + assert t2.finished + conn.close() + assert len(connects) == 1 + + +def test_binary_downlink_single_active_task_implicit(): + conn, _ = _make_connection(timeout=5) + task = conn.open_task() + _started(conn, task) + conn._ws.inbox.put(b"\x00\x01audio") + assert task.recv() == b"\x00\x01audio" + conn.close() + + +def test_binary_downlink_two_active_tasks_requires_claim(): + conn, _ = _make_connection(timeout=5) + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + t2.claim_binary_downlink() + with pytest.raises(RuntimeError, match="binary downlink"): + t1.claim_binary_downlink() + conn._ws.inbox.put(b"audio-for-t2") + assert t2.recv() == b"audio-for-t2" + # t1 still receives its own JSON events. + conn._ws.inbox.put(_event("result-generated", t1.task_id)) + assert t1.recv_event()["header"]["event"] == "result-generated" + conn.close() + + +def test_binary_downlink_ambiguous_fails_connection(): + conn, _ = _make_connection(timeout=5) + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + conn._ws.inbox.put(b"undemuxable") + with pytest.raises(WsEventError, match="claim_binary_downlink"): + t1.recv() + with pytest.raises(WsEventError, match="claim_binary_downlink"): + t2.recv() + assert conn.closed + with pytest.raises(RuntimeError, match="closed"): + conn.open_task() + + +def test_binary_uplink_single_owner_and_release_on_finish(): + conn, _ = _make_connection(timeout=5) + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + t1.send_binary(b"frame-1") + with pytest.raises(RuntimeError, match="binary uplink"): + t2.send_binary(b"frame-2") + assert conn._ws.sent[-1] == b"frame-1" + + # Finishing the uplink owner releases it for the next task. + conn._ws.inbox.put(_event("task-finished", t1.task_id)) + t1.recv_event() + t2.send_binary(b"frame-2") + assert conn._ws.sent[-1] == b"frame-2" + conn.close() + + +def test_socket_error_broadcasts_to_all_tasks(): + conn, _ = _make_connection(timeout=5) + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + conn._ws.inbox.put(OSError("boom")) + with pytest.raises(OSError, match="boom"): + t1.recv() + with pytest.raises(OSError, match="boom"): + t2.recv() + assert conn.closed + + +def test_peer_close_normalizes_to_api_connection_error(): + """A library close on the shared reader surfaces as APIConnectionError, + not a raw websocket exception -- matching WsSession.recv / realtime.""" + from websocket import WebSocketConnectionClosedException + + conn, _ = _make_connection(timeout=5) + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + conn._ws.inbox.put(WebSocketConnectionClosedException("closed")) + with pytest.raises(APIConnectionError, match="closed by peer"): + t1.recv() + with pytest.raises(APIConnectionError, match="closed by peer"): + t2.recv() + assert conn.closed + + +def test_task_close_detaches_and_late_events_drop(): + conn, _ = _make_connection(timeout=5) + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + t1.close() + # A late event for the detached task is dropped, not fatal. + conn._ws.inbox.put(_event("result-generated", t1.task_id)) + conn._ws.inbox.put(_event("result-generated", t2.task_id, {"ok": 1})) + assert t2.recv_event()["payload"] == {"ok": 1} + assert not conn.closed + with pytest.raises(RuntimeError, match="closed"): + t1.recv() + conn.close() + + +def test_task_failed_maps_error_and_finishes_task(): + conn, _ = _make_connection(timeout=5) + task = conn.open_task() + _started(conn, task) + conn._ws.inbox.put( + json.dumps( + { + "header": { + "event": "task-failed", + "task_id": task.task_id, + "request_id": "req-x", + "error_code": "InvalidParameter", + "error_message": "bad parameter", + }, + "payload": {}, + }, + ), + ) + with pytest.raises(DashScopeError, match="bad parameter"): + task.recv() + assert task.finished + assert task.request_id == "req-x" + conn.close() + + +def test_finish_task_and_cancel_semantics(): + conn, _ = _make_connection(timeout=5) + task = conn.open_task() + _started(conn, task) + task.finish_task() + task.finish_task() # duplicate finish is a no-op + task.finish_task({"directive": "cancel"}) + task.finish_task({"directive": "cancel"}) # duplicate cancel no-op + finishes = [ + f + for f in _sent_frames(conn._ws) + if f["header"]["action"] == "finish-task" + ] + assert len(finishes) == 2 + assert finishes[0]["payload"]["input"] == {} + assert finishes[1]["payload"]["input"] == {"directive": "cancel"} + conn.close() + + +def test_recv_timeout_raises(): + conn, _ = _make_connection() + task = conn.open_task(timeout=0.05) + with pytest.raises(TimeoutError): + task.recv() + conn.close() + + +def test_close_broadcasts_and_closes_socket(): + conn, connects = _make_connection(timeout=5) + task = conn.open_task() + _started(conn, task) + conn.close() + assert conn.closed + assert connects[0]["ws"].closed + with pytest.raises(APIConnectionError, match="connection closed"): + task.recv() + conn.close() # idempotent From 0de1cba0175801b0f9eaf5072667d50c99016c62 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 16 Oct 2026 17:35:00 +0800 Subject: [PATCH 2/9] docs(examples): add the examples tree (legacy 1.x samples, agent recipes, real-world scenarios) referenced by the READMEs --- .../applications/apps.py | 49 ++ .../audio_speech/audio_asr.py | 49 ++ .../audio_speech/audio_tts.py | 55 ++ .../audio_speech/tingwu.py | 83 ++ .../decision/decision.py | 107 +++ .../embeddings/embeddings.py | 45 ++ .../01_atomic_capabilities/files/files.py | 67 ++ .../fine_tuning/fine_tunes.py | 71 ++ .../image_video_synthesis/images.py | 74 ++ .../image_video_synthesis/videos.py | 66 ++ .../01_atomic_capabilities/models/models.py | 43 ++ .../multimodal_vision/multimodal_chat.py | 60 ++ .../01_atomic_capabilities/rerank/rerank.py | 49 ++ .../text_generation/chat.py | 65 ++ .../text_generation/tokenization.py | 46 ++ .../circuit_breaker_retry.py | 217 ++++++ .../client_side_cache.py | 109 +++ .../local_mock_debugging.py | 116 +++ .../mcp_server_demo/README.md | 108 +++ .../mcp_server_demo/client_demo.py | 125 +++ .../mcp_server_demo/server.py | 157 ++++ .../03_agent_recipes/rag_expert_system.py | 116 +++ examples/03_agent_recipes/tool_call_loop.py | 160 ++++ examples/04_real_world_scenarios/README.md | 192 +++++ examples/04_real_world_scenarios/README_zh.md | 192 +++++ .../customer_service_assistant.py | 366 +++++++++ .../production_ready_rag_harness.py | 717 ++++++++++++++++++ .../realtime_audio_meeting_harness.py | 541 +++++++++++++ .../ticket_triage_harness.py | 493 ++++++++++++ examples/README.md | 158 ++++ examples/README_zh.md | 158 ++++ examples/legacy_1x/agentstudio.py | 74 ++ examples/legacy_1x/agentstudio_deployments.py | 34 + examples/legacy_1x/aio_generation/basic.py | 48 ++ .../aio_generation/custom_session.py | 96 +++ .../legacy_1x/aio_generation/deep_research.py | 165 ++++ .../legacy_1x/aio_generation/reasoning.py | 46 ++ .../legacy_1x/aio_generation/tool_calls.py | 74 ++ examples/legacy_1x/aio_image_synthesis.py | 84 ++ .../aio_multimodal_conversation/asr.py | 49 ++ .../aio_multimodal_conversation/image.py | 65 ++ .../aio_multimodal_conversation/ocr.py | 79 ++ .../aio_multimodal_conversation/tool_calls.py | 79 ++ examples/legacy_1x/aio_video_synthesis.py | 74 ++ examples/legacy_1x/application.py | 42 + examples/legacy_1x/assistant_api.py | 80 ++ examples/legacy_1x/assistant_api_file.py | 31 + examples/legacy_1x/assistant_api_simple.py | 61 ++ examples/legacy_1x/cv_models.py | 50 ++ examples/legacy_1x/generation/basic.py | 73 ++ .../legacy_1x/generation/custom_session.py | 80 ++ .../legacy_1x/generation/deep_research.py | 160 ++++ examples/legacy_1x/generation/reasoning.py | 41 + examples/legacy_1x/generation/tool_calls.py | 69 ++ examples/legacy_1x/image_generation.py | 103 +++ examples/legacy_1x/image_synthesis.py | 49 ++ .../legacy_1x/multimodal_conversation/asr.py | 44 ++ .../multimodal_conversation/image.py | 59 ++ .../legacy_1x/multimodal_conversation/ocr.py | 74 ++ .../multimodal_conversation/reasoning.py | 44 ++ .../multimodal_conversation/tool_calls.py | 73 ++ .../multimodal_conversation/video.py | 46 ++ examples/legacy_1x/multimodal_embedding.py | 54 ++ examples/legacy_1x/qwen_asr.py | 41 + examples/legacy_1x/qwen_tts.py | 49 ++ examples/legacy_1x/set_region_example.py | 126 +++ examples/legacy_1x/text_rerank.py | 35 + examples/legacy_1x/tingwu_realtime.py | 150 ++++ examples/legacy_1x/tingwu_usages.py | 28 + examples/legacy_1x/video_synthesis.py | 148 ++++ 70 files changed, 7631 insertions(+) create mode 100644 examples/01_atomic_capabilities/applications/apps.py create mode 100644 examples/01_atomic_capabilities/audio_speech/audio_asr.py create mode 100644 examples/01_atomic_capabilities/audio_speech/audio_tts.py create mode 100644 examples/01_atomic_capabilities/audio_speech/tingwu.py create mode 100644 examples/01_atomic_capabilities/decision/decision.py create mode 100644 examples/01_atomic_capabilities/embeddings/embeddings.py create mode 100644 examples/01_atomic_capabilities/files/files.py create mode 100644 examples/01_atomic_capabilities/fine_tuning/fine_tunes.py create mode 100644 examples/01_atomic_capabilities/image_video_synthesis/images.py create mode 100644 examples/01_atomic_capabilities/image_video_synthesis/videos.py create mode 100644 examples/01_atomic_capabilities/models/models.py create mode 100644 examples/01_atomic_capabilities/multimodal_vision/multimodal_chat.py create mode 100644 examples/01_atomic_capabilities/rerank/rerank.py create mode 100644 examples/01_atomic_capabilities/text_generation/chat.py create mode 100644 examples/01_atomic_capabilities/text_generation/tokenization.py create mode 100644 examples/02_advanced_engineering/circuit_breaker_retry.py create mode 100644 examples/02_advanced_engineering/client_side_cache.py create mode 100644 examples/02_advanced_engineering/local_mock_debugging.py create mode 100644 examples/03_agent_recipes/mcp_server_demo/README.md create mode 100644 examples/03_agent_recipes/mcp_server_demo/client_demo.py create mode 100644 examples/03_agent_recipes/mcp_server_demo/server.py create mode 100644 examples/03_agent_recipes/rag_expert_system.py create mode 100644 examples/03_agent_recipes/tool_call_loop.py create mode 100644 examples/04_real_world_scenarios/README.md create mode 100644 examples/04_real_world_scenarios/README_zh.md create mode 100644 examples/04_real_world_scenarios/customer_service_assistant.py create mode 100644 examples/04_real_world_scenarios/production_ready_rag_harness.py create mode 100644 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py create mode 100644 examples/04_real_world_scenarios/ticket_triage_harness.py create mode 100644 examples/README.md create mode 100644 examples/README_zh.md create mode 100644 examples/legacy_1x/agentstudio.py create mode 100644 examples/legacy_1x/agentstudio_deployments.py create mode 100644 examples/legacy_1x/aio_generation/basic.py create mode 100644 examples/legacy_1x/aio_generation/custom_session.py create mode 100644 examples/legacy_1x/aio_generation/deep_research.py create mode 100644 examples/legacy_1x/aio_generation/reasoning.py create mode 100644 examples/legacy_1x/aio_generation/tool_calls.py create mode 100644 examples/legacy_1x/aio_image_synthesis.py create mode 100644 examples/legacy_1x/aio_multimodal_conversation/asr.py create mode 100644 examples/legacy_1x/aio_multimodal_conversation/image.py create mode 100644 examples/legacy_1x/aio_multimodal_conversation/ocr.py create mode 100644 examples/legacy_1x/aio_multimodal_conversation/tool_calls.py create mode 100644 examples/legacy_1x/aio_video_synthesis.py create mode 100644 examples/legacy_1x/application.py create mode 100644 examples/legacy_1x/assistant_api.py create mode 100644 examples/legacy_1x/assistant_api_file.py create mode 100644 examples/legacy_1x/assistant_api_simple.py create mode 100644 examples/legacy_1x/cv_models.py create mode 100644 examples/legacy_1x/generation/basic.py create mode 100644 examples/legacy_1x/generation/custom_session.py create mode 100644 examples/legacy_1x/generation/deep_research.py create mode 100644 examples/legacy_1x/generation/reasoning.py create mode 100644 examples/legacy_1x/generation/tool_calls.py create mode 100644 examples/legacy_1x/image_generation.py create mode 100644 examples/legacy_1x/image_synthesis.py create mode 100644 examples/legacy_1x/multimodal_conversation/asr.py create mode 100644 examples/legacy_1x/multimodal_conversation/image.py create mode 100644 examples/legacy_1x/multimodal_conversation/ocr.py create mode 100644 examples/legacy_1x/multimodal_conversation/reasoning.py create mode 100644 examples/legacy_1x/multimodal_conversation/tool_calls.py create mode 100644 examples/legacy_1x/multimodal_conversation/video.py create mode 100644 examples/legacy_1x/multimodal_embedding.py create mode 100644 examples/legacy_1x/qwen_asr.py create mode 100644 examples/legacy_1x/qwen_tts.py create mode 100644 examples/legacy_1x/set_region_example.py create mode 100644 examples/legacy_1x/text_rerank.py create mode 100644 examples/legacy_1x/tingwu_realtime.py create mode 100644 examples/legacy_1x/tingwu_usages.py create mode 100644 examples/legacy_1x/video_synthesis.py diff --git a/examples/01_atomic_capabilities/applications/apps.py b/examples/01_atomic_capabilities/applications/apps.py new file mode 100644 index 00000000..83f584bf --- /dev/null +++ b/examples/01_atomic_capabilities/applications/apps.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 application-call example: non-streaming and +streaming.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +# APP_ID: the application ID of a Model Studio app; it can also be +# provided via the DASHSCOPE_APP_ID environment variable +APP_ID = os.environ.get("DASHSCOPE_APP_ID", "") + +PROMPT = "如何做土豆炖猪脚?" + + +if __name__ == "__main__": + client = dashscope.Client() + + # Non-streaming + print("----- non-streaming request -----") + resp = client.apps.completions.create( + app_id=APP_ID, + prompt=PROMPT, + ) + print(resp.text) + print(f"session_id: {resp.session_id}") + + # Streaming: text_stream yields the text deltas as they arrive + print("----- streaming request -----") + stream = client.apps.completions.create( + app_id=APP_ID, + prompt=PROMPT, + stream=True, + ) + for text in stream.text_stream: + print(text, end="", flush=True) + print() + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Multi-turn memory: 04_real_world_scenarios/customer_service_assistant.py +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/audio_speech/audio_asr.py b/examples/01_atomic_capabilities/audio_speech/audio_asr.py new file mode 100644 index 00000000..a810d193 --- /dev/null +++ b/examples/01_atomic_capabilities/audio_speech/audio_asr.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 speech-recognition (ASR) example: batch +transcription over audio file URLs.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "paraformer-v2") + +# Replace with your own audio file URLs (must be publicly accessible) +FILE_URLS = [ + "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3", +] + + +if __name__ == "__main__": + client = dashscope.Client() + + # Batch transcription: submits the task and waits for the result + print("----- transcribe request -----") + job = client.audio.asr.transcribe( + FILE_URLS, + model=MODEL, + parameters={"language_hints": ["zh"]}, + ) + if job.succeeded: + assert job.output is not None + for item in job.output["results"]: + print(f"file: {item['file_url']}") + print(f"transcription url: {item['transcription_url']}") + else: + print(f"job failed: {job.output}") + + # Realtime recognition of a local file (websocket) is available + # via client.audio.asr.recognize_file(path, model=...), which + # yields recognition events as an iterator. + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Realtime hosting: 04_real_world_scenarios/realtime_audio_meeting_harness.py +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/audio_speech/audio_tts.py b/examples/01_atomic_capabilities/audio_speech/audio_tts.py new file mode 100644 index 00000000..b31a6ddd --- /dev/null +++ b/examples/01_atomic_capabilities/audio_speech/audio_tts.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 speech-synthesis (TTS) example: one-shot URL +result and streaming audio chunks.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "sambert-zhichu-v1") +VOICE = os.environ.get("DASHSCOPE_TTS_VOICE", "zhichu") + +TEXT = "你好,这是 dashscope 2.0 的语音合成示例。" + + +if __name__ == "__main__": + client = dashscope.Client() + + # One-shot synthesis: the rendered audio comes back as a + # temporary URL + print("----- synthesize request -----") + result = client.audio.tts.http.synthesize( + TEXT, + model=MODEL, + voice=VOICE, + format="wav", + sample_rate=16000, + ) + print(f"audio url: {result.audio_url}") + + # Streaming synthesis: audio arrives as binary chunks that can be + # written straight to a file + print("----- streaming synthesis request -----") + stream = client.audio.tts.http.stream( + TEXT, + model=MODEL, + voice=VOICE, + ) + with open("tts_output.wav", "wb") as f: + for chunk in stream: + if chunk.audio: + f.write(chunk.audio) + print("audio saved to tts_output.wav") + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# No dedicated harness for this capability yet; saving the +# stream to disk plus the generic patterns above is enough. +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/audio_speech/tingwu.py b/examples/01_atomic_capabilities/audio_speech/tingwu.py new file mode 100644 index 00000000..1eafc6cd --- /dev/null +++ b/examples/01_atomic_capabilities/audio_speech/tingwu.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 TingWu example: offline task call and realtime +session. + +Before running, replace the appid / fileUrl placeholders with your +own TingWu app ID and a publicly accessible audio/video URL. +""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get( + "DASHSCOPE_MODEL", + "tingwu-automotive-service-inspection", +) +APP_ID = os.environ.get("DASHSCOPE_TINGWU_APP_ID", "your-app-id") + +# Audio file streamed to the realtime session (PCM, 16 kHz); it can +# be overridden via the DASHSCOPE_DEMO_AUDIO environment variable +AUDIO_FILE = os.environ.get( + "DASHSCOPE_DEMO_AUDIO", + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "tests", + "data", + "asr_example.wav", + ), +) + + +if __name__ == "__main__": + client = dashscope.Client() + + # Offline task call: input carries the business-defined fields; + # fileUrl is the audio/video file to analyze and appid is the + # TingWu app ID + print("----- offline call -----") + result = client.tingwu.call( + model=MODEL, + input={ + "fileUrl": "http://demo.com/test.mp3", + "appid": "123456", + }, + ) + if result.is_error: + print(f"error: {result.code} {result.message}") + else: + print(result.output) + + # Realtime session (websocket): stream a local audio file and + # print the recognition events + print("----- realtime session -----") + with client.tingwu.realtime( + model="tingwu-turbo", + app_id=APP_ID, + format="pcm", + sample_rate=16000, + ) as session: + with open(AUDIO_FILE, "rb") as f: + while True: + data = f.read(3200) + if not data: + break + session.send_audio_frame(data) + session.stop() + for event in session.events(): + print(event) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Meeting harness: 04_real_world_scenarios/realtime_audio_meeting_harness.py +# (concurrency hosting, reconnects and per-room fault isolation) +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/decision/decision.py b/examples/01_atomic_capabilities/decision/decision.py new file mode 100644 index 00000000..bd8af485 --- /dev/null +++ b/examples/01_atomic_capabilities/decision/decision.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 decision-model (System One) example. + +One ``client.decision.create`` call answers a set of typed questions +about a business ``state`` in a single forward pass -- multi-choice +(``choice``), yes/no (``noul``) and ordinal scale (``score``) -- each +answer carrying its probability distribution. The model generates no +text, so latency and cost do not grow with output length. Typical +uses: ticket routing, content moderation, agent routing. + +The endpoint sits under the compatible-mode path on every gateway, so +it does not move with the client's chat ``protocol`` setting. +""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "decision-model-preview") + +# The business state under decision: free text, a dialogue, or a +# structured object like this support ticket. +STATE = { + "ticket_id": "T-1001", + "content": "订单支付后超过 24 小时仍未到账,用户无法继续使用核心服务。", +} + +if __name__ == "__main__": + client = dashscope.Client() + + print("----- decision request -----") + # Question values may also be the typed models ChoiceQuestion / + # NoulQuestion / ScoreQuestion from dashscope.v2.resources.decision; + # raw dicts below show the exact wire shape (and pass future fields + # through untouched). + resp = client.decision.create( + model=MODEL, + state=STATE, + questions={ + # choice: pick one of the criteria options (1-255 options; + # give the full set and add an "other" fallback). + "department": { + "type": "choice", + "instructions": "应该由哪个团队处理?", + "criteria": { + "billing": "支付、退款和账单问题", + "technical": "产品故障和集成问题", + "other": "不属于以上两类的问题", + }, + }, + # noul: yes/no judgement; the answer is P(yes), 0=no, 1=yes. + "escalate": { + "type": "noul", + "instructions": "是否需要立即通知值班人员?", + }, + # score: ordinal scale described low to high (2-255 levels; + # 3-7 clearly distinguishable levels work best). + "severity": { + "type": "score", + "instructions": "这个问题有多严重?", + "criteria": [ + "轻微问题,不影响功能", + "部分功能受影响,但存在替代方案", + "核心功能不可用,没有替代方案", + "造成严重业务或安全影响", + ], + }, + }, + ) + + # ``answers`` keys mirror the request's question ids. + department = resp.answers["department"] + print( + f"department={department.choice}, " + f"confidence={department.confidence}, " + f"probabilities={department.probabilities}", + ) + + escalate = resp.answers["escalate"] + print(f"escalate P(yes)={escalate.noul}") + + severity = resp.answers["severity"] + # score is the probability-weighted level index and may fall + # between two levels; legend maps an index back to its text. + print( + f"severity={severity.score}, " + f"confidence={severity.confidence}, " + f"legend={severity.legend}", + ) + + print( + f"input_tokens={resp.usage.input_tokens}, " + f"latency_ms={resp.latency_ms}", + ) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Ticket triage: 04_real_world_scenarios/ticket_triage_harness.py +# (threshold policy + fail-closed degradation around the call) +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/embeddings/embeddings.py b/examples/01_atomic_capabilities/embeddings/embeddings.py new file mode 100644 index 00000000..e9eadd52 --- /dev/null +++ b/examples/01_atomic_capabilities/embeddings/embeddings.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 text-embedding example.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "text-embedding-v4") + +TEXTS = [ + "杭州是浙江省的省会", + "上海是中国的直辖市", +] + + +if __name__ == "__main__": + client = dashscope.Client() + + print("----- embeddings request -----") + resp = client.embeddings.create( + model=MODEL, + input=TEXTS, # a single string is also accepted + dimension=1024, + ) + for item in resp.embeddings: + print(f"index={item.index}, embedding dim={len(item.embedding)}") + print(f"usage: {resp.usage.total_tokens} tokens") + + # Batch embedding over a file (one text per line) is available via + # the async-task facade: + # job = client.embeddings.jobs.create(model=..., file_url=...) + # result = client.embeddings.jobs.wait(job) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Semantic cache: 02_advanced_engineering/client_side_cache.py +# Recall into RAG: 04_real_world_scenarios/production_ready_rag_harness.py +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/files/files.py b/examples/01_atomic_capabilities/files/files.py new file mode 100644 index 00000000..ba03ca98 --- /dev/null +++ b/examples/01_atomic_capabilities/files/files.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 file-management example: upload -> list -> get -> +delete. + +Creates a small temporary JSONL file locally so the example is +self-contained. +""" + +import os +import tempfile + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). + + +if __name__ == "__main__": + client = dashscope.Client() + + # Build a small local file to upload + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".jsonl", + delete=False, + ) as f: + f.write('{"prompt": "你好", "completion": "你好!有什么可以帮你?"}\n') + file_path = f.name + + try: + # Upload + print("----- upload file -----") + uploaded = client.files.upload( + file_path=file_path, + purpose="fine-tune", + description="example upload", + ) + print(f"file_id: {uploaded.file_id}, name: {uploaded.name}") + + # List files + print("----- list files -----") + files = client.files.list() + for item in files.files[:5]: + print(f"file_id={item.file_id}, name={item.name}") + + # Get one file + print("----- get file -----") + item = client.files.get(uploaded.file_id) + print(f"file_id={item.file_id}, name={item.name}") + + # Delete the uploaded file + print("----- delete file -----") + deleted = client.files.delete(uploaded.file_id) + print(f"deleted: {deleted.deleted}") + finally: + os.unlink(file_path) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# No dedicated harness for this capability yet; reuse the +# generic patterns above for fault-tolerant upload/delete retries. +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/fine_tuning/fine_tunes.py b/examples/01_atomic_capabilities/fine_tuning/fine_tunes.py new file mode 100644 index 00000000..c445619a --- /dev/null +++ b/examples/01_atomic_capabilities/fine_tuning/fine_tunes.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 fine-tuning example: list jobs, and (optionally) +the full create -> wait -> cancel/delete lifecycle. + +The read-only list/get sections run as-is. The creation section is +commented out: uncomment it after uploading a training file (see +files.py) and setting DASHSCOPE_TRAINING_FILE_ID. +""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen-turbo") +TRAINING_FILE_ID = os.environ.get("DASHSCOPE_TRAINING_FILE_ID", "") + + +if __name__ == "__main__": + client = dashscope.Client() + + # List fine-tuning jobs (paged) + print("----- list fine-tunes -----") + page = client.fine_tunes.list(page_no=1, page_size=10) + print(f"total: {page.total_count}") + for job in page.jobs: + print( + f"job_id={job.job_id}, status={job.status}, " + f"model={job.finetuned_model}", + ) + + # Get one job by ID (uses the first job of the list above) + if page.jobs: + print("----- get fine-tune -----") + job = client.fine_tunes.get(page.jobs[0].job_id) + print( + f"job_id={job.job_id}, status={job.status}, " + f"finished={job.finished}", + ) + + # Full lifecycle: create -> wait -> delete. Uncomment after + # setting DASHSCOPE_TRAINING_FILE_ID to an uploaded file ID. + # + # print("----- create fine-tune -----") + # job = client.fine_tunes.create( + # model=MODEL, + # training_file_ids=TRAINING_FILE_ID, + # mode="sft", + # hyper_parameters={"n_epochs": 2}, + # finetuned_model="my-finetuned-model", + # ) + # print(f"created job_id={job.job_id}") + # + # print("----- wait fine-tune -----") + # final = client.fine_tunes.wait(job) + # print(f"status={final.status}, succeeded={final.succeeded}") + # + # print("----- delete fine-tune -----") + # client.fine_tunes.delete(job.job_id) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# This capability is a long-running jobs flow; reuse the +# generic patterns above for poll timeouts and retries. +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/image_video_synthesis/images.py b/examples/01_atomic_capabilities/image_video_synthesis/images.py new file mode 100644 index 00000000..525b5281 --- /dev/null +++ b/examples/01_atomic_capabilities/image_video_synthesis/images.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 image-generation example (wanx series). + +Demonstrates in order: one-call generate (submit + wait) -> manual +async task submit/wait/fetch -> list tasks. +""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "wanx2.1-t2i-turbo") + +PROMPT = "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵" + + +if __name__ == "__main__": + client = dashscope.Client() + + # One-call generate: submits the task and waits for the result + print("----- generate (submit + wait) -----") + job = client.images.generate( + model=MODEL, + prompt=PROMPT, + n=1, + size="1024*1024", + ) + if job.succeeded: + for url in job.image_urls: + print(f"image url: {url}") + else: + print(f"job failed: {job.output}") + + # Manual async-task flow: submit first, then wait/fetch by task_id + print("----- jobs.create + wait + fetch -----") + pending = client.images.jobs.create( + model=MODEL, + prompt=PROMPT, + n=1, + size="1024*1024", + ) + if pending.output is not None: + task_id = pending.output[ + "task_id" + ] # pylint: disable=unsubscriptable-object + else: + raise RuntimeError("pending.output is None") + print(f"submitted task_id: {task_id}") + + final = client.images.jobs.wait(task_id) + print(f"wait finished, succeeded: {final.succeeded}") + + snapshot = client.images.jobs.fetch(task_id) + print(f"fetch output: {snapshot.output}") + + # List historical tasks + print("----- jobs.list -----") + result = client.images.jobs.list() + print(f"total tasks: {result.total}") + for task in result.tasks[:3]: + print(f"task_id={task.task_id}, status={task.status}") + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# This capability is a long-running jobs flow; reuse the +# generic polling and failover patterns above. +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/image_video_synthesis/videos.py b/examples/01_atomic_capabilities/image_video_synthesis/videos.py new file mode 100644 index 00000000..a5da1e5b --- /dev/null +++ b/examples/01_atomic_capabilities/image_video_synthesis/videos.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 video-generation example (wan series). + +Demonstrates in order: one-call generate (submit + wait) -> manual +async task submit/wait/fetch -> list tasks. +Note: video-generation tasks take a while; please be patient. +""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "wan2.7-t2v") + +PROMPT = "一只小猫在月光下奔跑" + + +if __name__ == "__main__": + client = dashscope.Client() + + # One-call generate: submits the task and waits for the result + print("----- generate (submit + wait) -----") + job = client.videos.generate(model=MODEL, prompt=PROMPT) + if job.succeeded: + assert job.output is not None + print(f"video url: {job.output['video_url']}") + else: + print(f"job failed: {job.output}") + + # Manual async-task flow: submit first, then wait/fetch by task_id + print("----- jobs.create + wait + fetch -----") + pending = client.videos.jobs.create(model=MODEL, prompt=PROMPT) + if pending.output is not None: + task_id = pending.output[ + "task_id" + ] # pylint: disable=unsubscriptable-object + else: + raise RuntimeError("pending.output is None") + print(f"submitted task_id: {task_id}") + + final = client.videos.jobs.wait(task_id) + print(f"wait finished, succeeded: {final.succeeded}") + print(f"video url: {final.output.get('video_url')}") + + snapshot = client.videos.jobs.fetch(task_id) + print(f"fetch output: {snapshot.output}") + + # List historical tasks + print("----- jobs.list -----") + result = client.videos.jobs.list() + print(f"total tasks: {result.total}") + for task in result.tasks[:3]: + print(f"task_id={task.task_id}, status={task.status}") + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# This capability is a long-running jobs flow; reuse the +# generic polling and failover patterns above. +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/models/models.py b/examples/01_atomic_capabilities/models/models.py new file mode 100644 index 00000000..45018a07 --- /dev/null +++ b/examples/01_atomic_capabilities/models/models.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 model-listing example.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.8-max") + + +if __name__ == "__main__": + client = dashscope.Client() + + # List models (paged) + print("----- list models -----") + page = client.models.list(page_no=1, page_size=10) + print(f"total: {page.total_count}, page {page.page_no}") + for model in page.models: + print(f"model: {model.model}") + + # List only Model Studio (Bailian) models + print("----- list models (provider=Bailian) -----") + page = client.models.list(provider="Bailian") + for model in page.models[:5]: + print(f"model: {model.model}") + + # Get one model by name + print("----- get model -----") + model = client.models.get(MODEL) + print(f"model: {model.model}") + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# This capability is read-only; reuse the generic patterns +# above for pagination and throttling backoff. +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/multimodal_vision/multimodal_chat.py b/examples/01_atomic_capabilities/multimodal_vision/multimodal_chat.py new file mode 100644 index 00000000..d4c257a5 --- /dev/null +++ b/examples/01_atomic_capabilities/multimodal_vision/multimodal_chat.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 multimodal chat example: image understanding +(qwen-vl).""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-vl-plus") + +# Replace with your own image URL (must be publicly accessible) +IMAGE_URL = ( + "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/" + "zh-CN/20241022/emyrja/dog_and_girl.jpeg" +) + +MESSAGES = [ + { + "role": "user", + "content": [ + {"image": IMAGE_URL}, + {"text": "图中描绘的是什么景象?"}, + ], + }, +] + + +if __name__ == "__main__": + client = dashscope.Client() + + # Non-streaming + print("----- non-streaming request -----") + resp = client.multimodal.chat.create( # type: ignore[call-overload] + model=MODEL, + messages=MESSAGES, + ) + print(resp.output_text) + + # Streaming + print("----- streaming request -----") + stream = client.multimodal.chat.create( # type: ignore[call-overload] + model=MODEL, + messages=MESSAGES, + stream=True, + ) + for text in stream.text_stream: + print(text, end="", flush=True) + print() + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Managed tool loop: 03_agent_recipes/tool_call_loop.py +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/rerank/rerank.py b/examples/01_atomic_capabilities/rerank/rerank.py new file mode 100644 index 00000000..399f2e6d --- /dev/null +++ b/examples/01_atomic_capabilities/rerank/rerank.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 document-reranking example.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "gte-rerank-v2") + +QUERY = "哈尔滨在哪?" +DOCUMENTS = [ + "黑龙江离俄罗斯很近", + "哈尔滨是中国黑龙江省的省会,位于中国东北", +] + + +if __name__ == "__main__": + client = dashscope.Client() + + print("----- rerank request -----") + resp = client.rerank.create( + model=MODEL, + query=QUERY, + documents=DOCUMENTS, + top_n=5, + return_documents=True, + instruct="Retrieval document that can answer users query.", + ) + for result in resp.results: + document = result.document.text if result.document else None + print( + f"index={result.index}, " + f"score={result.relevance_score}, " + f"document={document}", + ) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Rerank into RAG: 04_real_world_scenarios/production_ready_rag_harness.py +# (cross-encoder reranking after recall is the foundation of +# RAG quality) +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/text_generation/chat.py b/examples/01_atomic_capabilities/text_generation/chat.py new file mode 100644 index 00000000..df7eadee --- /dev/null +++ b/examples/01_atomic_capabilities/text_generation/chat.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 chat example: non-streaming and streaming +completions.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +# The model can be overridden via the DASHSCOPE_MODEL environment +# variable. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + +MESSAGES = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "请用一句话介绍杭州"}, +] + + +if __name__ == "__main__": + # One-call quickstart, backed by a shared singleton client + print("----- quickstart request -----") + completion = dashscope.chat(MODEL, "请用一句话介绍杭州") + print(completion.output_text) + + client = dashscope.Client() + + # Non-streaming + print("----- non-streaming request -----") + resp = client.chat.completions.create( # type: ignore[call-overload] + model=MODEL, + messages=MESSAGES, + ) + print(resp.output_text) + print( + f"usage: {resp.usage.total_tokens} tokens, " + f"request_id: {resp.request_id}", + ) + + # Streaming: text_stream yields the content deltas as they arrive + print("----- streaming request -----") + stream = client.chat.completions.create( # type: ignore[call-overload] + model=MODEL, + messages=MESSAGES, + stream=True, + ) + for text in stream.text_stream: + print(text, end="", flush=True) + print() + # Call stream.get_final_message() instead of consuming text_stream + # to get the fully merged ChatCompletion (including usage) of a + # streaming response. + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Streaming retry: 04_real_world_scenarios/production_ready_rag_harness.py +# (the SDK does not auto-retry streams; the harness ships its +# own retry_with_backoff) +# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/text_generation/tokenization.py b/examples/01_atomic_capabilities/text_generation/tokenization.py new file mode 100644 index 00000000..672117f4 --- /dev/null +++ b/examples/01_atomic_capabilities/text_generation/tokenization.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 token-counting example.""" + +import os + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + + +if __name__ == "__main__": + client = dashscope.Client() + + # Count the tokens of a plain prompt + print("----- count (prompt) -----") + resp = client.tokenization.count( + model=MODEL, + prompt="请用一句话介绍杭州", + ) + print(f"input_tokens: {resp.input_tokens}") + print(f"token_ids: {resp.token_ids}") + + # Count the tokens of a chat message list + print("----- count (messages) -----") + resp = client.tokenization.count( + model=MODEL, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "请用一句话介绍杭州"}, + ], + ) + print(f"input_tokens: {resp.input_tokens}") + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# Token watermark: 04_real_world_scenarios/production_ready_rag_harness.py +# (counts segment by segment and sends only the context that +# fits) +# ------------------------------------------------------------- diff --git a/examples/02_advanced_engineering/circuit_breaker_retry.py b/examples/02_advanced_engineering/circuit_breaker_retry.py new file mode 100644 index 00000000..a4b97612 --- /dev/null +++ b/examples/02_advanced_engineering/circuit_breaker_retry.py @@ -0,0 +1,217 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 high availability: cross-region failover guarded by a +circuit breaker, layered on top of the SDK's built-in retry. + +Resilience happens at two levels: + +1. WITHIN a region the SDK already retries transient 429 / 5xx with + Retry-After-aware exponential backoff -- see ``Client(max_retries=)`` + and ``dashscope/v2/transport/_retry.py``. You do not reimplement it. +2. ACROSS regions this recipe adds a per-region circuit breaker and + fails over to the next healthy region when one keeps erroring, so a + whole-region outage does not take your application down. + +The demo runs fully offline and deterministically: a mock transport +makes the primary region return 503 and the secondary return 200. Drop +``http_client_factory`` to hit the real gateways -- note that API keys +are region-specific (see ``legacy_1x/set_region_example.py``). +""" + +import time + +import httpx + +import dashscope +from dashscope.v2 import errors + +# Fail over across these regions, in order. Both have a dedicated +# shared gateway, so no workspace is required for the demo. +REGIONS = ["cn-beijing", "ap-southeast-1"] + +CLOSED, OPEN, HALF_OPEN = "closed", "open", "half_open" + + +class CircuitBreaker: + """Minimal per-region circuit breaker. + + CLOSED -> requests flow; consecutive failures are counted. + OPEN -> requests are refused fast until ``reset_timeout`` + elapses, then the breaker moves to HALF_OPEN. + HALF_OPEN -> one probe is allowed; success closes the breaker, + failure re-opens it. + """ + + def __init__(self, failure_threshold=2, reset_timeout=30.0): + self.failure_threshold = failure_threshold + self.reset_timeout = reset_timeout + self.state = CLOSED + self.failure_count = 0 + self._opened_at = 0.0 + + def allow(self): + """Whether a request may be attempted right now.""" + if self.state == OPEN: + if time.monotonic() - self._opened_at >= self.reset_timeout: + self.state = HALF_OPEN + return True + return False + return True + + def record_success(self): + self.failure_count = 0 + self.state = CLOSED + + def record_failure(self): + self.failure_count += 1 + if ( + self.state == HALF_OPEN + or self.failure_count >= self.failure_threshold + ): + self.state = OPEN + self._opened_at = time.monotonic() + + +def _is_retryable(exc): + """Connection errors and 5xx / 429 are worth failing over on.""" + if isinstance(exc, errors.APIConnectionError): + return True + status = getattr(exc, "status_code", None) + return status is not None and (status >= 500 or status == 429) + + +class MultiRegionClient: + """Try each region in order, guarded by a circuit breaker.""" + + def __init__( + self, + regions, + http_client_factory=None, + max_retries=0, + failure_threshold=2, + reset_timeout=30.0, + ): + self._regions = list(regions) + self._factory = http_client_factory + self._max_retries = max_retries + self._failure_threshold = failure_threshold + self._reset_timeout = reset_timeout + self._breakers = {} + self._clients = {} + for region in self._regions: + self._breakers[region] = CircuitBreaker( + failure_threshold=failure_threshold, + reset_timeout=reset_timeout, + ) + + def _client(self, region): + if region not in self._clients: + kwargs = { + "region": region, + "max_retries": self._max_retries, + } + if self._factory is not None: + # Offline demo: injected mock transport + dummy creds. + # The dummy workspace also silences the legacy-gateway + # warning; real regional use needs a region-specific + # workspace AND API key (see legacy_1x/set_region_example). + kwargs["api_key"] = "sk-mock" + kwargs["workspace"] = "ws-demo" + kwargs["http_client"] = self._factory(region) + self._clients[region] = dashscope.Client(**kwargs) + return self._clients[region] + + def chat(self, prompt, model="qwen3.7-plus"): + """Return ``(region, response)`` from the first healthy region.""" + last_exc = None + for region in self._regions: + breaker = self._breakers[region] + if not breaker.allow(): + print(f" [{region}] circuit OPEN -> skip") + continue + try: + resp = self._client(region).chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + ) + breaker.record_success() + print(f" [{region}] OK (circuit {breaker.state})") + return region, resp + except errors.DashScopeError as exc: + last_exc = exc + retryable = _is_retryable(exc) + if retryable: + breaker.record_failure() + print( + f" [{region}] {type(exc).__name__} " + f"status={exc.status_code} retryable={retryable} " + f"-> circuit {breaker.state}", + ) + if not retryable: + raise # a 4xx is a real bug; do not mask it + raise errors.APIConnectionError( + f"all regions failed: {last_exc}", + ) from last_exc + + +def _mock_http_client(region): + """Offline transport: the primary region 503s, every other 200s. + + The handler closes over ``region`` instead of sniffing the request + host, so it stays correct when a workspace changes the gateway. + """ + is_primary = region == REGIONS[0] + + def handler(_request): + if is_primary: + return httpx.Response( + 503, + json={ + "code": "ServiceUnavailable", + "message": "mocked regional outage", + }, + ) + return httpx.Response( + 200, + json={ + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": f"来自 {region} 的应答", + }, + }, + ], + }, + "usage": {"total_tokens": 10}, + "request_id": f"mock-{region}", + }, + ) + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def main(): + """Drive three calls to show the breaker opening and failing over.""" + mc = MultiRegionClient( + REGIONS, + http_client_factory=_mock_http_client, + max_retries=0, # keep the demo instant; SDK retry is separate + failure_threshold=2, + ) + + # Three calls show the breaker progressing CLOSED -> OPEN: the + # first two probe the dead primary and fail over; the third sees + # the primary OPEN and goes straight to the healthy region. + for i in range(1, 4): + print(f"----- call {i} -----") + served_by, resp = mc.chat("你好") + print(f" served by: {served_by}") + print(f" answer : {resp.output_text}") + print(f" request_id: {resp.request_id}") + + +if __name__ == "__main__": + main() diff --git a/examples/02_advanced_engineering/client_side_cache.py b/examples/02_advanced_engineering/client_side_cache.py new file mode 100644 index 00000000..4f0ff229 --- /dev/null +++ b/examples/02_advanced_engineering/client_side_cache.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 client-side semantic cache: cut token spend by +answering repeat / near-repeat questions from local memory. + +A semantic cache keys on MEANING, not exact text. The query is +embedded with ``client.embeddings`` and compared (cosine similarity) +against the questions already cached. At or above a similarity +threshold the stored answer is returned with no model call at all; +below it, the query is sent to ``client.chat`` and the result is +cached for next time. + +The store here is a plain in-memory list plus stdlib ``math``; swap in +Redis or a vector database for production. Requires DASHSCOPE_API_KEY +because cache misses call the real embeddings + chat endpoints. +""" + +import math +import os + +import dashscope + +EMBED_MODEL = os.environ.get("DASHSCOPE_EMBED_MODEL", "text-embedding-v4") +CHAT_MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") +# Cosine similarity at or above which two questions are treated as the +# same and the cached answer is reused. Tune to your precision/recall +# trade-off: higher means fewer false hits, lower means more savings. +THRESHOLD = 0.90 + + +def _cosine(vec_a, vec_b): + """Cosine similarity of two equal-length float vectors.""" + dot = sum(x * y for x, y in zip(vec_a, vec_b)) + norm_a = math.sqrt(sum(x * x for x in vec_a)) + norm_b = math.sqrt(sum(y * y for y in vec_b)) + if norm_a == 0.0 or norm_b == 0.0: + return 0.0 + return dot / (norm_a * norm_b) + + +class SemanticCache: + """In-memory semantic cache backed by dashscope embeddings.""" + + def __init__(self, client, threshold=THRESHOLD): + self._client = client + self._threshold = threshold + self._entries = [] # list of (embedding, question, answer) + self.hits = 0 + self.misses = 0 + + def _embed(self, text): + resp = self._client.embeddings.create( + model=EMBED_MODEL, + input=text, # a single string is accepted + ) + return resp.embeddings[0].embedding + + def _chat(self, question): + resp = self._client.chat.completions.create( + model=CHAT_MODEL, + messages=[{"role": "user", "content": question}], + ) + return resp.output_text + + def ask(self, question): + """Return ``(answer, from_cache)`` for ``question``.""" + query_vec = self._embed(question) + best_sim = -1.0 + best_answer = None + for vec, _cached_q, answer in self._entries: + sim = _cosine(query_vec, vec) + if sim > best_sim: + best_sim = sim + best_answer = answer + if best_answer is not None and best_sim >= self._threshold: + self.hits += 1 + return best_answer, True + # Miss: pay for one real completion, then remember it. + answer = self._chat(question) + self._entries.append((query_vec, question, answer)) + self.misses += 1 + return answer, False + + +def main(): + """Ask a few questions and show which ones hit the cache.""" + client = dashscope.Client() + cache = SemanticCache(client) + + questions = [ + "用一句话介绍杭州", + "用一句话介绍杭州", # exact repeat -> guaranteed cache hit + "请简单介绍一下杭州这座城市", # near-duplicate -> likely hit + "用一句话介绍北京", # different meaning -> cache miss + ] + for question in questions: + answer, from_cache = cache.ask(question) + tag = "CACHE HIT " if from_cache else "CACHE MISS" + print(f"[{tag}] Q: {question}") + print(f" A: {answer}") + + print( + f"----- hits={cache.hits}, misses={cache.misses}, " + f"real chat calls={cache.misses} -----", + ) + + +if __name__ == "__main__": + main() diff --git a/examples/02_advanced_engineering/local_mock_debugging.py b/examples/02_advanced_engineering/local_mock_debugging.py new file mode 100644 index 00000000..99526c93 --- /dev/null +++ b/examples/02_advanced_engineering/local_mock_debugging.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 local mock debugging: develop against a canned +gateway with zero network calls and zero token cost. + +The 2.0 ``Client`` accepts an injected ``http_client``. Passing an +``httpx.Client`` wired to ``httpx.MockTransport`` serves canned +responses in-process, so you can exercise both the success path and +the error paths (bad parameter, rate limit, content filter) +deterministically -- before spending a single real token. + +Swap ``_mock_client()`` for ``dashscope.Client()`` and the exact same +calling code runs against the real gateway. +""" + +import httpx + +import dashscope +from dashscope.v2 import errors + +# A fake gateway URL. MockTransport intercepts every request, so no +# traffic ever leaves this process and the host is never resolved. +BASE_URL = "https://mock.local/api/v1" + + +def _chat_envelope(text, request_id="mock-req-1"): + """The native chat-completion envelope the real gateway returns.""" + return { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": text}, + }, + ], + }, + "usage": { + "input_tokens": 8, + "output_tokens": 4, + "total_tokens": 12, + }, + "request_id": request_id, + } + + +def _handler(request): + """Route by request body so one transport serves many scenarios. + + Send a message containing ``trigger-error`` to exercise the error + path; anything else returns a normal completion. + """ + body = request.content.decode("utf-8") + if "trigger-error" in body: + return httpx.Response( + 400, + json={ + "code": "InvalidParameter", + "message": "mocked bad request", + }, + ) + return httpx.Response(200, json=_chat_envelope("你好,这是本地 Mock 返回。")) + + +def _mock_client(): + """A Client whose transport is fully served in-process.""" + transport = httpx.MockTransport(_handler) + return dashscope.Client( + api_key="sk-mock", # never validated; no real call is made + base_url=BASE_URL, + http_client=httpx.Client(transport=transport, base_url=BASE_URL), + ) + + +if __name__ == "__main__": + client = _mock_client() + + print("----- mocked success -----") + resp = client.chat.completions.create( + model="qwen3.7-plus", + messages=[{"role": "user", "content": "你好"}], + ) + print("output_text :", resp.output_text) + print("request_id :", resp.request_id) + print("total_tokens:", resp.usage.total_tokens) + + # Error path 1: the SDK raises a typed exception by default. + print("----- mocked error (raise_on_error=True) -----") + try: + client.chat.completions.create( + model="qwen3.7-plus", + messages=[{"role": "user", "content": "trigger-error"}], + ) + except errors.DashScopeError as exc: + print("caught :", type(exc).__name__) + print("code :", exc.code) + print("status_code :", exc.status_code) + print("message :", exc.message) + + # Error path 2: opt out of raising to inspect the error inline. + print("----- mocked error (raise_on_error=False) -----") + lenient = dashscope.Client( + api_key="sk-mock", + base_url=BASE_URL, + http_client=httpx.Client( + transport=httpx.MockTransport(_handler), + base_url=BASE_URL, + ), + raise_on_error=False, + ) + err = lenient.chat.completions.create( + model="qwen3.7-plus", + messages=[{"role": "user", "content": "trigger-error"}], + ) + print("is_error :", err.is_error) + print("code :", err.code) + print("message :", err.message) diff --git a/examples/03_agent_recipes/mcp_server_demo/README.md b/examples/03_agent_recipes/mcp_server_demo/README.md new file mode 100644 index 00000000..71ebac50 --- /dev/null +++ b/examples/03_agent_recipes/mcp_server_demo/README.md @@ -0,0 +1,108 @@ +# MCP server demo (stdio, dependency-free) + +A minimal **Model Context Protocol (MCP)** server that runs over `stdio` +using only the Python standard library -- no SDK, no API key, no network. + +MCP is the protocol an agent host uses to discover and call external +tools. This folder shows both sides so the demo is self-contained: + +| File | Role | +| --------------- | ----------------------------------------------------------------- | +| `server.py` | The MCP **server**: newline-delimited JSON-RPC 2.0 on stdin/stdout. | +| `client_demo.py`| A tiny MCP **client**: spawns `server.py` and drives a full session.| + +`server.py` implements the minimum a tools-only server needs: + +- `initialize` -> returns `protocolVersion`, `capabilities`, `serverInfo` +- `notifications/initialized` -> a notification, so the server stays silent +- `tools/list` -> the tool schemas the agent may call (`add`, `echo`) +- `tools/call` -> runs one tool and returns its `content` (or `isError`) + +The protocol version (`2024-11-05`) matches the SDK's own MCP clients; +see `dashscope/acli/platforms/bailian/mcp.py` (read-only reference). + +## Run it + +Drive the whole conversation automatically (recommended): + +```bash +python3 examples/03_agent_recipes/mcp_server_demo/client_demo.py +``` + +Expected output: + +```text +initialize -> serverInfo={'name': 'dashscope-demo-mcp-server', 'version': '0.1.0'} +tools/list -> ['add', 'echo'] +tools/call add(2, 3) -> [{'type': 'text', 'text': '5'}] +tools/call echo -> "hello MCP" +tools/call unknown -> isError=True +``` + +Or talk to the server by hand -- pipe JSON-RPC lines into it: + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"you","version":"0.1.0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}' \ + | python3 examples/03_agent_recipes/mcp_server_demo/server.py +``` + +## Sample session + +One JSON object per line; the server replies to requests (they carry an +`id`) and stays silent for notifications (no `id`): + +```jsonc +// -> initialize +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"you","version":"0.1.0"}}} +// <- result +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"dashscope-demo-mcp-server","version":"0.1.0"}}} + +// -> notification (no reply) +{"jsonrpc":"2.0","method":"notifications/initialized"} + +// -> tools/list +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +// <- result (schemas trimmed here; see server.py for the full text) +{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add","description":"Add two numbers and return their sum.","inputSchema":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}},{"name":"echo","description":"Return the input text unchanged.","inputSchema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}]}} + +// -> tools/call +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}} +// <- result +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"5"}]}} +``` + +## Wire it into a real MCP client + +Any MCP-capable host (Claude Desktop, an IDE agent, etc.) can launch +this server. Use the `command` + `args` form and an absolute path: + +```json +{ + "mcpServers": { + "dashscope-demo": { + "command": "python3", + "args": [ + "/ABSOLUTE/PATH/TO/examples/03_agent_recipes/mcp_server_demo/server.py" + ] + } + } +} +``` + +> `stdout` is reserved for JSON-RPC messages. If you add a tool that +> needs to log, write to `stderr` (e.g. `print(..., file=sys.stderr)`), +> never to `stdout`. + +## Extend it + +Add a tool in three steps inside `server.py`: + +1. Write the function (pure stdlib, no side effects on `stdout`). +2. Register its schema in `TOOLS` (`description` + JSON `inputSchema`). +3. Map its name to the function in `IMPL`. + +`tools/list` and `tools/call` pick it up automatically. diff --git a/examples/03_agent_recipes/mcp_server_demo/client_demo.py b/examples/03_agent_recipes/mcp_server_demo/client_demo.py new file mode 100644 index 00000000..4e3a8d49 --- /dev/null +++ b/examples/03_agent_recipes/mcp_server_demo/client_demo.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Drive the local MCP demo server end to end over stdio. + +``server.py`` is a real MCP server, but you normally reach it through an +MCP client (an agent host). This script *is* a minimal client: it spawns +``server.py`` as a subprocess and speaks newline-delimited JSON-RPC 2.0 +to it, doing the standard handshake and then listing and calling tools:: + + initialize -> notifications/initialized -> tools/list -> tools/call + +That makes the MCP demo runnable and testable on its own -- no external +agent, no API key, no network. Run it from anywhere:: + + python3 .../mcp_server_demo/client_demo.py +""" + +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +SERVER = os.path.join(HERE, "server.py") +PROTOCOL_VERSION = "2024-11-05" + + +def _send(proc, payload): + """Write one newline-delimited JSON-RPC message to the server.""" + proc.stdin.write(json.dumps(payload, ensure_ascii=False) + "\n") + proc.stdin.flush() + + +def _recv(proc): + """Read one JSON-RPC message back from the server.""" + line = proc.stdout.readline() + return json.loads(line) if line.strip() else None + + +def _request(proc, req_id, method, params=None): + """Send a request (it has an id) and return its response.""" + payload = { + "jsonrpc": "2.0", + "id": req_id, + "method": method, + "params": params or {}, + } + _send(proc, payload) + return _recv(proc) + + +def _notify(proc, method): + """Send a notification (no id): the server must not reply.""" + _send(proc, {"jsonrpc": "2.0", "method": method}) + + +def _session(proc): + """Run the full handshake + tools/list + tools/call conversation.""" + # 1. Handshake: negotiate protocol version and capabilities. + resp = _request( + proc, + 1, + "initialize", + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": {"name": "client-demo", "version": "0.1.0"}, + }, + ) + server_info = resp["result"]["serverInfo"] + print(f"initialize -> serverInfo={server_info}") + + # The client acknowledges the handshake with a notification. + _notify(proc, "notifications/initialized") + + # 2. Discover the tools the server exposes. + resp = _request(proc, 2, "tools/list") + names = [tool["name"] for tool in resp["result"]["tools"]] + print(f"tools/list -> {names}") + + # 3. Call a tool and read back its content blocks. + resp = _request( + proc, + 3, + "tools/call", + {"name": "add", "arguments": {"a": 2, "b": 3}}, + ) + content = resp["result"]["content"] + print(f"tools/call add(2, 3) -> {content}") + + resp = _request( + proc, + 4, + "tools/call", + {"name": "echo", "arguments": {"text": "hello MCP"}}, + ) + text = resp["result"]["content"][0]["text"] + print(f"tools/call echo -> {text}") + + # 4. An unknown tool comes back as a tool-level error, not a crash. + resp = _request(proc, 5, "tools/call", {"name": "nope", "arguments": {}}) + is_error = resp["result"]["isError"] + print(f"tools/call unknown -> isError={is_error}") + + +def main(): + with subprocess.Popen( + [sys.executable, SERVER], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) as proc: + try: + _session(proc) + finally: + proc.stdin.close() # EOF ends the server's read loop + proc.wait(timeout=5) + err = proc.stderr.read().strip() + if err: + print(f"[server stderr]\n{err}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/examples/03_agent_recipes/mcp_server_demo/server.py b/examples/03_agent_recipes/mcp_server_demo/server.py new file mode 100644 index 00000000..8c8c72c4 --- /dev/null +++ b/examples/03_agent_recipes/mcp_server_demo/server.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""A dependency-free MCP (Model Context Protocol) server over stdio. + +MCP lets an agent discover and call external tools through a small +JSON-RPC 2.0 protocol. This server speaks the ``stdio`` transport: it +reads newline-delimited JSON-RPC requests on stdin and writes one JSON +response per line on stdout (stdout is reserved for protocol messages; +anything a tool prints must go to stderr). + +It implements the minimum a tools-only server needs: + +* ``initialize`` -> protocol version + capabilities +* ``notifications/initialized`` -> a notification, so no reply +* ``tools/list`` -> the tool schemas the agent may call +* ``tools/call`` -> run one tool and return its content + +Only the Python standard library is used, so it runs anywhere with no +API key and no network. Run it directly and type JSON-RPC lines, or +drive it end to end with ``client_demo.py``. +""" + +import json +import sys + +# The protocol version the SDK's own MCP clients negotiate. Mirrors +# ``dashscope/acli/platforms/bailian/mcp.py`` (read-only reference); the +# constant is inlined here to keep this server dependency-free. +PROTOCOL_VERSION = "2024-11-05" +SERVER_INFO = {"name": "dashscope-demo-mcp-server", "version": "0.1.0"} + + +# ---- tool implementations (pure stdlib, offline, deterministic) ---- # +def _add(a, b): + return a + b + + +def _echo(text): + return text + + +# Tool registry: name -> {"description", "inputSchema"} plus its impl. +TOOLS = { + "add": { + "description": "Add two numbers and return their sum.", + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "number", "description": "first addend"}, + "b": {"type": "number", "description": "second addend"}, + }, + "required": ["a", "b"], + }, + }, + "echo": { + "description": "Return the input text unchanged.", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "text to echo"}, + }, + "required": ["text"], + }, + }, +} + +IMPL = {"add": _add, "echo": _echo} + + +def _tool_list(): + """Build the ``tools/list`` result payload.""" + return [{"name": name, **spec} for name, spec in TOOLS.items()] + + +def _text_content(text): + """Wrap a string as an MCP text content block.""" + return {"content": [{"type": "text", "text": text}]} + + +def _tool_error(message): + """A tool-level failure: content plus ``isError`` (not JSON-RPC).""" + return {"content": [{"type": "text", "text": message}], "isError": True} + + +def _handle_initialize(_params): + return { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + } + + +def _handle_tools_list(_params): + return {"tools": _tool_list()} + + +def _handle_tools_call(params): + name = params.get("name") + arguments = params.get("arguments") or {} + impl = IMPL.get(name) + if impl is None: + return _tool_error(f"unknown tool: {name}") + try: + value = impl(**arguments) + except TypeError as exc: + return _tool_error(f"bad arguments for {name}: {exc}") + return _text_content(json.dumps(value, ensure_ascii=False)) + + +HANDLERS = { + "initialize": _handle_initialize, + "tools/list": _handle_tools_list, + "tools/call": _handle_tools_call, +} + + +def handle(request): + """Return a JSON-RPC response dict, or ``None`` for notifications.""" + req_id = request.get("id") + if req_id is None: + # Notifications (e.g. notifications/initialized) get no reply. + return None + + method = request.get("method") + params = request.get("params") or {} + handler = HANDLERS.get(method) + if handler is None: + error = {"code": -32601, "message": f"method not found: {method}"} + return {"jsonrpc": "2.0", "id": req_id, "error": error} + return {"jsonrpc": "2.0", "id": req_id, "result": handler(params)} + + +def _write(response): + sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def main(): + """Read JSON-RPC lines from stdin until EOF, replying on stdout.""" + while True: + line = sys.stdin.readline() + if not line: + break # EOF: the client closed the pipe + line = line.strip() + if not line: + continue + try: + request = json.loads(line) + except json.JSONDecodeError: + continue # ignore malformed input; keep serving + response = handle(request) + if response is not None: + _write(response) + + +if __name__ == "__main__": + main() diff --git a/examples/03_agent_recipes/rag_expert_system.py b/examples/03_agent_recipes/rag_expert_system.py new file mode 100644 index 00000000..5f2e4e22 --- /dev/null +++ b/examples/03_agent_recipes/rag_expert_system.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 agent recipe: a standard RAG loop with Bailian +retrieval (embeddings + rerank). + +Retrieval-augmented generation grounds the model in your own documents +so answers cite sources instead of hallucinating. The loop: + +1. embed a small corpus once (``client.embeddings``); +2. embed the query and take a cosine top-k (stdlib ``math``); +3. re-score those candidates with ``client.rerank`` (a cross-encoder) + for better precision; +4. stuff the top passages into an "answer only from context" prompt, + call ``client.chat``, and print the answer with citations. + +Requires DASHSCOPE_API_KEY. +""" + +import math +import os + +import dashscope + +EMBED_MODEL = os.environ.get("DASHSCOPE_EMBED_MODEL", "text-embedding-v4") +RERANK_MODEL = os.environ.get("DASHSCOPE_RERANK_MODEL", "gte-rerank-v2") +CHAT_MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") +TOP_K = 3 + +# A tiny stand-in corpus; in production load chunks from files or a DB. +CORPUS = [ + "DashScope 是阿里云百炼提供的模型服务 SDK,支持文本生成、向量、重排等能力。", + "百炼平台提供 qwen 系列大模型,可通过 API Key 调用。", + "文本向量(embedding)把句子映射为定长浮点向量,用于语义检索。", + "重排(rerank)用交叉编码器对候选文档按与查询的相关性重新打分。", + "杭州是浙江省的省会,位于中国东南沿海。", +] + + +def _cosine(vec_a, vec_b): + """Cosine similarity of two equal-length float vectors.""" + dot = sum(x * y for x, y in zip(vec_a, vec_b)) + norm_a = math.sqrt(sum(x * x for x in vec_a)) + norm_b = math.sqrt(sum(y * y for y in vec_b)) + if norm_a == 0.0 or norm_b == 0.0: + return 0.0 + return dot / (norm_a * norm_b) + + +def _embed_texts(client, texts): + """Embed a list of texts, returned in input order.""" + resp = client.embeddings.create(model=EMBED_MODEL, input=texts) + ordered = sorted(resp.embeddings, key=lambda item: item.index) + return [item.embedding for item in ordered] + + +def _retrieve(query_vec, corpus_vecs, top_k): + """Indices of the top_k corpus vectors closest to the query.""" + scored = [ + (_cosine(query_vec, vec), idx) for idx, vec in enumerate(corpus_vecs) + ] + scored.sort(reverse=True) + return [idx for _sim, idx in scored[:top_k]] + + +def _rerank(client, query, candidates): + """Re-score candidates; return passage text ordered by relevance.""" + resp = client.rerank.create( + model=RERANK_MODEL, + query=query, + documents=candidates, + top_n=len(candidates), + return_documents=True, + ) + return [r.document.text for r in resp.results if r.document] + + +def rag_answer(client, query): + """Run the full retrieve -> rerank -> generate loop.""" + corpus_vecs = _embed_texts(client, CORPUS) + query_vec = _embed_texts(client, [query])[0] + + candidate_idx = _retrieve(query_vec, corpus_vecs, TOP_K) + candidates = [CORPUS[idx] for idx in candidate_idx] + print("retrieved (cosine top-k):") + for idx in candidate_idx: + print(f" - [{idx}] {CORPUS[idx]}") + + passages = _rerank(client, query, candidates) + print("after rerank:") + for passage in passages: + print(f" - {passage}") + + context = "\n".join(f"[{n}] {p}" for n, p in enumerate(passages)) + prompt = ( + "仅根据下面的资料回答问题,并标注引用的编号;" + "若资料不足,则回答:资料不足。\n" + f"资料:\n{context}\n\n问题:{query}" + ) + resp = client.chat.completions.create( + model=CHAT_MODEL, + messages=[{"role": "user", "content": prompt}], + ) + return resp.output_text + + +def main(): + """Run the retrieve -> rerank -> generate demo.""" + client = dashscope.Client() + question = "什么是重排(rerank)?它有什么作用?" + answer = rag_answer(client, question) + print("----- answer -----") + print(answer) + + +if __name__ == "__main__": + main() diff --git a/examples/03_agent_recipes/tool_call_loop.py b/examples/03_agent_recipes/tool_call_loop.py new file mode 100644 index 00000000..30163cbd --- /dev/null +++ b/examples/03_agent_recipes/tool_call_loop.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 agent recipe: a managed multi-turn tool-call loop. + +This is the classic agent state machine. Send the conversation plus a +tool schema; whenever the model asks to call a tool +(``message.tool_calls``), execute it locally, append the result as a +``role="tool"`` message, and call the model again -- until it returns a +plain answer with no further tool calls. A ``MAX_TURNS`` guard stops a +misbehaving model from looping forever. + +Requires DASHSCOPE_API_KEY (it calls the real chat endpoint). +""" + +import json +import os + +import dashscope + +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") +MAX_TURNS = 5 + +# Tool schemas the model may choose to call (OpenAI function shape, +# which the native gateway also accepts). +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "查询某个城市当前的天气", + "parameters": { + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "城市名,例如 杭州", + }, + }, + "required": ["city"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_population", + "description": "查询某个城市的常住人口(万人)", + "parameters": { + "type": "object", + "properties": { + "city": {"type": "string", "description": "城市名"}, + }, + "required": ["city"], + }, + }, + }, +] + + +# ---- local tool implementations (stand-ins for real services) ---- # +def get_weather(city): + table = {"杭州": "晴,26℃", "北京": "多云,21℃"} + return table.get(city, "未知") + + +def get_population(city): + table = {"杭州": 1237, "北京": 2184} + return table.get(city, 0) + + +TOOL_IMPL = { + "get_weather": get_weather, + "get_population": get_population, +} + + +def _run_tool(name, arguments_json): + """Execute one tool call, always returning a JSON string result.""" + try: + args = json.loads(arguments_json or "{}") + except json.JSONDecodeError: + return json.dumps({"error": "invalid JSON arguments"}) + impl = TOOL_IMPL.get(name) + if impl is None: + return json.dumps({"error": f"unknown tool: {name}"}) + try: + return json.dumps({"result": impl(**args)}, ensure_ascii=False) + except TypeError as exc: + return json.dumps({"error": f"bad arguments: {exc}"}) + + +def _dump_tool_calls(tool_calls): + """Serialize ToolCall models back to plain wire dicts.""" + return [ + { + "id": call.id, + "type": call.type, + "function": { + "name": call.function.name, + "arguments": call.function.arguments, + }, + } + for call in tool_calls + ] + + +def agent_loop(client, user_input): + """Drive the tool-call state machine until a final answer.""" + messages = [ + { + "role": "system", + "content": "你是一个助手,可以调用工具获取实时信息。", + }, + {"role": "user", "content": user_input}, + ] + for turn in range(MAX_TURNS): + resp = client.chat.completions.create( + model=MODEL, + messages=messages, + tools=TOOLS, + ) + message = resp.choices[0].message + if not message.tool_calls: + print(f"[turn {turn}] final: {message.content}") + return message.content + + # Echo the assistant tool-call turn back verbatim ... + messages.append( + { + "role": "assistant", + "content": message.content, + "tool_calls": _dump_tool_calls(message.tool_calls), + }, + ) + # ... then run each tool and append its result. + for call in message.tool_calls: + name = call.function.name + args = call.function.arguments + result = _run_tool(name, args) + print(f"[turn {turn}] {name}({args}) -> {result}") + messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "name": name, + "content": result, + }, + ) + print(f"reached MAX_TURNS={MAX_TURNS} without a final answer") + return None + + +def main(): + """Run the agent loop against the real chat endpoint.""" + client = dashscope.Client() + agent_loop(client, "杭州现在天气怎么样?顺便说下它有多少人口。") + + +if __name__ == "__main__": + main() diff --git a/examples/04_real_world_scenarios/README.md b/examples/04_real_world_scenarios/README.md new file mode 100644 index 00000000..5b00078f --- /dev/null +++ b/examples/04_real_world_scenarios/README.md @@ -0,0 +1,192 @@ +# 04 Real-world Scenarios + +> **English** | [中文](README_zh.md) + +This tier is not "how to call some capability" — it is **building a real business end to end**: composing the atomic capabilities of `01`, the engineering wrappers of `02` and the agent recipes of `03` into complete, directly comparable cases. Every case carries the tradeoffs production actually brings (guardrails, context budgets, fault tolerance, observability), plus a "why it is written this way" walkthrough. + +| Case | Scenario | Network | +| --- | --- | --- | +| [`customer_service_assistant.py`](./customer_service_assistant.py) | E-commerce online customer service: guardrailed persona + business tools + multi-turn memory + managed tool loop | Yes | +| [`production_ready_rag_harness.py`](./production_ready_rag_harness.py) | Industrial-grade RAG Q&A engine: streaming semantic retrieval + local token watermarks + streaming backoff retry + multi-region circuit breaking and failover + graceful degradation | No (offline mock by default; `RAG_HARNESS_LIVE=1` hits the real gateway) | +| [`realtime_audio_meeting_harness.py`](./realtime_audio_meeting_harness.py) | High-concurrency realtime meeting transcription base: one dedicated websocket per meeting + meeting state machine + reconnect backoff + local transcript merge across reconnects + per-room fault isolation and degradation | No (offline mock by default; `MEETING_HARNESS_LIVE=1` hits the real gateway) | +| [`ticket_triage_harness.py`](./ticket_triage_harness.py) | Decision-model ticket triage: one decision round per ticket (choice / noul / score in a single forward pass) + local threshold policy + fail-closed degradation to the human queue + per-ticket isolation and an exact input-token cost ledger | No (scripted decision backend by default; `TRIAGE_HARNESS_LIVE=1` hits the real gateway) | + +--- + +## Case: e-commerce online customer-service assistant + +`customer_service_assistant.py` demonstrates an after-sales service bot for a "sample mall". Users can ask about orders, track shipments, ask whether a refund is possible, and request a human agent in natural language; the bot calls business tools to fetch **real data** before answering. + +### The real problems it solves + +| Production concern | What the example does | +| --- | --- | +| **Guardrails** | The system prompt scopes answers to after-sales topics, requires tool calls for order facts, forbids fabrication, and defines when to escalate to a human | +| **Business tool integration** | `query_order` / `track_shipment` / `check_refund_eligible` / `create_ticket`, all over one in-memory "order database" — swap in your own services | +| **Context budget** | `ConversationMemory` uses a sliding window: the system message is pinned and only the last `MAX_HISTORY_TURNS` turns are kept, so long dialogues never blow up the context | +| **Wire integrity** | Truncating from the head can leave "orphan" `role="tool"` messages (whose assistant tool-call turn was dropped), which the API rejects; `_trim` discards leading tool messages to keep the request valid | +| **Runaway backstop** | `MAX_TURNS` stops the model from calling tools forever; tool execution uses defensive JSON parsing, returning structured errors for unknown tools / bad arguments instead of raising | + +### Architecture at a glance + +```text +user input + │ + ▼ +ConversationMemory ──(system + recent turns)──► client.chat.completions.create(tools=TOOLS) + ▲ │ + │ ┌──────────────────┴──────────────────┐ + │ │ tool_calls present? │ + │ yes ◄──┤ ├──► no + │ └───────────────────────────────────┘ + │ execute tools locally → feed role="tool" results back + └──────────────────────────────────────────────────────────────────────┐ + final answer ◄──┘ +``` + +### Why the tool loop is non-streaming + +A tool-call turn must **read the complete `message.tool_calls`** before local execution, so streaming deltas are meaningless inside the loop; the loop uses non-streaming `create`. If you want the pure-chat typewriter effect, see `stream=True` + `text_stream` in [`01_atomic_capabilities/text_generation/chat.py`](../01_atomic_capabilities/text_generation/chat.py); when you need both streaming and merged `tool_calls`, use `stream.get_final_message()`. + +### Run + +```bash +export DASHSCOPE_API_KEY="sk-xxx" +python3 examples/04_real_world_scenarios/customer_service_assistant.py +``` + +The demo runs a three-turn dialogue whose second turn — "can this order be refunded now?" — never repeats the order id: it only resolves because `ConversationMemory` carries turn one's `SO2024001` into the context. That is exactly what the memory layer is for. + +### Adapt it into your own customer service + +1. **Swap the data source**: replace `ORDERS` and the four `*_tool` functions with real order / logistics / ticket service calls (keep returning JSON-serializable dicts). +2. **Change the persona and policies**: edit `SYSTEM_PROMPT` and business constants like `REFUND_WINDOW_DAYS`. +3. **Add tools**: write the function → add its schema to `TOOLS` → register it in `TOOL_IMPL`; the loop picks it up automatically. +4. **Plug in persistent memory**: `ConversationMemory` is currently an in-process list; in production swap in Redis / a database keyed by session id, keeping the interface (`add` / `extend` / `messages`) unchanged. + +--- + +## Case: industrial-grade production-ready RAG harness + +`production_ready_rag_harness.py` is not "how to string-concatenate a RAG call" — it stitches the four things an enterprise knowledge-base Q&A must get right for production into one runnable blueprint: **streaming semantic retrieval -> local token watermarks -> rigid resilience governance -> graceful degradation**. It runs offline by default (`httpx.MockTransport` serves canned responses and injects faults on demand), so you can **watch** retries, failover and watermark trimming actually happen without an API key. + +### The four production dimensions + +| Dimension | Approach | Why it matters | +| --- | --- | --- | +| **Streaming semantic retrieval** | `LocalVectorStore` embeds once + cosine top-k, then `client.rerank` cross-encoder refinement | Fast recall and precise ranking — the foundation of RAG quality | +| **Local token watermark** | `TokenWatermark` measures segment by segment via `client.tokenization.count` and sends only what fits | A fat context never hits the window limit — or the bill | +| **Rigid resilience** | Non-streaming calls rely on the SDK's built-in `max_retries` (Retry-After-aware backoff); **the SDK does not retry streaming calls**, so the harness ships its own `retry_with_backoff`; on top, `ResilientGateway` breaks per region and fails over across regions | 429/5xx / whole-region outages cannot take the app down | +| **Graceful degradation** | `answer()` wraps retrieval / rerank / generation in one guard; `RateLimitError` / `ContentFilterError` / all-regions-down all become clean degraded answers | Upper layers receive a result, not a stack trace | + +### A real pit you will step into without reading the source + +> **Streaming requests do not go through the SDK's auto-retry.** `open_stream` in `dashscope/v2/transport/http.py` checks the initial status once and returns (source comment: "only the initial status is checked here, no retries"), while the plain `request` runs the `max_retries` backoff loop. So a streaming RAG that does not handle 429/503 itself throws the exception straight at the user. The harness's `retry_with_backoff` exists precisely for this gap. + +Likewise, a stream is a **one-shot iterator**: consuming `text_stream` and then calling `get_final_message()` yields an empty tail, while the usage counters live in the **last chunk**, not the first. `_consume_stream` therefore iterates exactly once, printing the deltas and folding in usage at the same time. + +### Run + +```bash +# Offline (default, no key needed, deterministic output, demos scenarios A/B/C/D in turn) +python3 examples/04_real_world_scenarios/production_ready_rag_harness.py + +# Hit the real gateway (needs DASHSCOPE_API_KEY; runs scenario A only, no fault injection) +RAG_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/production_ready_rag_harness.py +``` + +The offline demo runs in order: + +- **Scenario A** standard Q&A: recall -> watermark trimming (watch `dropped`) -> rerank -> streaming generation with citations. +- **Scenario B** one injected 429: the harness backs off exponentially and then succeeds (because streams are not retried by the SDK). +- **Scenario C** primary-region 503: mid-answer the breaker flips CLOSED -> OPEN; later steps skip the dead region outright and fail over to the secondary. +- **Scenario D** every region down: no exception — a degraded answer is returned. + +### Adapt it into your own knowledge base + +1. **Swap the vector store**: `LocalVectorStore` is in-memory cosine; in production switch to Milvus / AnalyticDB / pgvector, keeping the `index` / `search` interface. +2. **Hit the real gateway**: drop `MockGateway`; `build_engine` already has the `RAG_HARNESS_LIVE=1` branch (single region); true multi-region disaster recovery needs a key + workspace per region (see `legacy_1x/set_region_example.py`). +3. **Tune the watermark and backoff**: derive `CONTEXT_TOKEN_BUDGET` from the model's max context minus instructions and the reserved answer length; for `GEN_RETRIES` / `base_delay`, production suggests `base_delay≈0.5` while honoring the server's Retry-After. +4. **Batch the counting**: the demo counts segment by segment for observability; high-frequency paths should batch counting or estimate with a local tokenizer. + +--- + +## Case: high-concurrency realtime meeting transcription base + +`realtime_audio_meeting_harness.py` rebuilds the legacy TingWu realtime script (`legacy_1x/tingwu_realtime.py`, a test-flavored style of callbacks + `time.sleep` polling) into a **meeting-platform-grade concurrency supervisor**: transcribe N meeting rooms in realtime at the same time, each room with its own state machine and transcript, and no single room's disconnect or moderation block can drag the others down. It also runs offline by default — a scripted duplex socket is injected through TingWu realtime's `connector=` (the same seam used by `tests/v2/test_tingwu.py`), so without a microphone or a key you can watch concurrency, reconnects and degradation actually happen. + +### The four production dimensions + +| Dimension | Approach | Why it matters | +| --- | --- | --- | +| **Protocol-respecting concurrency** | One meeting = one dedicated websocket + one worker; `ThreadPoolExecutor(max_workers=MAX_CONCURRENT)` does both concurrency and admission | See the pit below: realtime cannot be multiplexed, so the pool size is the cap on active sockets | +| **Local meeting state machine** | Each room walks `PENDING→CONNECTING→LISTENING→TRANSCRIBING→COMPLETED`, with `RECONNECTING`/`DEGRADED` as managed side branches | Hundreds of rooms become observable and reportable through one shared state vocabulary | +| **Cloud recognition / local assembly** | `TranscriptAssembler` merges incremental sentences by `sentenceId` (partials overwritten by finals) | TingWu re-emits the same `sentenceId` incrementally; only local merging yields the full transcript — and lets transcription resume across reconnects | +| **Rigid resilience + isolation** | A drop (`APIConnectionError`) triggers exponential-backoff reconnect; a business `task-failed` degrades that room instead of blindly reconnecting; exceptions stay sealed inside each room's worker | Transport faults deserve reconnects and business faults deserve degradation — misclassify and you do wasted work; isolation keeps one room's failure from spreading | + +### A real pit you will step into without reading the source + +> **The realtime family cannot be multiplexed.** The header comment of `dashscope/v2/transport/ws_connection.py` states it: the realtime protocol has no task id — one socket is one session, closed when the session ends — so it always opens a dedicated connection per session. (The multiplexable `WsConnection` only serves the JSON-framed asr/tts/translation tasks, and binary uplink is single-owner.) Therefore the correct shape of "high-concurrency realtime audio" is **one dedicated connection per meeting plus a supervising thread pool**, not stuffing multiple audio streams into one socket — the protocol simply cannot do that. The harness's `MeetingSupervisor` uses a bounded thread pool to tie "concurrency" to "active sockets / blast radius" as admission control. + +Likewise, when a socket drops mid-stream, `WsSession.recv` raises `APIConnectionError` ("closed before task-finished"), while a `task-failed` event is mapped through the error catalog into a business exception with `status_code=200` (e.g. `ContentFilterError`). `_is_reconnectable` distinguishes exactly on this: the former reconnects, the latter degrades — the offline demo's `room-gamma` (after a drop-and-reconnect, the half sentence `今天我们讨论` is completed into `今天我们讨论第三季度的路线图。`) and `room-delta` (a content-moderation `task-failed` that degrades immediately) demonstrate these two paths respectively. + +### Run + +```bash +# Offline (default, no key needed, deterministic output, 4 rooms concurrent + reconnect + single-room degradation) +python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py + +# Hit the real gateway (needs DASHSCOPE_API_KEY + DASHSCOPE_TINGWU_APP_ID, runs one real-audio room) +MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +``` + +The offline demo runs 4 rooms concurrently, reports `completed` / `degraded` separately, and shows each room's attempts / reconnects / segments plus a transcript preview — `room-delta` is degraded by a moderation block while the other 3 complete, visibly proving "one room's failure does not spread". + +### Adapt it into your own meeting platform + +1. **Swap the audio source**: `_read_pcm` currently reads local 16 kHz PCM/WAV; in production feed `_pump_audio` frames from a microphone / RTC downlink / message-queue byte stream, keeping `pace=True` for realtime pacing. +2. **True speak-while-transcribing**: the demo is "send all audio → stop → read events" (consistent with `01_atomic_capabilities/audio_speech/tingwu.py`, for offline determinism); for live-caption style, drain `realtime.events()` on a reader thread concurrently while sending — the socket itself is full-duplex. +3. **Tune concurrency and admission**: `MAX_CONCURRENT` is the active-socket cap — set it by your machine's file-descriptor / thread budget; `RECONNECT_ATTEMPTS` and `BASE_DELAY` (production suggests ≈0.5s) control reconnect aggressiveness. +4. **Plug in persistence and observability**: `MeetingSession.transition` is the single exit for state; in production wire it to logging / metrics / meeting-record storage, persisting transcripts and states by `meeting_id`. + +--- + +## Case: decision-model ticket triage harness + +`ticket_triage_harness.py` turns the decision model (`decision-model-preview`) into the front door of a ticket system: every incoming ticket gets ONE decision round — which team owns it (`choice`), whether to page the on-call engineer (`noul`), how severe it is (`score`) — and a local policy engine turns the returned probability distributions into routing actions. It runs offline by default: the mock seam is the one-method decision backend, so `MockDecisionBackend` serves scripted answers (a vague ticket that falls below the confidence floor, plus an injected backend outage) while the policy, degradation and ledger logic under test is the production code, unchanged. + +### The four production dimensions + +| Dimension | Approach | Why it matters | +| --- | --- | --- | +| **One round, typed questions** | `client.decision.create` answers choice + noul + score about the same `state` in a single forward pass; no text is generated | Latency and cost stay flat per ticket — unlike "ask an LLM for JSON and parse it" | +| **Local threshold policy** | `TriagePolicy` owns the confidence floor, the P(yes) escalation cut and the severity bands | The model returns distributions, never decisions; thresholds are auditable business decisions, tunable without redeploying anything | +| **Fail-closed degradation** | Low routing confidence sends the ticket to the human queue; an exception OR an `is_error` envelope degrades it there with the error attached | No ticket is ever auto-routed on a guess — see the pit below | +| **Per-ticket isolation + token ledger** | A backend failure degrades that ticket alone; `usage.input_tokens` is booked per ticket into a running ledger | One bad ticket never stalls the batch, and the ledger is the exact bill — input tokens are the only quantity the decision model bills | + +### A real pit you will step into without reading the source + +> **DashScope can report a failure with HTTP 200.** `ResponseBase` in `dashscope/v2/types/common.py` carries `code` / `message` / `is_error` on every response, and with `raise_on_error=False` the SDK fills that envelope instead of raising. A triage loop that only wraps `try/except` will treat a failed call as a successful one with empty answers — and auto-route the ticket on nothing. The harness therefore checks `response.is_error` even in the default raising mode: belt and suspenders, because the day someone flips `raise_on_error=False` the guard must already be in place. + +Likewise, **`score` answers are probability-weighted level indices, not integer classes** — `2.3` means "between level 2 and 3, leaning 2", and `legend` maps an index back to your level text. Cut lines like `P1_SCORE = 2.5` are therefore thresholds on a continuous axis, which is exactly what severity bands want. + +And a routing note: the System One endpoint lives under the **compatible-mode path on every gateway** (derived by `compatible_mode_base_url` in `dashscope/v2/_config.py`), so changing the client's chat `protocol` does not move it. + +### Run + +```bash +# Offline (default, no key needed, deterministic: 5 tickets, one low-confidence, one injected outage) +python3 examples/04_real_world_scenarios/ticket_triage_harness.py + +# Hit the real gateway (needs DASHSCOPE_API_KEY; the same harness, no fault injection) +TRIAGE_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/ticket_triage_harness.py +``` + +The offline demo triages 5 tickets: T-2001 / T-2002 route to their teams, T-2003 ("你们这个系统到底怎么回事") falls below the confidence floor and goes to human review, T-2004 pages the on-call engineer as P1, and T-2005 suffers an injected backend outage and degrades to the human queue — then the summary prints the state distribution and the exact input-token ledger. + +### Adapt it into your own ticket system + +1. **Swap the intake queue**: `TICKETS` is a stand-in list; stream real tickets in, keeping whatever `state` shape the model should judge (`ticket_id` / `content` here). +2. **Tune the policy, not the model**: `CONFIDENCE_FLOOR` / `ESCALATE_THRESHOLD` / `P1_SCORE` / `P2_SCORE` are business decisions — calibrate them from observed routing quality. +3. **Wire the exits**: verdicts are printed today; in production push `triaged` to the team queue, `escalated` to paging, and `human_review` / `degraded` to the manual triage inbox — the verdict object already carries everything a sink needs. +4. **Scale out**: decision rounds are independent per ticket, so `run` can be parallelized; keep a bounded pool and let the SDK's `max_retries` absorb 429/5xx inside each non-streaming call. diff --git a/examples/04_real_world_scenarios/README_zh.md b/examples/04_real_world_scenarios/README_zh.md new file mode 100644 index 00000000..bbe91964 --- /dev/null +++ b/examples/04_real_world_scenarios/README_zh.md @@ -0,0 +1,192 @@ +# 04 真实场景 + +> [English](README.md) | **中文** + +这一层不是"怎么调某个能力"——而是**把一个真实业务端到端建起来**:把 `01` 的原子能力、`02` 的工程封装和 `03` 的 agent 配方组装成完整、可直接对标的案例。每个案例都带着生产环境真实存在的权衡(护栏、上下文预算、容错、可观测性),并附一段"为什么这样写"的讲解。 + +| 案例 | 场景 | 联网 | +| --- | --- | --- | +| [`customer_service_assistant.py`](./customer_service_assistant.py) | 电商在线客服:护栏化人设 + 业务工具 + 多轮记忆 + 受管工具循环 | 是 | +| [`production_ready_rag_harness.py`](./production_ready_rag_harness.py) | 工业级 RAG 问答引擎:流式语义检索 + 本地 token 水位 + 流式退避重试 + 多地域熔断与故障切换 + 优雅降级 | 否(默认离线 mock;`RAG_HARNESS_LIVE=1` 打真实网关) | +| [`realtime_audio_meeting_harness.py`](./realtime_audio_meeting_harness.py) | 高并发实时会议转写底座:每场会议一条独立 websocket + 会议状态机 + 重连退避 + 跨重连本地转写稿合并 + 按房间故障隔离与降级 | 否(默认离线 mock;`MEETING_HARNESS_LIVE=1` 打真实网关) | +| [`ticket_triage_harness.py`](./ticket_triage_harness.py) | 决策模型工单分诊:每张工单一次决策 round(单次前向推理回答 choice / noul / score)+ 本地阈值策略 + 失败封闭降级到人工队列 + 单工单隔离与精确的 input-token 成本账本 | 否(默认脚本化决策后端;`TRIAGE_HARNESS_LIVE=1` 打真实网关) | + +--- + +## 案例:电商在线客服助手 + +`customer_service_assistant.py` 演示了一个"示例商城"的售后服务机器人。用户可以用自然语言查订单、跟踪物流、询问能否退款、要求转人工;机器人在回答前会调用业务工具取**真实数据**。 + +### 它解决的真实问题 + +| 生产关切 | 示例的做法 | +| --- | --- | +| **护栏** | system prompt 把回答限定在售后话题内,订单事实必须走工具调用,禁止编造,并定义了何时转人工 | +| **业务工具集成** | `query_order` / `track_shipment` / `check_refund_eligible` / `create_ticket`,全部跑在一个内存"订单库"上——换成你自己的服务即可 | +| **上下文预算** | `ConversationMemory` 使用滑动窗口:system 消息固定保留,只保留最近 `MAX_HISTORY_TURNS` 轮,长对话永远不会撑爆上下文 | +| **报文完整性** | 从头部截断可能留下"孤儿" `role="tool"` 消息(其 assistant 工具调用轮已被丢弃),API 会拒绝这种请求;`_trim` 丢弃头部孤儿 tool 消息以保证请求合法 | +| **失控兜底** | `MAX_TURNS` 防止模型无限调用工具;工具执行使用防御式 JSON 解析,对未知工具 / 错误参数返回结构化错误而不是抛异常 | + +### 架构一览 + +```text +用户输入 + │ + ▼ +ConversationMemory ──(system + 最近若干轮)──► client.chat.completions.create(tools=TOOLS) + ▲ │ + │ ┌──────────────────┴──────────────────┐ + │ │ 有 tool_calls 吗? │ + │ yes ◄──┤ ├──► no + │ └─────────────────────────────────────┘ + │ 本地执行工具 → 把 role="tool" 结果喂回 + └──────────────────────────────────────────────────────────────────────┐ + 最终答案 ◄──┘ +``` + +### 为什么工具循环是非流式的 + +工具调用轮必须在本地执行前**读到完整的 `message.tool_calls`**,因此流式增量在循环内没有意义;循环使用非流式 `create`。如果你想要纯聊天的打字机效果,见 [`01_atomic_capabilities/text_generation/chat.py`](../01_atomic_capabilities/text_generation/chat.py) 里的 `stream=True` + `text_stream`;当你同时需要流式和合并后的 `tool_calls` 时,用 `stream.get_final_message()`。 + +### 运行 + +```bash +export DASHSCOPE_API_KEY="sk-xxx" +python3 examples/04_real_world_scenarios/customer_service_assistant.py +``` + +演示会跑一段三轮对话,其中第二轮——"这个订单现在能退款吗?"——不再重复订单号:它能被正确解答,完全是因为 `ConversationMemory` 把第一轮的 `SO2024001` 带进了上下文。这正是记忆层存在的意义。 + +### 改造成你自己的客服系统 + +1. **换数据源**:把 `ORDERS` 和四个 `*_tool` 函数换成真实的订单 / 物流 / 工单服务调用(保持返回 JSON 可序列化的 dict)。 +2. **改人设与政策**:编辑 `SYSTEM_PROMPT` 和 `REFUND_WINDOW_DAYS` 等业务常量。 +3. **加工具**:写好函数 → 把 schema 加进 `TOOLS` → 在 `TOOL_IMPL` 里注册;循环会自动接管。 +4. **接持久化记忆**:`ConversationMemory` 目前是进程内列表;生产环境换成按 session id 存取的 Redis / 数据库,保持接口(`add` / `extend` / `messages`)不变。 + +--- + +## 案例:工业级生产就绪 RAG harness + +`production_ready_rag_harness.py` 不是"怎么拼一个 RAG 调用"——它把企业知识库问答要上生产必须做对的四件事缝成一个可运行的蓝图:**流式语义检索 -> 本地 token 水位 -> 刚性弹性治理 -> 优雅降级**。它默认离线运行(`httpx.MockTransport` 提供罐头响应并按需注入故障),所以不需要 API key 你就能**亲眼看到**重试、故障切换和水位裁剪真实发生。 + +### 四个生产维度 + +| 维度 | 做法 | 为什么重要 | +| --- | --- | --- | +| **流式语义检索** | `LocalVectorStore` 一次向量 + 余弦 top-k,然后 `client.rerank` 交叉编码器精排 | 快速召回 + 精确排序——RAG 质量的地基 | +| **本地 token 水位** | `TokenWatermark` 用 `client.tokenization.count` 逐段实测,只发送装得下的段落 | 再胖的上下文也不会撞窗口上限——或账单 | +| **刚性弹性** | 非流式调用依赖 SDK 内置 `max_retries`(感知 Retry-After 的退避);**SDK 不重试流式调用**,所以 harness 自带 `retry_with_backoff`;其上再由 `ResilientGateway` 按地域熔断并跨地域切换 | 429/5xx / 整地域故障都拖不垮应用 | +| **优雅降级** | `answer()` 把检索 / 精排 / 生成包进一个护栏;`RateLimitError` / `ContentFilterError` / 全地域故障都变成干净的降级答案 | 上层拿到的是结果,而不是堆栈 | + +### 不读源码一定会踩的坑 + +> **流式请求不走 SDK 的自动重试。** `dashscope/v2/transport/http.py` 里的 `open_stream` 只检查一次初始状态就返回(源码注释:"only the initial status is checked here, no retries"),而普通 `request` 会跑 `max_retries` 退避循环。所以一个不自己处理 429/503 的流式 RAG,会把异常直接甩到用户脸上。harness 的 `retry_with_backoff` 正是为这个缺口而存在。 + +同样,流是**一次性迭代器**:先消费 `text_stream` 再调 `get_final_message()` 只能拿到空尾巴,而 usage 计数在**最后一个 chunk** 而不是第一个。因此 `_consume_stream` 恰好迭代一次,边打印增量边把 usage 折叠进来。 + +### 运行 + +```bash +# 离线(默认,无需密钥,输出确定,依次演示场景 A/B/C/D) +python3 examples/04_real_world_scenarios/production_ready_rag_harness.py + +# 打真实网关(需要 DASHSCOPE_API_KEY;只跑场景 A,无故障注入) +RAG_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/production_ready_rag_harness.py +``` + +离线演示依次进行: + +- **场景 A** 标准问答:召回 -> 水位裁剪(观察 `dropped`)-> 精排 -> 带引用的流式生成。 +- **场景 B** 注入一次 429:harness 指数退避后成功(因为流式不被 SDK 重试)。 +- **场景 C** 主地域 503:回答途中熔断器 CLOSED -> OPEN;后续步骤直接跳过死亡地域并切换到副地域。 +- **场景 D** 全部地域不可用:不抛异常——返回降级答案。 + +### 改造成你自己的知识库 + +1. **换向量库**:`LocalVectorStore` 是内存余弦;生产换成 Milvus / AnalyticDB / pgvector,保持 `index` / `search` 接口。 +2. **打真实网关**:去掉 `MockGateway`;`build_engine` 已内置 `RAG_HARNESS_LIVE=1` 分支(单地域);真正的多地域容灾需要每个地域各自的密钥 + 工作空间(见 `legacy_1x/set_region_example.py`)。 +3. **调水位与退避**:`CONTEXT_TOKEN_BUDGET` 由模型最大上下文减去指令和预留回答长度推导;`GEN_RETRIES` / `base_delay` 生产建议 `base_delay≈0.5`,并尊重服务端的 Retry-After。 +4. **批量计数**:演示为了可观测性逐段计数;高频路径应批量计数或用本地 tokenizer 估算。 + +--- + +## 案例:高并发实时会议转写底座 + +`realtime_audio_meeting_harness.py` 把 legacy 听悟 realtime 脚本(`legacy_1x/tingwu_realtime.py`,回调 + `time.sleep` 轮询的测试风格)重建成**会议平台级并发监管器**:同时实时转写 N 个会议室,每个房间有自己的状态机和转写稿,任何一个房间的断线或审核拦截都不能拖垮其他房间。它同样默认离线运行——通过听悟 realtime 的 `connector=` 注入一条脚本化双工 socket(与 `tests/v2/test_tingwu.py` 用的是同一个接缝),没有麦克风、没有密钥也能看到并发、重连和降级真实发生。 + +### 四个生产维度 + +| 维度 | 做法 | 为什么重要 | +| --- | --- | --- | +| **尊重协议的并发** | 一场会议 = 一条独立 websocket + 一个 worker;`ThreadPoolExecutor(max_workers=MAX_CONCURRENT)` 同时承担并发与准入 | 见下面的坑:realtime 无法复用,所以池大小就是活跃 socket 的上限 | +| **本地会议状态机** | 每个房间走 `PENDING→CONNECTING→LISTENING→TRANSCRIBING→COMPLETED`,`RECONNECTING`/`DEGRADED` 是受管侧分支 | 上百个房间通过同一套状态词汇变得可观测、可汇报 | +| **云端识别 / 本地拼装** | `TranscriptAssembler` 按 `sentenceId` 合并增量句子(partial 被 final 覆盖) | 听悟会增量地重发同一个 `sentenceId`;只有本地合并才能得到完整转写稿——并让转写跨重连续上 | +| **刚性弹性 + 隔离** | 断线(`APIConnectionError`)触发指数退避重连;业务性 `task-failed` 让该房间降级而不是盲目重连;异常封死在每个房间的 worker 内 | 传输故障该重连,业务故障该降级——搞混了就是白忙;隔离保证一个房间的失败不外溢 | + +### 不读源码一定会踩的坑 + +> **realtime 家族无法复用连接。** `dashscope/v2/transport/ws_connection.py` 的头注释写明了:realtime 协议没有 task id——一条 socket 就是一个会话,会话结束即关闭——所以它总是为每个会话开一条独立连接。(可复用的 `WsConnection` 只服务 JSON 帧的 asr/tts/translation 任务,且二进制上行是单属主的。)因此"高并发实时音频"的正确形态是**每场会议一条独立连接 + 一个监管线程池**,而不是把多路音频塞进一条 socket——协议做不到。harness 的 `MeetingSupervisor` 用有界线程池把"并发"绑定到"活跃 socket / 爆炸半径"上,作为准入控制。 + +同样,当 socket 在中途断开时,`WsSession.recv` 抛 `APIConnectionError`("closed before task-finished"),而 `task-failed` 事件会经错误目录映射成 `status_code=200` 的业务异常(如 `ContentFilterError`)。`_is_reconnectable` 正是据此区分:前者重连,后者降级——离线演示里的 `room-gamma`(断线重连后,半句 `今天我们讨论` 被补全为 `今天我们讨论第三季度的路线图。`)和 `room-delta`(内容审核 `task-failed` 立即降级)分别演示了这两条路径。 + +### 运行 + +```bash +# 离线(默认,无需密钥,输出确定,4 个房间并发 + 重连 + 单房间降级) +python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py + +# 打真实网关(需要 DASHSCOPE_API_KEY + DASHSCOPE_TINGWU_APP_ID,跑一个真实音频房间) +MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +``` + +离线演示并发跑 4 个房间,分别汇报 `completed` / `degraded`,展示每个房间的尝试次数 / 重连次数 / 句段数和转写预览——`room-delta` 被审核拦截降级,其余 3 个完成,直观地证明"一个房间的失败不外溢"。 + +### 改造成你自己的会议平台 + +1. **换音频源**:`_read_pcm` 目前读本地 16 kHz PCM/WAV;生产环境从麦克风 / RTC 下行 / 消息队列字节流向 `_pump_audio` 喂帧,保持 `pace=True` 的实时节奏。 +2. **真正的边说边转写**:演示是"发完全部音频 → stop → 读事件"(与 `01_atomic_capabilities/audio_speech/tingwu.py` 一致,为了离线确定性);实时字幕风格需要在发送的同时用读线程排空 `realtime.events()`——socket 本身是全双工的。 +3. **调并发与准入**:`MAX_CONCURRENT` 是活跃 socket 上限——按你机器的 fd / 线程预算设定;`RECONNECT_ATTEMPTS` 和 `BASE_DELAY`(生产建议 ≈0.5s)控制重连激进程度。 +4. **接持久化与可观测性**:`MeetingSession.transition` 是状态的唯一出口;生产环境接到日志 / 指标 / 会议记录存储上,按 `meeting_id` 持久化转写稿与状态。 + +--- + +## 案例:决策模型工单分诊 harness + +`ticket_triage_harness.py` 把决策模型(`decision-model-preview`)变成工单系统的前台:每张进线工单只做一次决策 round——归哪个团队(`choice`)、要不要呼叫值班工程师(`noul`)、有多严重(`score`)——再由本地策略引擎把返回的概率分布变成路由动作。它默认离线运行:mock 缝在"一方法决策后端"上,`MockDecisionBackend` 提供脚本化答案(一张低于置信度下限的模糊工单,外加一次注入的后端故障),而被测的策略、降级和账本逻辑就是原封不动的生产代码。 + +### 四个生产维度 + +| 维度 | 做法 | 为什么重要 | +| --- | --- | --- | +| **一次 round,类型化问题** | `client.decision.create` 对同一个 `state` 在单次前向推理中回答 choice + noul + score;不生成文本 | 每张工单的延迟和成本是平的——不像"让大模型生成 JSON 再解析" | +| **本地阈值策略** | `TriagePolicy` 拥有置信度下限、P(yes) 升级阈值和严重度分档 | 模型返回的是分布,永远不是决策;阈值是可审计的业务决策,调整不需要重新部署任何东西 | +| **失败封闭降级** | 路由置信度不足送人工队列;异常**或** `is_error` 信封都降级到人工队列并附带错误 | 没有工单会被凭空猜测地自动路由——见下面的坑 | +| **单工单隔离 + token 账本** | 后端故障只降级那一张工单;`usage.input_tokens` 逐票计入滚动账本 | 一张坏工单永远拖不停整个批次,而且账本就是精确账单——input token 是决策模型唯一计费的量 | + +### 不读源码一定会踩的坑 + +> **DashScope 可能用 HTTP 200 报告失败。** `dashscope/v2/types/common.py` 的 `ResponseBase` 在每个响应上都带 `code` / `message` / `is_error`,而 `raise_on_error=False` 时 SDK 会填充这个信封而不是抛异常。一个只包 `try/except` 的分诊循环会把失败的调用当成答案为空的成功调用——然后基于"什么都没有"自动路由工单。因此 harness 即使在默认 raising 模式下也检查 `response.is_error`:这是双保险,因为当某天有人把 `raise_on_error=False` 打开时,这道防线必须已经就位。 + +同样,**`score` 答案是概率加权等级索引,不是整数类别**——`2.3` 表示"介于等级 2 和 3 之间,偏向 2",`legend` 把索引映射回你给的等级文本。像 `P1_SCORE = 2.5` 这样的分数线因此是连续轴上的阈值,这正是严重度分档想要的东西。 + +还有一条路由说明:System One 端点在**每个网关的 compatible-mode 路径下**(由 `dashscope/v2/_config.py` 的 `compatible_mode_base_url` 推导),所以改变客户端的聊天 `protocol` 不会移动它。 + +### 运行 + +```bash +# 离线(默认,无需密钥,输出确定:5 张工单,一张低置信度,一次注入故障) +python3 examples/04_real_world_scenarios/ticket_triage_harness.py + +# 打真实网关(需要 DASHSCOPE_API_KEY;同一个 harness,无故障注入) +TRIAGE_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/ticket_triage_harness.py +``` + +离线演示分诊 5 张工单:T-2001 / T-2002 路由到各自团队,T-2003("你们这个系统到底怎么回事")低于置信度下限进入人工复核,T-2004 作为 P1 呼叫值班工程师,T-2005 遭遇注入的后端故障并降级到人工队列——最后汇总打印状态分布和精确的 input-token 账本。 + +### 改造成你自己的工单系统 + +1. **换进线队列**:`TICKETS` 是占位列表;接入真实工单流,保持模型需要评判的 `state` 形状(这里是 `ticket_id` / `content`)。 +2. **调策略而不是调模型**:`CONFIDENCE_FLOOR` / `ESCALATE_THRESHOLD` / `P1_SCORE` / `P2_SCORE` 是业务决策——根据观察到的路由质量标定它们。 +3. **接出口**:目前判定只是打印;生产环境把 `triaged` 推到团队队列、`escalated` 推到值班呼叫、`human_review` / `degraded` 推到人工分诊收件箱——verdict 对象已经带齐了任何下游需要的一切。 +4. **横向扩展**:决策 round 按工单互相独立,`run` 可以并行化;保持有界线程池,并让 SDK 的 `max_retries` 在每个非流式调用内部吸收 429/5xx。 diff --git a/examples/04_real_world_scenarios/customer_service_assistant.py b/examples/04_real_world_scenarios/customer_service_assistant.py new file mode 100644 index 00000000..f2ed547f --- /dev/null +++ b/examples/04_real_world_scenarios/customer_service_assistant.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 real-world scenario: an online customer-service +assistant with guardrails, tool access and multi-turn memory. + +This is a complete, production-shaped case rather than a one-line model +call. It stitches together the patterns a real support bot needs: + +* **Guardrailed persona** -- the system prompt scopes the bot to + order / logistics / refund topics, forbids inventing order data, and + tells it when to escalate to a human. +* **Business tools** -- the model reads real order state through + ``query_order`` / ``track_shipment`` / ``check_refund_eligible`` and + can open a support ticket via ``create_ticket`` (all backed here by a + small in-memory "database"; swap in your own service calls). +* **Sliding-window memory** -- :class:`ConversationMemory` pins the + system prompt, keeps only the most recent turns under a budget, and + never splits an assistant tool-call turn from its tool results, so a + long chat cannot blow the context window or corrupt the wire format. +* **Managed tool-call loop** -- the classic agent state machine with a + ``MAX_TURNS`` guard and defensive JSON handling. + +The demo runs a three-turn conversation where turn 2 ("can I refund +*it*?") only makes sense if the bot remembers the order id from turn 1, +which is exactly what the memory layer buys you. + +Requires DASHSCOPE_API_KEY (it calls the real chat endpoint). +""" + +import json +import os + +import dashscope + +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") +# Hard stop so a misbehaving model cannot loop on tools forever. +MAX_TURNS = 6 +# Number of post-system messages kept in context; older turns are +# dropped oldest-first. Tune against your model's context window. +MAX_HISTORY_TURNS = 20 + +SYSTEM_PROMPT = ( + "你是「示例商城」的在线客服助手。请严格遵守以下规则:\n" + "1. 只回答与订单、物流、退款、售后相关的问题;对无关问题礼貌说明" + "无法处理并引导用户回到售后话题。\n" + "2. 涉及具体订单时,必须调用工具查询真实数据后再回答," + "禁止编造订单状态、物流或退款结论。\n" + "3. 当无法解决问题、或用户明确要求人工时,调用 create_ticket " + "创建工单,并把工单号告诉用户。\n" + "4. 回答简洁、友好,使用中文。" +) + +# ---- a tiny stand-in order database (replace with your real backend) ---- # +ORDERS = { + "SO2024001": { + "item": "无线蓝牙耳机", + "amount": 299.0, + "status": "已发货", + "shipped_days": 2, + "carrier": "示例A速运", + "tracking_no": "EX1234567890", + "latest_event": "【杭州市】快件已到达杭州转运中心", + "signed": False, + }, + "SO2024002": { + "item": "机械键盘", + "amount": 459.0, + "status": "已签收", + "shipped_days": 12, + "carrier": "示例B物流", + "tracking_no": "EX9988776655", + "latest_event": "【北京市】已签收,签收人:本人", + "signed": True, + }, +} + +# Refund window in days after shipping, per store policy. +REFUND_WINDOW_DAYS = 7 + + +def query_order(order_id): + """Return the core state of one order.""" + order = ORDERS.get(order_id) + if order is None: + return {"found": False, "message": f"未找到订单 {order_id}"} + return { + "found": True, + "order_id": order_id, + "item": order["item"], + "amount": order["amount"], + "status": order["status"], + } + + +def track_shipment(order_id): + """Return carrier / tracking / latest logistics event.""" + order = ORDERS.get(order_id) + if order is None: + return {"found": False, "message": f"未找到订单 {order_id}"} + return { + "found": True, + "order_id": order_id, + "carrier": order["carrier"], + "tracking_no": order["tracking_no"], + "latest_event": order["latest_event"], + } + + +def check_refund_eligible(order_id): + """Decide refund eligibility from the store's 7-day policy.""" + order = ORDERS.get(order_id) + if order is None: + return { + "found": False, + "eligible": False, + "reason": f"未找到订单 {order_id}", + } + if order["shipped_days"] <= REFUND_WINDOW_DAYS and not order["signed"]: + return { + "found": True, + "eligible": True, + "reason": "发货未超过 7 天且未签收,可申请退款", + } + if order["signed"]: + reason = "订单已签收,需走退货流程,建议转人工处理" + else: + reason = f"发货已超过 {REFUND_WINDOW_DAYS} 天,暂不支持自助退款" + return {"found": True, "eligible": False, "reason": reason} + + +def create_ticket(order_id, reason): + """Open a human-support ticket; returns the ticket id.""" + ticket_id = f"TK{abs(hash(order_id + reason)) % 100000:05d}" + return { + "created": True, + "ticket_id": ticket_id, + "order_id": order_id, + "reason": reason, + } + + +# Tool schemas the model may call (OpenAI function shape, accepted by +# the native gateway too). +TOOLS = [ + { + "type": "function", + "function": { + "name": "query_order", + "description": "查询订单的商品、金额与当前状态", + "parameters": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "订单号,例如 SO2024001", + }, + }, + "required": ["order_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "track_shipment", + "description": "查询订单的承运商、运单号与最新物流轨迹", + "parameters": { + "type": "object", + "properties": { + "order_id": {"type": "string", "description": "订单号"}, + }, + "required": ["order_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "check_refund_eligible", + "description": "按商城退款政策判断订单是否可退款并给出原因", + "parameters": { + "type": "object", + "properties": { + "order_id": {"type": "string", "description": "订单号"}, + }, + "required": ["order_id"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "create_ticket", + "description": "创建人工客服工单,用于无法自助解决或用户要求转人工", + "parameters": { + "type": "object", + "properties": { + "order_id": {"type": "string", "description": "相关订单号"}, + "reason": { + "type": "string", + "description": "转人工的原因,简述用户诉求", + }, + }, + "required": ["order_id", "reason"], + }, + }, + }, +] + +TOOL_IMPL = { + "query_order": query_order, + "track_shipment": track_shipment, + "check_refund_eligible": check_refund_eligible, + "create_ticket": create_ticket, +} + + +class ConversationMemory: + """Sliding-window history that pins the system prompt. + + Two production concerns are handled here: + + * **Context budget** -- only the most recent ``max_turns`` messages + after the system prompt are kept, so a long conversation cannot + exceed the model's window. + * **Wire integrity** -- trimming from the front can leave an orphan + ``role="tool"`` message whose ``assistant`` tool-call turn was + dropped, which the API rejects. ``_trim`` drops any leading tool + messages so every request stays well-formed. + """ + + def __init__(self, system_prompt, max_turns=MAX_HISTORY_TURNS): + self._system = {"role": "system", "content": system_prompt} + self._turns = [] + self._max_turns = max_turns + + def add(self, message): + """Append one message and re-apply the window.""" + self._turns.append(message) + self._trim() + + def extend(self, messages): + """Append several messages and re-apply the window.""" + self._turns.extend(messages) + self._trim() + + def _trim(self): + if len(self._turns) > self._max_turns: + self._turns = self._turns[-self._max_turns :] + while self._turns and self._turns[0]["role"] == "tool": + self._turns.pop(0) + + def messages(self): + """The full wire-ready message list (system + kept turns).""" + return [self._system] + list(self._turns) + + +def _run_tool(name, arguments_json): + """Execute one tool call, always returning a JSON string result.""" + try: + args = json.loads(arguments_json or "{}") + except json.JSONDecodeError: + return json.dumps({"error": "invalid JSON arguments"}) + impl = TOOL_IMPL.get(name) + if impl is None: + return json.dumps({"error": f"unknown tool: {name}"}) + try: + return json.dumps(impl(**args), ensure_ascii=False) + except TypeError as exc: + return json.dumps({"error": f"bad arguments: {exc}"}) + + +def _dump_tool_calls(tool_calls): + """Serialize ToolCall models back to plain wire dicts.""" + return [ + { + "id": call.id, + "type": call.type, + "function": { + "name": call.function.name, + "arguments": call.function.arguments, + }, + } + for call in tool_calls + ] + + +def handle_user_turn(client, memory, user_input): + """Process one user message through the tool-call loop. + + The whole context is re-sent each turn (the API is stateless); the + model keeps calling tools until it returns a plain answer, which is + recorded back into ``memory`` and returned. + """ + memory.add({"role": "user", "content": user_input}) + for turn in range(MAX_TURNS): + # Non-streaming on purpose: a tool-call turn must be read as a + # complete message before we can execute it. For a pure-chat + # streaming reply see 01_atomic_capabilities/text_generation. + resp = client.chat.completions.create( + model=MODEL, + messages=memory.messages(), + tools=TOOLS, + ) + message = resp.choices[0].message + if not message.tool_calls: + answer = message.content or "" + memory.add({"role": "assistant", "content": answer}) + return answer + + # Echo the assistant tool-call turn verbatim, then feed back + # each tool result. Both go through memory so the window and + # wire-integrity rules keep applying. + memory.add( + { + "role": "assistant", + "content": message.content, + "tool_calls": _dump_tool_calls(message.tool_calls), + }, + ) + for call in message.tool_calls: + result = _run_tool( + call.function.name, + call.function.arguments, + ) + print( + f" · [turn {turn}] tool {call.function.name}" + f"({call.function.arguments}) -> {result}", + ) + memory.add( + { + "role": "tool", + "tool_call_id": call.id, + "name": call.function.name, + "content": result, + }, + ) + fallback = "抱歉,我暂时无法处理该请求,正在为您转接人工客服。" + memory.add({"role": "assistant", "content": fallback}) + return fallback + + +def main(): + """Run a three-turn support conversation against the real API.""" + client = dashscope.Client() + memory = ConversationMemory(SYSTEM_PROMPT) + + # Turn 2 refers to "this order" without repeating the id: it only + # works because ConversationMemory carries turn 1 forward. + conversation = [ + "我的订单 SO2024001 到哪了?", + "那这个订单现在能退款吗?", + "帮我转人工吧。", + ] + for user_input in conversation: + print(f"\n用户: {user_input}") + answer = handle_user_turn(client, memory, user_input) + print(f"客服: {answer}") + + print( + f"\n----- 对话结束,上下文保留 " f"{len(memory.messages())} 条消息 -----", + ) + + +if __name__ == "__main__": + main() diff --git a/examples/04_real_world_scenarios/production_ready_rag_harness.py b/examples/04_real_world_scenarios/production_ready_rag_harness.py new file mode 100644 index 00000000..5e3a0878 --- /dev/null +++ b/examples/04_real_world_scenarios/production_ready_rag_harness.py @@ -0,0 +1,717 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 real-world scenario: a production-ready RAG harness. + +This is not "how to call embeddings + chat". It is an industrial-grade +retrieval-augmented-generation engine that stitches the four things a +real knowledge-base service must get right into one runnable blueprint: + +1. **Streaming semantic retrieval** -- embed once, cosine top-k, then a + cross-encoder ``client.rerank`` pass for precision. +2. **Local token watermark** -- every retrieved passage is measured with + ``client.tokenization.count`` and only the passages that fit a local + token budget reach the model, so a fat context can never blow the + window or the bill. +3. **Rigid resilience** -- the SDK retries transient 429/5xx *within* a + region for normal calls (``Client(max_retries=)``), but a STREAMING + call is NOT retried by the SDK (``transport.open_stream`` checks the + status once and returns -- see dashscope/v2/transport/http.py). So the + harness owns exponential backoff for the streaming generate step, and + layers a per-region circuit breaker + cross-region failover on top. +4. **Graceful degradation** -- 429 / content-filter / total-outage are + caught and turned into a clean degraded answer instead of a stack + trace. + +It runs **fully offline and deterministically by default** through an +in-process ``httpx.MockTransport`` that serves canned embeddings / +rerank / tokenizer / chat envelopes and injects faults on demand, so you +can *see* the retry, failover and watermark logic work without spending +a token. Set ``RAG_HARNESS_LIVE=1`` to point the exact same engine at the +real gateway with ``DASHSCOPE_API_KEY``. +""" + +import json +import math +import os +import re +import time + +import httpx + +import dashscope +from dashscope.v2 import errors + +# ---- configuration (override via env) ---- # +EMBED_MODEL = os.environ.get("DASHSCOPE_EMBED_MODEL", "text-embedding-v4") +RERANK_MODEL = os.environ.get("DASHSCOPE_RERANK_MODEL", "gte-rerank-v2") +CHAT_MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") +LIVE = os.environ.get("RAG_HARNESS_LIVE") == "1" + +# Fail over across these regions, in order (offline demo only; real +# cross-region use needs a region-specific key + workspace). +REGIONS = ["cn-beijing", "ap-southeast-1"] +# Injected http_client needs a base_url: the chat resource sends an +# absolute URL, but embeddings/rerank/tokenizer send a relative path that +# resolves against the client's base_url (see local_mock_debugging.py). +MOCK_BASE = "https://mock.local" + +TOP_K = 4 # candidates pulled from the vector store before rerank +# Local token budget for the retrieved passages. In production derive it +# from the model's max context minus the instruction and a reserved +# answer allowance; here it is a fixed watermark that visibly trims. +CONTEXT_TOKEN_BUDGET = 140 +EMBED_DIM = 64 # mock embedding dimensionality +GEN_RETRIES = 2 # harness-owned retries for the streaming generate step + +CLOSED, OPEN, HALF_OPEN = "closed", "open", "half_open" + +QUERY = "DashScope 支持哪些模型能力?" + +# A tiny stand-in knowledge base; load chunks from files or a DB in prod. +CORPUS = [ + "DashScope 是阿里云百炼的模型服务 SDK,支持文本生成、向量、重排等能力。", + "文本生成通过 chat.completions 调用 qwen 系列大模型,支持流式与非流式。", + "文本向量(embedding)把句子映射为定长浮点向量,用于语义检索与聚类。", + "重排(rerank)用交叉编码器对候选文档按与查询的相关性重新打分。", + "百炼还提供图片生成、语音合成与识别、以及微调等多模态能力。", + "杭州是浙江省的省会,位于中国东南沿海,与本项目无关。", +] + + +# ===================== local vector & similarity utils ===================== # +def _cosine(vec_a, vec_b): + """Cosine similarity of two equal-length float vectors.""" + dot = sum(x * y for x, y in zip(vec_a, vec_b)) + norm_a = math.sqrt(sum(x * x for x in vec_a)) + norm_b = math.sqrt(sum(y * y for y in vec_b)) + if norm_a == 0.0 or norm_b == 0.0: + return 0.0 + return dot / (norm_a * norm_b) + + +def _mock_vector(text, dim=EMBED_DIM): + """Deterministic bag-of-characters pseudo-embedding for offline use. + + Real deployments get these from ``client.embeddings``; the mock needs + a stable vector so cosine retrieval is meaningful without a network. + """ + vec = [0.0] * dim + for ch in text: + vec[ord(ch) % dim] += 1.0 + norm = math.sqrt(sum(x * x for x in vec)) or 1.0 + return [x / norm for x in vec] + + +def _estimate_tokens(text): + """1 CJK char ~= 1 token: a safe upper-bound estimate for the mock.""" + return max(1, len(text)) + + +# ===================== circuit breaker & resilience ===================== # +class CircuitBreaker: + """Minimal per-region circuit breaker (CLOSED/OPEN/HALF_OPEN).""" + + def __init__(self, failure_threshold=2, reset_timeout=30.0): + self.failure_threshold = failure_threshold + self.reset_timeout = reset_timeout + self.state = CLOSED + self.failure_count = 0 + self._opened_at = 0.0 + + def allow(self): + """Whether a request may be attempted right now.""" + if self.state == OPEN: + if time.monotonic() - self._opened_at >= self.reset_timeout: + self.state = HALF_OPEN + return True + return False + return True + + def record_success(self): + self.failure_count = 0 + self.state = CLOSED + + def record_failure(self): + self.failure_count += 1 + if self.state == HALF_OPEN or ( + self.failure_count >= self.failure_threshold + ): + self.state = OPEN + self._opened_at = time.monotonic() + + +def _is_retryable(exc): + """Connection errors and 5xx / 429 are worth retrying or failing over.""" + if isinstance(exc, errors.APIConnectionError): + return True + status = getattr(exc, "status_code", None) + return status is not None and (status >= 500 or status == 429) + + +def retry_with_backoff(fn, retries, base_delay=0.0): + """Exponential backoff for calls the SDK will NOT retry itself. + + Streaming requests bypass the transport's retry loop, so the harness + owns this. ``base_delay=0`` keeps the offline demo instant; use ~0.5 + in production and honor a server Retry-After when present. + """ + attempt = 0 + while True: + try: + return fn() + except errors.DashScopeError as exc: + if not _is_retryable(exc) or attempt >= retries: + raise + delay = base_delay * (2**attempt) + print( + f" · generate retry {attempt + 1}/{retries} after " + f"{type(exc).__name__} (backoff {delay:.2f}s)", + ) + if delay: + time.sleep(delay) + attempt += 1 + + +class ResilientGateway: + """Run each pipeline step against the first healthy region. + + Within a region the SDK's own ``max_retries`` absorbs transient + errors for non-streaming calls; across regions this adds a circuit + breaker so a whole-region outage fails over instead of falling over. + """ + + def __init__(self, regions, client_factory, failure_threshold=2): + self._regions = list(regions) + self._factory = client_factory + self._breakers = { + region: CircuitBreaker(failure_threshold) + for region in self._regions + } + self._clients = {} + + def _client(self, region): + if region not in self._clients: + self._clients[region] = self._factory(region) + return self._clients[region] + + def run(self, op, fn, quiet=False): + """Return ``(region, result)`` from the first healthy region.""" + last_exc = None + for region in self._regions: + breaker = self._breakers[region] + if not breaker.allow(): + if not quiet: + print(f" [{region}] circuit OPEN -> skip {op}") + continue + try: + result = fn(self._client(region)) + breaker.record_success() + return region, result + except errors.DashScopeError as exc: + last_exc = exc + if not _is_retryable(exc): + raise # a 4xx is a real bug; never mask it + breaker.record_failure() + if not quiet: + print( + f" [{region}] {type(exc).__name__} " + f"status={exc.status_code} on {op} -> circuit " + f"{breaker.state}; failing over", + ) + raise errors.APIConnectionError( + f"all regions failed for {op}: {last_exc}", + ) from last_exc + + +# ===================== retrieve / watermark / generate ===================== # +class LocalVectorStore: + """In-memory vector DB (swap for Milvus / AnalyticDB / pgvector).""" + + def __init__(self, gateway, embed_model): + self._gateway = gateway + self._model = embed_model + self._docs = [] # list of (text, vector) + + def index(self, texts): + _, resp = self._gateway.run( + "embed-index", + lambda c: c.embeddings.create(model=self._model, input=texts), + ) + ordered = sorted(resp.embeddings, key=lambda item: item.index) + self._docs = list(zip(texts, [item.embedding for item in ordered])) + + def search(self, query, top_k): + """Return the top_k passages as ``(text, score)`` by cosine.""" + _, resp = self._gateway.run( + "embed-query", + lambda c: c.embeddings.create(model=self._model, input=query), + ) + query_vec = resp.embeddings[0].embedding + scored = [ + (_cosine(query_vec, vec), idx) + for idx, (_text, vec) in enumerate(self._docs) + ] + scored.sort(reverse=True) + return [(self._docs[idx][0], sim) for sim, idx in scored[:top_k]] + + +class TokenWatermark: + """Local token budget: only passages that fit reach the model.""" + + def __init__(self, gateway, model, budget): + self._gateway = gateway + self._model = model + self._budget = budget + + def count(self, text): + # quiet: tokenizing every passage is high-frequency, so it does + # not repeat the failover trace the embed/rerank/chat steps show. + _, resp = self._gateway.run( + "tokenize", + lambda c: c.tokenization.count(model=self._model, prompt=text), + quiet=True, + ) + return resp.input_tokens + + def fit(self, passages): + """Greedily keep ranked passages under budget. + + Returns ``(kept, used_tokens, dropped)``; ``dropped`` is how many + lowest-ranked passages the watermark refused to send. + """ + kept, used = [], 0 + for passage in passages: + tokens = self.count(passage) + if used + tokens > self._budget: + break + kept.append(passage) + used += tokens + return kept, used, len(passages) - len(kept) + + +class RagAnswer: + """Structured result so callers can log/observe, not just print.""" + + def __init__( + self, + answer, + citations=None, + served_region=None, + usage_total=None, + request_id=None, + kept=0, + dropped=0, + degraded=False, + error_code=None, + ): + self.answer = answer + self.citations = citations or [] + self.served_region = served_region + self.usage_total = usage_total + self.request_id = request_id + self.kept = kept + self.dropped = dropped + self.degraded = degraded + self.error_code = error_code + + +class ProductionRAGEngine: + """Embed -> rerank -> token-watermark -> resilient streaming chat.""" + + def __init__(self, gateway, *, base_delay=0.0): + self._gateway = gateway + self._store = LocalVectorStore(gateway, EMBED_MODEL) + self._watermark = TokenWatermark( + gateway, + CHAT_MODEL, + CONTEXT_TOKEN_BUDGET, + ) + self._base_delay = base_delay + + def ingest(self, corpus): + self._store.index(corpus) + + def _rerank(self, query, candidates): + _, resp = self._gateway.run( + "rerank", + lambda c: c.rerank.create( + model=RERANK_MODEL, + query=query, + documents=candidates, + top_n=len(candidates), + return_documents=True, + ), + ) + return [r.document.text for r in resp.results if r.document] + + def _open_stream(self, client, prompt): + # The SDK does not retry streams, so the harness backs off here. + return retry_with_backoff( + lambda: client.chat.completions.create( + model=CHAT_MODEL, + messages=[{"role": "user", "content": prompt}], + stream=True, + ), + GEN_RETRIES, + base_delay=self._base_delay, + ) + + def _generate(self, prompt): + region, stream = self._gateway.run( + "chat", + lambda c: self._open_stream(c, prompt), + ) + return region, _consume_stream(stream) + + def answer(self, query): + """Run the full pipeline and return a :class:`RagAnswer`. + + Every stage (retrieval, rerank, watermark, generation) sits + inside one guard, so a failure anywhere -- not just generation + -- degrades gracefully instead of raising to the caller. + """ + try: + return self._answer_or_raise(query) + except errors.RateLimitError as exc: + return self._degrade("系统繁忙(触发限流),请稍后重试。", exc) + except errors.ContentFilterError as exc: + return self._degrade("内容未通过安全审核,无法作答。", exc) + except errors.DashScopeError as exc: + return self._degrade("检索/生成服务暂不可用,已降级返回。", exc) + + def _answer_or_raise(self, query): + hits = self._store.search(query, TOP_K) + candidates = [text for text, _score in hits] + ranked = self._rerank(query, candidates) + kept, used, dropped = self._watermark.fit(ranked) + print( + f" token watermark: kept {len(kept)} passage(s), " + f"{used}/{CONTEXT_TOKEN_BUDGET} tokens, dropped {dropped}", + ) + if not kept: + return RagAnswer( + "资料不足,无法回答该问题。", + dropped=dropped, + degraded=True, + ) + prompt = _build_prompt(kept, query) + region, (text, usage, request_id) = self._generate(prompt) + return RagAnswer( + text, + citations=kept, + served_region=region, + usage_total=usage, + request_id=request_id, + kept=len(kept), + dropped=dropped, + ) + + @staticmethod + def _degrade(message, exc): + code = getattr(exc, "code", None) + print(f" graceful degrade: {type(exc).__name__} code={code}") + return RagAnswer(message, degraded=True, error_code=code) + + +def _build_prompt(passages, query): + context = "\n".join(f"[{i}] {p}" for i, p in enumerate(passages)) + return ( + "你是企业知识库助手。仅依据下面的资料回答问题,并在句末用 [n] " + "标注引用编号;若资料不足,直接回答:资料不足。\n" + f"资料:\n{context}\n\n问题:{query}" + ) + + +def _consume_stream(stream): + """Print deltas live and capture usage without a second iteration. + + A stream is a one-shot iterator: consuming ``text_stream`` and then + ``get_final_message()`` would see an empty tail. Iterating once and + folding content + usage together is the correct merge (the usage + counter arrives on the final chunk, not the first). + """ + parts, usage_total, request_id = [], None, None + print("客服回答: ", end="", flush=True) + for chunk in stream: + delta = chunk.delta + if delta is not None and delta.content: + parts.append(delta.content) + print(delta.content, end="", flush=True) + if getattr(chunk, "usage", None) is not None: + usage_total = chunk.usage.total_tokens + if getattr(chunk, "request_id", None): + request_id = chunk.request_id + print() + return "".join(parts), usage_total, request_id + + +# ===================== offline mock gateway w/ faults ===================== # +def _sse(events): + out = [] + for i, data in enumerate(events, 1): + out.append(f"id: {i}\ndata: {json.dumps(data)}\n\n") + return "".join(out) + + +def _fault(status, code, message): + # Retry-After: 0 keeps the SDK/harness backoff instant in the demo. + return httpx.Response( + status, + json={"code": code, "message": message}, + headers={"Retry-After": "0"}, + ) + + +class MockGateway: + """Routes by path, serves canned envelopes, injects faults on demand.""" + + def __init__(self): + self.down_regions = set() + self.rate_limit_next_chat = 0 + self.injected_429 = 0 + + def client_factory(self): + def factory(region): + transport = httpx.MockTransport( + lambda request: self._route(region, request), + ) + return dashscope.Client( + api_key="sk-mock", + workspace="ws-demo", + region=region, + max_retries=2, + http_client=httpx.Client( + transport=transport, + base_url=MOCK_BASE, + ), + ) + + return factory + + def _route(self, region, request): + # A region outage takes down every endpoint together, so the + # breaker accumulates failures across the whole pipeline. + if region in self.down_regions: + return _fault( + 503, + "ServiceUnavailable", + f"mocked regional outage in {region}", + ) + path = request.url.path + if path.endswith("/tokenizer"): + return self._tokenizer(request) + if "embeddings" in path: + return self._embeddings(request) + if "rerank" in path: + return self._rerank(request) + return self._chat(region, request) + + @staticmethod + def _embeddings(request): + body = json.loads(request.content) + texts = body["input"]["texts"] + embeddings = [ + {"index": i, "embedding": _mock_vector(text)} + for i, text in enumerate(texts) + ] + return httpx.Response( + 200, + json={ + "output": {"embeddings": embeddings}, + "usage": {"total_tokens": sum(map(len, texts))}, + "request_id": "mock-embed", + }, + ) + + @staticmethod + def _rerank(request): + body = json.loads(request.content) + query = body["input"]["query"] + documents = body["input"]["documents"] + query_vec = _mock_vector(query) + scored = [ + (_cosine(query_vec, _mock_vector(doc)), i, doc) + for i, doc in enumerate(documents) + ] + scored.sort(reverse=True) + results = [ + { + "index": i, + "relevance_score": round(sim, 4), + "document": {"text": doc}, + } + for sim, i, doc in scored + ] + return httpx.Response( + 200, + json={ + "output": {"results": results}, + "usage": {"total_tokens": len(documents)}, + "request_id": "mock-rerank", + }, + ) + + @staticmethod + def _tokenizer(request): + body = json.loads(request.content) + prompt = body["input"].get("prompt", "") + tokens = _estimate_tokens(prompt) + return httpx.Response( + 200, + json={ + "output": {"token_ids": list(range(tokens))}, + "usage": {"input_tokens": tokens}, + "request_id": "mock-tokenizer", + }, + ) + + def _chat(self, region, request): + if self.rate_limit_next_chat > 0: + self.rate_limit_next_chat -= 1 + self.injected_429 += 1 + return _fault(429, "Throttling", "mocked rate limit") + prompt = json.loads(request.content)["input"]["messages"][-1][ + "content" + ] + if request.headers.get("X-DashScope-SSE") == "enable": + return self._chat_stream(prompt, region) + return httpx.Response(200, json=self._chat_envelope(prompt, region)) + + @staticmethod + def _chat_envelope(prompt, region): + text = _canned_answer(prompt, region) + return { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": text}, + }, + ], + }, + "usage": { + "input_tokens": 20, + "output_tokens": 10, + "total_tokens": 30, + }, + "request_id": f"mock-chat-{region}", + } + + @staticmethod + def _chat_stream(prompt, region): + text = _canned_answer(prompt, region) + pieces = [text[i : i + 12] for i in range(0, len(text), 12)] + events = [ + { + "output": { + "choices": [ + { + "finish_reason": None, + "message": {"content": piece}, + }, + ], + }, + "request_id": f"mock-chat-{region}", + } + for piece in pieces + ] + events.append( + { + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": ""}}, + ], + }, + "usage": { + "input_tokens": 20, + "output_tokens": len(text), + "total_tokens": 20 + len(text), + }, + "request_id": f"mock-chat-{region}", + }, + ) + return httpx.Response( + 200, + text=_sse(events), + headers={"content-type": "text/event-stream"}, + ) + + +def _canned_answer(prompt, region): + """Offline stand-in answer that reflects the passages it was given. + + It reports how many [n] citations survived the token watermark, so + the demo output makes the trimming visible. Not real model output. + """ + markers = re.findall(r"\[(\d+)\]", prompt) + cites = "".join(f"[{m}]" for m in markers) + return ( + f"(离线 Mock 应答,节点 {region})根据知识库中 {len(markers)} 段" + f"资料:DashScope 覆盖文本生成、向量与重排等能力。{cites}" + ) + + +# ===================== wiring & scenario demo ===================== # +def build_engine(): + """Return ``(engine, mock_state_or_None)`` for offline or live mode.""" + if LIVE: + client = dashscope.Client(max_retries=2) + gateway = ResilientGateway(["default"], lambda _region: client) + return ProductionRAGEngine(gateway, base_delay=0.5), None + state = MockGateway() + gateway = ResilientGateway(REGIONS, state.client_factory()) + return ProductionRAGEngine(gateway, base_delay=0.0), state + + +def _report(result): + print(f" served by: {result.served_region}") + print(f" request_id: {result.request_id}, usage: {result.usage_total}") + print(f" degraded: {result.degraded}, error_code: {result.error_code}") + + +def _scenario_retrieval(engine): + print("\n=== 场景 A:标准检索问答(召回->水位->精排->流式生成)===") + print(f"用户提问: {QUERY}") + _report(engine.answer(QUERY)) + + +def _scenario_rate_limit(engine, state): + print("\n=== 场景 B:429 限流,harness 指数退避自动吸收(流式不被 SDK 重试)===") + state.rate_limit_next_chat = 1 + _report(engine.answer(QUERY)) + print(f" 注入并被吸收的 429 次数: {state.injected_429}") + + +def _scenario_failover(engine, state): + print("\n=== 场景 C:主地域推理 503,熔断 + 跨地域灾备切换 ===") + state.down_regions = {REGIONS[0]} + for i in range(1, 4): + print(f" -- 第 {i} 次问答 --") + result = engine.answer(QUERY) + print(f" served by: {result.served_region}") + + +def _scenario_degrade(engine, state): + print("\n=== 场景 D:全部地域不可用,优雅降级而非抛栈 ===") + state.down_regions = set(REGIONS) + result = engine.answer(QUERY) + print(f" 降级返回: {result.answer}") + print(f" degraded={result.degraded}, error_code={result.error_code}") + + +def main(): + """Ingest the corpus and run the resilience scenarios.""" + engine, state = build_engine() + print(f"模式: {'LIVE 真实网关' if LIVE else 'OFFLINE 本地 Mock'}") + engine.ingest(CORPUS) + _scenario_retrieval(engine) + if state is None: + print("\n(LIVE 模式跳过故障注入场景 B/C/D)") + return + _scenario_rate_limit(engine, state) + _scenario_failover(engine, state) + _scenario_degrade(engine, state) + + +if __name__ == "__main__": + main() diff --git a/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py b/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py new file mode 100644 index 00000000..1906a537 --- /dev/null +++ b/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py @@ -0,0 +1,541 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 real-world scenario: a concurrent realtime meeting +transcription harness. + +This is not "how to open one TingWu realtime session". It is an +industrial-grade base for a meeting platform that must transcribe many +rooms at once and keep each meeting's state and transcript intact even +when a socket drops. It stitches four production concerns into one +runnable blueprint: + +1. **Concurrency that respects the protocol.** Realtime TingWu is NOT + multiplexable: its wire protocol carries no task id, so one websocket + is exactly one session and closes when the session ends (see the + module note in dashscope/v2/transport/ws_connection.py). Concurrency + therefore means one *dedicated* connection + worker per meeting, and + the pool size is the admission control that caps how many live + sockets -- and how much blast radius -- you hold open at once. +2. **Local state machine per meeting.** Every room moves through + PENDING -> CONNECTING -> LISTENING -> TRANSCRIBING -> COMPLETED, with + RECONNECTING and DEGRADED as managed off-ramps, so a supervisor can + observe and report on hundreds of rooms uniformly. +3. **Cloud/local split for the transcript.** The cloud recognizes; the + SDK side *assembles*. TingWu streams incremental ``recognize-result`` + events that re-send the same ``sentenceId`` with growing text until + it is final. ``TranscriptAssembler`` merges those partials locally, + which also makes the transcript survive a mid-stream reconnect. +4. **Rigid resilience + isolation.** A dropped socket + (``APIConnectionError``) reconnects with exponential backoff; a + business ``task-failed`` (bad app id, content policy) is *not* + blindly retried -- it degrades that one room. Either way the failure + is contained: one room falling over never takes down the others. + +It runs **fully offline and deterministically by default** by injecting +a scripted duplex socket through TingWu's ``connector=`` seam (the same +seam tests/v2/test_tingwu.py uses), so you can *see* the concurrency, +reconnect and degradation without a microphone or a token. Set +``MEETING_HARNESS_LIVE=1`` to point the exact same engine at the real +gateway with ``DASHSCOPE_API_KEY`` + ``DASHSCOPE_TINGWU_APP_ID``. +""" + +import json +import os +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +import dashscope +from dashscope.v2 import errors + +# ---- configuration (override via env) ---- # +MODEL = os.environ.get("DASHSCOPE_TINGWU_MODEL", "tingwu-turbo") +APP_ID = os.environ.get("DASHSCOPE_TINGWU_APP_ID", "your-app-id") +TERMINOLOGY = os.environ.get("DASHSCOPE_TINGWU_TERMINOLOGY") or None +LIVE = os.environ.get("MEETING_HARNESS_LIVE") == "1" +AUDIO_FILE = os.environ.get( + "DASHSCOPE_DEMO_AUDIO", + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "tests", + "data", + "asr_example.wav", + ), +) + +# Bounded worker pool == bounded live sockets == admission control. +MAX_CONCURRENT = int(os.environ.get("MEETING_MAX_CONCURRENT", "4")) +RECONNECT_ATTEMPTS = 2 # per-meeting reconnects before degrading +BASE_DELAY = 0.0 # 0 keeps the offline demo instant; use ~0.5 in prod +SESSION_TIMEOUT = 30.0 + +SAMPLE_RATE = 16000 +FRAME_BYTES = 3200 # 100 ms of 16 kHz 16-bit mono PCM +FRAME_INTERVAL = 0.1 # realtime cadence for the live path only + +# Meeting states (a plain string enum keeps the report dependency-free). +PENDING = "pending" +CONNECTING = "connecting" +LISTENING = "listening" +TRANSCRIBING = "transcribing" +COMPLETED = "completed" +RECONNECTING = "reconnecting" +DEGRADED = "degraded" + + +# ===================== local transcript assembly ===================== # +class TranscriptAssembler: + """Merge streaming partials into final segments, keyed by sentenceId. + + TingWu realtime re-sends the same ``sentenceId`` with growing text + until the sentence is final. Keeping the latest text per id and + preserving first-seen order is the local half of the cloud/local + split -- and because the assembler outlives a single socket, the + transcript keeps its place across a mid-stream reconnect. + """ + + def __init__(self): + self._order = [] + self._text = {} + + def feed(self, sentence): + """Fold one recognize-result sentence in; return its id/text.""" + sid = sentence.get("sentenceId") + if sid is None: # no id: fall back to positional so nothing is lost + sid = len(self._order) + if sid not in self._text: + self._order.append(sid) + self._text[sid] = sentence.get("text", "") + return sid, self._text[sid], bool(sentence.get("end")) + + def segments(self): + return [self._text[sid] for sid in self._order if self._text[sid]] + + def text(self): + return "".join(self.segments()) + + +# ===================== per-meeting state machine ===================== # +class MeetingSession: + """One meeting: identity, state, transcript, and retry bookkeeping.""" + + def __init__(self, meeting_id, room, frames, pace=False): + self.meeting_id = meeting_id + self.room = room + self.frames = frames + self.pace = pace # True only for the live path (realtime cadence) + self.state = PENDING + self.assembler = TranscriptAssembler() + self.attempts = 0 + self.reconnects = 0 + self.error = None + + def transition(self, state, note=""): + """Move to ``state`` and log it, tagged so interleaving is readable. + + Meetings run on worker threads, so their log lines interleave; + the ``[meeting_id]`` prefix is what makes a concurrent run still + traceable per room. + """ + self.state = state + suffix = f" :: {note}" if note else "" + print(f" [{self.meeting_id}] -> {state}{suffix}") + + +def _is_reconnectable(exc): + """Only a dropped/timed-out socket is worth reopening. + + A ``task-failed`` event (bad app id, content policy, parameter error) + surfaces as a non-connection DashScopeError; reconnecting would just + fail again, so those degrade instead of retry. A raw socket timeout + from the websocket lib is not in the DashScope hierarchy, so it is + classified by name. + """ + if isinstance(exc, (errors.APIConnectionError, TimeoutError)): + return True + name = type(exc).__name__ + return "Timeout" in name or "ConnectionClosed" in name + + +def _handle_event(session, event): + output = (event.get("payload") or {}).get("output") or {} + if output.get("action") != "recognize-result": + return # speech-end just terminates the iterator; nothing to store + sentence = output.get("sentence") or {} + _sid, text, is_end = session.assembler.feed(sentence) + marker = "final" if is_end else "part " + print(f" [{session.meeting_id}] {marker} | {text}") + + +def _pump_audio(session, realtime): + """Send every frame, then signal end of audio (stop). + + Production variant: to transcribe *live* rather than after stop, + drain ``realtime.events()`` on a reader thread while frames are still + being sent -- the socket is duplex. The sequential shape here matches + 01_atomic_capabilities/audio_speech/tingwu.py and keeps the offline + mock deterministic. + """ + for frame in session.frames: + realtime.send_audio_frame(frame) + if session.pace: # only the live path paces to realtime cadence + time.sleep(FRAME_INTERVAL) + realtime.stop() + + +def _run_once(session, gateway): + """Open one dedicated socket and drive the meeting over it.""" + session.transition(CONNECTING) + with _open_realtime(gateway, session.meeting_id) as realtime: + # __enter__ already waited for task-started, so audio may flow. + session.transition(LISTENING) + _pump_audio(session, realtime) + session.transition(TRANSCRIBING) + for event in realtime.events(): + _handle_event(session, event) + + +def run_meeting(session, gateway): + """Drive one meeting to a terminal state; never raises. + + Containing every failure inside the per-meeting worker is what makes + the supervisor's isolation guarantee real: a socket drop reconnects + with backoff, anything else degrades this room and returns, leaving + the other meetings untouched. + """ + while True: + session.attempts += 1 + try: + _run_once(session, gateway) + session.transition( + COMPLETED, + f"{len(session.assembler.segments())} segment(s)", + ) + return session + except Exception as exc: # isolated per meeting by design + session.error = exc + reconnectable = _is_reconnectable(exc) + if reconnectable and session.reconnects < RECONNECT_ATTEMPTS: + session.reconnects += 1 + delay = BASE_DELAY * (2 ** (session.reconnects - 1)) + session.transition( + RECONNECTING, + f"{type(exc).__name__} backoff {delay:.2f}s " + f"(attempt {session.reconnects}/{RECONNECT_ATTEMPTS})", + ) + if delay: + time.sleep(delay) + continue + reason = type(exc).__name__ + if not reconnectable: + reason += " (business failure, not reconnectable)" + session.transition(DEGRADED, f"{reason}: {exc}") + return session + + +def _open_realtime(gateway, meeting_id): + client = gateway.client() + return client.tingwu.realtime( + model=MODEL, + app_id=gateway.app_id, + format="pcm", + sample_rate=SAMPLE_RATE, + terminology=TERMINOLOGY, + connector=gateway.connector(meeting_id), # None => real websocket + timeout=SESSION_TIMEOUT, + ) + + +# ===================== concurrency supervisor base ===================== # +class MeetingSupervisor: + """Run many meetings concurrently over bounded dedicated sockets. + + The pool size is deliberately the concurrency *and* the admission + cap: because each realtime session owns a socket + worker, holding + the pool bounded is what keeps a burst of join requests from opening + an unbounded number of live connections. + """ + + def __init__(self, gateway, max_concurrent): + self._gateway = gateway + self._max = max_concurrent + + def run(self, meetings): + order = {m.meeting_id: i for i, m in enumerate(meetings)} + results = [] + with ThreadPoolExecutor(max_workers=self._max) as pool: + futures = [ + pool.submit(run_meeting, meeting, self._gateway) + for meeting in meetings + ] + for future in as_completed(futures): + results.append(future.result()) # run_meeting never raises + results.sort(key=lambda s: order[s.meeting_id]) + return results + + +# ===================== scripted duplex mock gateway ===================== # +def _event(name, output=None): + payload = {"output": output} if output is not None else {} + return json.dumps( + { + "header": {"event": name, "task_id": "t", "request_id": "r"}, + "payload": payload, + }, + ) + + +def _recognize(text, sentence_id, end): + return _event( + "result-generated", + { + "action": "recognize-result", + "sentence": { + "text": text, + "sentenceId": sentence_id, + "end": end, + }, + }, + ) + + +_STARTED = _event("task-started") +_SPEECH_END = _event("result-generated", {"action": "speech-end"}) + + +class FakeDuplexSocket: + """A scripted duplex websocket that replays a canned event inbox. + + Mirrors tests/v2/test_tingwu.py's FakeWebSocket. ``recv`` returns the + next JSON frame, or ``None`` once the inbox is exhausted -- which the + SDK maps to ``APIConnectionError`` ("closed before task-finished"), + so an inbox that stops short of ``speech-end`` simulates a mid-stream + socket drop and drives the reconnect path. + """ + + def __init__(self, inbox): + self._inbox = list(inbox) + self.sent = [] + self.closed = False + + # pylint: disable=unused-argument + def send(self, payload, opcode=1): + # opcode must stay named ``opcode``: ws.py calls send(data, + # opcode=2) for binary audio frames. + self.sent.append(payload) + + def recv(self): + if not self._inbox: + return None # simulate a dropped socket / premature EOF + return self._inbox.pop(0) + + def settimeout(self, _seconds): + pass + + # pylint: enable=unused-argument + + def close(self): + self.closed = True + + +class MockMeetingGateway: + """Serve a per-meeting, per-attempt event script through ``connector=``. + + Each call to the connector builds a fresh ``FakeDuplexSocket`` from + the next scripted inbox for that meeting, so a meeting whose first + attempt drops can be given a completing second attempt -- exactly the + reconnect-and-resume flow the harness is meant to survive. + """ + + def __init__(self, scripts, app_id="app-mock"): + self.app_id = app_id + self._scripts = scripts # meeting_id -> list of inboxes (per attempt) + self._attempts = {} + self._client = dashscope.Client( + api_key="sk-mock", + workspace="ws-demo", + region="cn-beijing", + ) + + def client(self): + return self._client + + def connector(self, meeting_id): + def _connector(_url, _headers): + return FakeDuplexSocket(self._next_inbox(meeting_id)) + + return _connector + + def _next_inbox(self, meeting_id): + attempt = self._attempts.get(meeting_id, 0) + self._attempts[meeting_id] = attempt + 1 + script = self._scripts[meeting_id] + return script[min(attempt, len(script) - 1)] + + +class LiveGateway: + """Real gateway: no connector (the SDK opens a real websocket).""" + + def __init__(self, app_id): + self.app_id = app_id + self._client = dashscope.Client() + + def client(self): + return self._client + + def connector(self, _meeting_id): + return None + + +# ===================== audio frame source ===================== # +def _mock_frames(count=3): + return [bytes(FRAME_BYTES) for _ in range(count)] + + +def _read_pcm(path): + frames = [] + with open(path, "rb") as handle: + while True: + chunk = handle.read(FRAME_BYTES) + if not chunk: + break + frames.append(chunk) + return frames + + +# ===================== report ===================== # +def _report(results): + print("\n" + "=" * 70) + print("会议托管报告 / Meeting Supervisor Report") + print("=" * 70) + for session in results: + full = session.assembler.text() + preview = full[:36] + ("…" if len(full) > 36 else "") + print( + f" {session.meeting_id:<12} state={session.state:<13} " + f"attempts={session.attempts} reconnects={session.reconnects} " + f"segments={len(session.assembler.segments())}", + ) + print(f" room : {session.room}") + print(f" transcript: {preview or '(none)'}") + if session.error is not None: + print(f" last error: {type(session.error).__name__}") + completed = sum(1 for s in results if s.state == COMPLETED) + degraded = sum(1 for s in results if s.state == DEGRADED) + print("-" * 70) + print( + f" {completed} completed / {degraded} degraded / " + f"{len(results)} total -- one room failing never took down " + f"the others.", + ) + print("=" * 70) + + +# ===================== demo scenarios ===================== # +def _offline_scripts(): + """Four rooms that together exercise every resilience path. + + alpha/beta are clean; gamma drops mid-stream on attempt 0 and resumes + on attempt 1 (the partial sentence ``s2`` is completed after the + reconnect, proving the assembler survives it); delta hits a business + ``task-failed`` that must degrade, not reconnect. + """ + return { + "room-alpha": [ + [ + _STARTED, + _recognize("大家好,欢迎参加本次评审。", "s1", True), + _recognize("我们先同步一下进度。", "s2", True), + _SPEECH_END, + ], + ], + "room-beta": [ + [ + _STARTED, + _recognize("线上的同学能听到吗?", "s1", True), + _recognize("可以,我们开始吧。", "s2", True), + _recognize("先请产品同学介绍背景。", "s3", True), + _SPEECH_END, + ], + ], + "room-gamma": [ + # attempt 0: socket drops mid-sentence (no speech-end). + [ + _STARTED, + _recognize("这个季度的目标很明确。", "s1", True), + _recognize("今天我们讨论", "s2", False), + ], + # attempt 1: resumes and completes sentence s2, then ends. + [ + _STARTED, + _recognize("今天我们讨论第三季度的路线图。", "s2", True), + _recognize("结论下周同步给全员。", "s3", True), + _SPEECH_END, + ], + ], + "room-delta": [ + [ + _STARTED, + _event( + "result-generated", + { + "action": "task-failed", + "errorCode": "DataInspectionFailed", + "errorMessage": "content policy blocked this room", + }, + ), + ], + ], + } + + +def _run_offline_demo(): + print( + f"离线并发实时会议转写演示({MAX_CONCURRENT} 路并发上限," f"每场会议独占一条 websocket)\n", + ) + scripts = _offline_scripts() + gateway = MockMeetingGateway(scripts) + meetings = [ + MeetingSession( + mid, + f"Room {mid.split('-')[1].title()}", + _mock_frames(), + ) + for mid in scripts + ] + supervisor = MeetingSupervisor(gateway, MAX_CONCURRENT) + started = time.monotonic() + results = supervisor.run(meetings) + elapsed = time.monotonic() - started + print(f"\n并发跑完 {len(results)} 场会议,用时 {elapsed:.3f}s") + _report(results) + + +def _run_live(): + if APP_ID in ("", "your-app-id"): + print( + "MEETING_HARNESS_LIVE=1 需要设置 DASHSCOPE_TINGWU_APP_ID " + "(听悟应用 ID)与 DASHSCOPE_API_KEY。", + ) + return + if not os.path.exists(AUDIO_FILE): + print(f"找不到音频文件:{AUDIO_FILE}(可用 DASHSCOPE_DEMO_AUDIO 指定)") + return + frames = _read_pcm(AUDIO_FILE) + print(f"实时会议转写(live):{len(frames)} 帧 x {FRAME_BYTES}B,paced") + gateway = LiveGateway(APP_ID) + meeting = MeetingSession("live-room", "Live Room", frames, pace=True) + result = run_meeting(meeting, gateway) + _report([result]) + + +def main(): + if LIVE: + _run_live() + else: + _run_offline_demo() + + +if __name__ == "__main__": + main() diff --git a/examples/04_real_world_scenarios/ticket_triage_harness.py b/examples/04_real_world_scenarios/ticket_triage_harness.py new file mode 100644 index 00000000..38ba4cc7 --- /dev/null +++ b/examples/04_real_world_scenarios/ticket_triage_harness.py @@ -0,0 +1,493 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 real-world scenario: decision-model ticket triage. + +This is not "how to call ``client.decision.create``". It is the +production harness a ticket system needs *around* that call: + +1. **One decision round per ticket** -- the decision model + (``decision-model-preview``) answers a batch of typed questions + about the ticket in a single forward pass: which team owns it + (``choice``), whether to page the on-call engineer (``noul``) and + how severe it is (``score``). No text is generated, so latency and + cost stay flat per ticket. +2. **A local threshold policy** -- the model returns probability + distributions, never "what to do". Harness-owned cut lines (a + confidence floor, an escalation threshold, severity bands) turn + distributions into routing actions, so the policy is auditable and + tunable without touching the model. +3. **Fail-closed degradation** -- low routing confidence sends the + ticket to the HUMAN queue; a failed call -- an exception, or an + ``is_error`` envelope, which can ride HTTP 200 -- degrades the + ticket to the same queue with the error attached. No ticket is ever + auto-routed on a guess. +4. **Per-ticket isolation + a token ledger** -- one ticket's backend + failure never stops the batch, and ``usage.input_tokens`` (the only + quantity the decision model bills) is booked per ticket. + +It runs **fully offline and deterministically by default**: the mock +seam is the one-method decision backend, so ``MockDecisionBackend`` +serves scripted answers and injects a fault on demand while the +policy, degradation and ledger logic under test stays identical. Set +``TRIAGE_HARNESS_LIVE=1`` to run the exact same harness against the +real gateway with ``DASHSCOPE_API_KEY``. +""" + +import os + +import dashscope +from dashscope.v2 import errors + +# ---- configuration (override via env) ---- # +DECISION_MODEL = os.environ.get("DASHSCOPE_MODEL", "decision-model-preview") +LIVE = os.environ.get("TRIAGE_HARNESS_LIVE") == "1" + +# Policy thresholds: business decisions owned by the harness, not by +# the model. Tune them from observed routing quality. +CONFIDENCE_FLOOR = 0.6 # below this, routing goes to a human +ESCALATE_THRESHOLD = 0.8 # P(yes) required to page the on-call engineer +P1_SCORE = 2.5 # probability-weighted severity index cut lines +P2_SCORE = 1.5 +DECISION_TIMEOUT = 10.0 # per-call timeout in seconds (live mode) + +TRIAGED, ESCALATED, HUMAN_REVIEW, DEGRADED = ( + "triaged", + "escalated", + "human_review", + "degraded", +) + +# One decision round asks every question at once; ``answers`` come back +# keyed by these same question ids. Raw dicts show the wire shape; the +# typed ChoiceQuestion / NoulQuestion / ScoreQuestion models in +# dashscope.v2.resources.decision add local validation if wanted. +QUESTIONS = { + "department": { + "type": "choice", + "instructions": "应该由哪个团队处理这张工单?", + "criteria": { + "billing": "支付、退款和账单问题", + "technical": "产品故障、报错和集成问题", + "logistics": "发货、运输和签收问题", + "other": "不属于以上任何一类的问题", + }, + }, + "escalate": { + "type": "noul", + "instructions": "是否紧急到需要立即通知值班工程师?", + }, + "severity": { + "type": "score", + "instructions": "这张工单有多严重?", + "criteria": [ + "轻微问题,不影响功能使用", + "部分功能受影响,但存在替代方案", + "核心功能不可用,没有替代方案", + "造成严重业务资损或安全影响", + ], + }, +} + +# A stand-in ticket intake queue; load from your ticket system in prod. +TICKETS = [ + { + "ticket_id": "T-2001", + "content": "订单支付后超过 24 小时仍未到账,用户无法继续使用核心服务。", + }, + { + "ticket_id": "T-2002", + "content": "快递显示已签收但本人没有收到包裹,需要核实。", + }, + { + "ticket_id": "T-2003", + "content": "你们这个系统到底怎么回事,给个说法。", + }, + { + "ticket_id": "T-2004", + "content": "线上服务全面报错,所有用户无法登录,疑似严重故障。", + }, + { + "ticket_id": "T-2005", + "content": "发票抬头开错了,需要重新开具。", + }, +] + + +# ===================== verdict & policy (local, deterministic) =========== # +class TicketVerdict: + """Structured per-ticket outcome: what to do, why, and what it cost.""" + + def __init__( + self, + ticket_id, + state, + department=None, + priority=None, + escalate=False, + confidence=None, + reason="", + input_tokens=0, + latency_ms=None, + request_id=None, + error_code=None, + ): + self.ticket_id = ticket_id + self.state = state + self.department = department + self.priority = priority + self.escalate = escalate + self.confidence = confidence + self.reason = reason + self.input_tokens = input_tokens + self.latency_ms = latency_ms + self.request_id = request_id + self.error_code = error_code + + +class TriagePolicy: + """Turn answer distributions into a routing action. + + Every threshold here is a local business decision: the model + supplies distributions, the policy owns the consequences. + """ + + def __init__( + self, + confidence_floor=CONFIDENCE_FLOOR, + escalate_threshold=ESCALATE_THRESHOLD, + ): + self._floor = confidence_floor + self._escalate = escalate_threshold + + @staticmethod + def _priority(score): + # ``score`` is the probability-WEIGHTED level index (0-based, + # and it may fall between two levels), not an integer class. + if score is None: + return "P3" + if score >= P1_SCORE: + return "P1" + if score >= P2_SCORE: + return "P2" + return "P3" + + def decide(self, ticket_id, response): + answers = response.answers + department = answers.get("department") + escalate = answers.get("escalate") + severity = answers.get("severity") + dept_name = getattr(department, "choice", None) + dept_conf = getattr(department, "confidence", None) + p_yes = getattr(escalate, "noul", None) or 0.0 + usage = getattr(response, "usage", None) + base = { + "department": dept_name, + "priority": self._priority(getattr(severity, "score", None)), + "escalate": p_yes >= self._escalate, + "confidence": dept_conf, + "input_tokens": getattr(usage, "input_tokens", 0) or 0, + "latency_ms": getattr(response, "latency_ms", None), + "request_id": getattr(response, "request_id", None), + } + # Fail closed #1: uncertain routing is worse than no routing. + if dept_conf is None or dept_conf < self._floor: + return TicketVerdict( + ticket_id, + HUMAN_REVIEW, + reason=f"路由置信度 {dept_conf} 低于阈值 {self._floor}", + **base, + ) + # Fail closed #2: a high P(yes) pages a human by design. + if p_yes >= self._escalate: + return TicketVerdict( + ticket_id, + ESCALATED, + reason=f"P(需值班介入)={p_yes:.2f} 达到阈值 {self._escalate}", + **base, + ) + return TicketVerdict( + ticket_id, + TRIAGED, + reason="置信度与阈值检查均通过", + **base, + ) + + +# ===================== decision backends (the mock seam) ================= # +class DashScopeDecisionBackend: + """Live backend: one ``client.decision.create`` round per ticket. + + Only constructed in LIVE mode, so the offline demo never touches + the network. ``decision.create`` is non-streaming, so the SDK's own + ``max_retries`` absorbs transient 429/5xx before this ever raises. + Note the endpoint lives under the compatible-mode path on every + gateway: the client's chat ``protocol`` setting does not move it. + """ + + def __init__(self, client): + self._client = client + + def decide(self, *, model, state, questions, timeout): + return self._client.decision.create( + model=model, + state=state, + questions=questions, + timeout=timeout, + ) + + +class MockAnswer: + """Attribute-compatible stand-in for the SDK's DecisionAnswer.""" + + def __init__(self): + self.type = None + self.choice = None + self.noul = None + self.score = None + self.probabilities = None + self.confidence = None + self.legend = None + + +class MockDecisionResponse: + """Attribute-compatible stand-in for client.decision's response.""" + + def __init__(self, answers, input_tokens, latency_ms, request_id): + self.answers = answers + self.usage = type("Usage", (), {"input_tokens": input_tokens})() + self.latency_ms = latency_ms + self.request_id = request_id + self.is_error = False + self.code = None + self.message = None + self.status_code = 200 + + +def _spread(winner, confidence, options): + """Probabilities for a choice answer: winner + equal remainder.""" + others = [name for name in options if name != winner] + share = round((1.0 - confidence) / len(others), 4) if others else 0.0 + probs = {name: share for name in others} + probs[winner] = confidence + return probs + + +def _mock_answers(spec, questions): + dept_name, dept_conf = spec["department"] + department = MockAnswer() + department.type = "choice" + department.choice = dept_name + department.confidence = dept_conf + department.probabilities = _spread( + dept_name, + dept_conf, + questions["department"]["criteria"], + ) + + p_yes = spec["escalate"] + escalate = MockAnswer() + escalate.type = "noul" + escalate.noul = p_yes + escalate.probabilities = {"no": round(1.0 - p_yes, 4), "yes": p_yes} + + score_value, score_conf = spec["severity"] + levels = questions["severity"]["criteria"] + severity = MockAnswer() + severity.type = "score" + severity.score = score_value + severity.confidence = score_conf + severity.legend = {str(i): text for i, text in enumerate(levels)} + center = int(round(score_value)) + severity.probabilities = { + str(i): round(score_conf if i == center else 0.05, 4) + for i in range(len(levels)) + } + return { + "department": department, + "escalate": escalate, + "severity": severity, + } + + +# Scripted per-ticket outcomes: department (name, confidence), escalate +# P(yes), severity (weighted index, confidence). T-2003 is deliberately +# vague so its low confidence exercises the human-review path. +MOCK_SCRIPT = { + "T-2001": { + "department": ("billing", 0.93), + "escalate": 0.06, + "severity": (2.3, 0.81), + }, + "T-2002": { + "department": ("logistics", 0.88), + "escalate": 0.12, + "severity": (1.4, 0.77), + }, + "T-2003": { + "department": ("other", 0.34), + "escalate": 0.08, + "severity": (0.9, 0.41), + }, + "T-2004": { + "department": ("technical", 0.97), + "escalate": 0.96, + "severity": (2.9, 0.93), + }, +} + +# Tickets whose decision call "fails" in the offline demo. +FAIL_TICKETS = {"T-2005"} + + +class MockDecisionBackend: + """Offline stand-in for the decision resource. + + Serves scripted, attribute-compatible answers keyed by ticket id + and raises a typed SDK error for tickets in ``fail_ids``, so the + demo exercises the low-confidence and the degradation path without + a network. Swap for :class:`DashScopeDecisionBackend` to go live. + """ + + def __init__(self, script, fail_ids=()): + self._script = script + self._fail_ids = set(fail_ids) + + def decide(self, *, model, state, questions, timeout): + ticket_id = state.get("ticket_id", "?") + if ticket_id in self._fail_ids: + raise errors.APIConnectionError( + f"mocked decision backend outage for {ticket_id}", + ) + answers = _mock_answers(self._script[ticket_id], questions) + return MockDecisionResponse( + answers, + input_tokens=40 + len(state.get("content", "")), + latency_ms=min(37.5, timeout * 1000), + request_id=f"mock-{model}-{ticket_id}", + ) + + +# ===================== the harness ===================== # +class TicketTriageHarness: + """Batch triage driver: per-ticket isolation + a token ledger. + + A backend failure becomes a DEGRADED verdict for that ticket alone; + the batch never stops. The decision model generates no text, so + ``usage.input_tokens`` is the whole bill -- the ledger is the cost + model, exact rather than estimated. + """ + + def __init__( + self, + backend, + policy, + model=DECISION_MODEL, + timeout=DECISION_TIMEOUT, + ): + self._backend = backend + self._policy = policy + self._model = model + self._timeout = timeout + self.total_input_tokens = 0 + self.triaged_ok = 0 + + def triage(self, ticket): + """Triage one ticket; never raises for backend failures.""" + ticket_id = ticket["ticket_id"] + try: + response = self._backend.decide( + model=self._model, + state=ticket, + questions=QUESTIONS, + timeout=self._timeout, + ) + except errors.DashScopeError as exc: + return self._degrade(ticket_id, exc=exc) + # Belt and suspenders: DashScope can report a failure with HTTP + # 200, so under raise_on_error=False the failure arrives as an + # envelope, not an exception. Never trust an is_error body. + if getattr(response, "is_error", False): + return self._degrade(ticket_id, envelope=response) + verdict = self._policy.decide(ticket_id, response) + self.total_input_tokens += verdict.input_tokens + self.triaged_ok += 1 + return verdict + + def run(self, tickets): + verdicts = [] + for ticket in tickets: + verdict = self.triage(ticket) + self._report(ticket, verdict) + verdicts.append(verdict) + return verdicts + + @staticmethod + def _degrade(ticket_id, exc=None, envelope=None): + if envelope is not None: + code = getattr(envelope, "code", None) + note = getattr(envelope, "message", None) + else: + code = getattr(exc, "code", None) or type(exc).__name__ + note = str(exc) + return TicketVerdict( + ticket_id, + DEGRADED, + reason=f"决策后端不可用({note}),已转人工队列,不凭空猜测路由", + error_code=code, + ) + + @staticmethod + def _report(ticket, verdict): + print(f"\n 工单 {ticket['ticket_id']}: {ticket['content'][:22]}...") + print( + f" 状态={verdict.state}, 团队={verdict.department}, " + f"优先级={verdict.priority}, 值班介入={verdict.escalate}", + ) + print( + f" 置信度={verdict.confidence}, " + f"input_tokens={verdict.input_tokens}, " + f"request_id={verdict.request_id}", + ) + print(f" 判定依据: {verdict.reason}") + if verdict.error_code: + print(f" error_code: {verdict.error_code}") + + +# ===================== wiring & demo ===================== # +def build_harness(): + """Build the harness on a live or scripted decision backend.""" + if LIVE: + client = dashscope.Client(max_retries=2) + backend = DashScopeDecisionBackend(client) + else: + backend = MockDecisionBackend(MOCK_SCRIPT, fail_ids=FAIL_TICKETS) + return TicketTriageHarness(backend, TriagePolicy()) + + +def main(): + harness = build_harness() + mode = "LIVE 真实网关" if LIVE else "OFFLINE 脚本化 Mock 后端" + print(f"模式: {mode},模型: {DECISION_MODEL}") + print( + f"策略阈值: 置信度下限={CONFIDENCE_FLOOR}, " + f"值班介入 P(yes)>={ESCALATE_THRESHOLD}, " + f"严重度 P1/P2 分数线={P1_SCORE}/{P2_SCORE}", + ) + if LIVE: + print("(LIVE 模式:全部工单走真实决策模型,无故障注入)") + verdicts = harness.run(TICKETS) + counts = {} + for verdict in verdicts: + counts[verdict.state] = counts.get(verdict.state, 0) + 1 + print("\n===== 批次汇总 =====") + print(f" 工单总数: {len(verdicts)}, 状态分布: {counts}") + print( + f" 自动路由成功 {harness.triaged_ok} 单," + f"计费 input_tokens 合计 {harness.total_input_tokens}", + ) + print(" 说明: 决策模型只按输入 token 计费(不生成文本);") + print(" 转人工/降级的工单不计入自动路由量。") + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..002bfafc --- /dev/null +++ b/examples/README.md @@ -0,0 +1,158 @@ +# DashScope SDK Examples — Production Best Practices for Model Studio + +> **English** | [中文](README_zh.md) + +This is not an "API call reference". In the age of AI-assisted development, anyone can generate a one-line `call(model="xxx")` in seconds — the value of this directory is that it **writes those few hundred lines of "grubby code" needed to push a Model Studio application to production, correctly, in advance**: every advanced file is a miniature production-grade harness blueprint with rigid engineering defenses, targeting a specific development or business scenario. Everything is built on the 2.0 resource-style client `dashscope.Client()` (consistent with `dashscope.v2` inside the package); legacy 1.x examples are preserved intact under `legacy_1x/`. + +A qualified best-practice case must demonstrate three dimensions at once: + +1. **Solving real-world business/engineering pain points** (enterprise knowledge-base Q&A, high-concurrency realtime meeting transcription, online intelligent customer service, and so on). +2. **Cloud LLM × local SDK engineering synergy** (local token-watermark trimming, graceful streaming incremental merge, transcript assembly across reconnects). +3. **Production-grade rigid defense and resilience governance** (graceful 429/500 handling, timeout backoff and retry, multi-region circuit breaking and disaster recovery, state-machine management, per-room fault isolation). + +## Flagship cases (read these three first) + +| Case | In one line | Runs offline | +| --- | --- | --- | +| [`04_real_world_scenarios/production_ready_rag_harness.py`](./04_real_world_scenarios/production_ready_rag_harness.py) | Industrial-grade RAG: streaming semantic retrieval + local token watermarks + streaming backoff retry (the SDK does not retry streams) + multi-region circuit breaking and failover + graceful degradation | ✅ | +| [`04_real_world_scenarios/realtime_audio_meeting_harness.py`](./04_real_world_scenarios/realtime_audio_meeting_harness.py) | High-concurrency realtime meeting transcription: one dedicated websocket per room (realtime cannot be multiplexed) + meeting state machine + reconnects + transcript merge across reconnects + per-room isolation and degradation | ✅ | +| [`04_real_world_scenarios/customer_service_assistant.py`](./04_real_world_scenarios/customer_service_assistant.py) | Online intelligent customer service: guardrailed persona + business tools + sliding-window multi-turn memory (with orphan tool-message trimming) + managed tool loop | Needs a key | + +> All three flagship cases embed source-level insights into *why* the code is written the way it is (e.g. **the SDK does not auto-retry streaming requests**, **the realtime protocol cannot be multiplexed**); see [`04_real_world_scenarios/README.md`](./04_real_world_scenarios/README.md). + +## Directory layout + +``` +examples/ +├── 01_atomic_capabilities/ Atomic capability quickref (minimal runnable; also the authoritative wire-shape seeds for AI) +├── 02_advanced_engineering/ Engineering patterns: local mock debugging, semantic cache, circuit breaker + multi-region DR +├── 03_agent_recipes/ Agent recipes: tool-call loop, RAG, MCP server +├── 04_real_world_scenarios/ Flagship best practices: stitch the three tiers above into end-to-end production harnesses +└── legacy_1x/ 1.x module-level API examples (dashscope.Generation etc.), for reference only +``` + +What the tiers mean (an advancement path from "quickref" to "production blueprint"): + +- **01 Atomic capabilities (quickref seeds)**: one file per capability, minimal but runnable. Deliberately kept simple — it is the authoritative seed for looking up wire shapes, and for feeding Tongyi Lingma / Cursor so they generate correct code. Each file ends with a "production springboard" footer pointing at the 02/03/04 cases that harden it. +- **02 Advanced engineering**: wraps the SDK in the patterns real production cares about (saving money, resisting pressure, offline debugging). +- **03 Agent recipes**: multi-step, composable end-to-end agent flows. +- **04 Real-world scenarios**: not "how to call some capability", but a whole real business built end to end into a production-grade harness (with guardrails, context budgets, fault tolerance, disaster recovery, state machines and other production tradeoffs); each case ships its own `README.md` explaining "why it is written this way". +- **legacy_1x**: the old 1.x module-level style, subdirectory layout kept exactly as-is, for reference only; new projects should prefer the four tiers above. + +## Getting ready + +1. Install the SDK: + + ```bash + pip install -U dashscope + ``` + +2. Configure your Model Studio API key ([how to get one](https://help.aliyun.com/zh/model-studio/get-api-key)): + + ```bash + export DASHSCOPE_API_KEY="sk-xxx" + ``` + +3. Run any example file directly (from the repository root): + + ```bash + python3 examples/01_atomic_capabilities/text_generation/chat.py + ``` + +> **Runs offline (no API key needed)**: `02_advanced_engineering/local_mock_debugging.py`, `02_advanced_engineering/circuit_breaker_retry.py`, `03_agent_recipes/mcp_server_demo/client_demo.py`, `04_real_world_scenarios/production_ready_rag_harness.py`, `04_real_world_scenarios/realtime_audio_meeting_harness.py` and `04_real_world_scenarios/ticket_triage_harness.py` use mock transports / scripted seams / subprocesses — fully offline and deterministic, ideal for a first hands-on look at the call shapes. + +## Common environment variables + +| Variable | Purpose | Default | +| --- | --- | --- | +| `DASHSCOPE_API_KEY` | Model Studio API key (required for networked examples) | none | +| `DASHSCOPE_MODEL` | Overrides the chat/generation model (chat, cache, RAG, tool loop, etc.) | per-file default (mostly `qwen-plus`) | +| `DASHSCOPE_EMBED_MODEL` | Overrides the embedding model (`client_side_cache.py`, `rag_expert_system.py`) | `text-embedding-v4` | +| `DASHSCOPE_RERANK_MODEL` | Overrides the rerank model (`rag_expert_system.py`) | `gte-rerank-v2` | +| `DASHSCOPE_WORKSPACE` | AgentStudio workspace | `trial` | +| `DASHSCOPE_APP_ID` | Model Studio application ID (application.py) | none | +| `DASHSCOPE_FILE_ID` | File ID (assistant_api_file.py) | none | +| `DASHSCOPE_DEMO_AUDIO` | Local audio path (tingwu_realtime.py, tingwu.py) | `tests/data/asr_example.wav` | +| `DASHSCOPE_TINGWU_APP_ID` | TingWu application ID (tingwu_realtime.py, tingwu.py) | none | +| `DASHSCOPE_TTS_VOICE` | TTS voice (audio_tts.py) | `zhichu` | +| `DASHSCOPE_TRAINING_FILE_ID` | Fine-tuning training file ID (fine_tunes.py) | none | +| `DASHSCOPE_DEMO_IMAGE` | Local image path (cv_models.py) | `~/Downloads/cat.png` | +| `DASHSCOPE_DEMO_VIDEO_URL` | Video URL (multimodal_conversation/video.py) | sample video | +| `RAG_HARNESS_LIVE` | Set to `1` for `production_ready_rag_harness.py` to hit the real gateway (otherwise offline mock) | unset (offline) | +| `MEETING_HARNESS_LIVE` | Set to `1` for `realtime_audio_meeting_harness.py` to hit the real gateway (otherwise offline mock) | unset (offline) | +| `TRIAGE_HARNESS_LIVE` | Set to `1` for `ticket_triage_harness.py` to hit the real gateway (otherwise scripted decision backend) | unset (offline) | +| `DASHSCOPE_TINGWU_MODEL` | TingWu realtime model (`realtime_audio_meeting_harness.py`) | `tingwu-turbo` | +| `MEETING_MAX_CONCURRENT` | Realtime meeting concurrency cap (i.e. the active-socket cap) | `4` | + +## 01_atomic_capabilities — Atomic capabilities + +Uniform style: `client = dashscope.Client()` (reads the key from the `DASHSCOPE_API_KEY` environment variable by default). + +| Path | Contents | +| --- | --- | +| `text_generation/chat.py` | Text generation: one-line `dashscope.chat()`, non-streaming, streaming (text_stream) | +| `text_generation/tokenization.py` | Token counting | +| `multimodal_vision/multimodal_chat.py` | Multimodal chat (qwen-vl image understanding) | +| `audio_speech/audio_tts.py` | Speech synthesis (one-shot + streaming) | +| `audio_speech/audio_asr.py` | Batch speech recognition | +| `audio_speech/tingwu.py` | TingWu offline tasks and realtime sessions | +| `image_video_synthesis/images.py` | Image generation: one-call generate-and-wait + the manual jobs flow | +| `image_video_synthesis/videos.py` | Video generation: one-call generate-and-wait + the manual jobs flow | +| `embeddings/embeddings.py` | Text embedding (with batch-task entry notes) | +| `rerank/rerank.py` | Document reranking | +| `decision/decision.py` | Decision model (System One): typed choice / noul / score questions over a business state in one call | +| `files/files.py` | File management: upload / list / get / delete | +| `applications/apps.py` | Model Studio application invocation (non-streaming + streaming) | +| `fine_tuning/fine_tunes.py` | Fine-tune jobs: list / get, create / wait / delete (uncomment to run) | +| `models/models.py` | Model list and get | + +## 02_advanced_engineering — Advanced engineering + +| Path | Contents | Network | +| --- | --- | --- | +| `local_mock_debugging.py` | Injects `httpx.MockTransport` to run the success path and the error paths (400 / throttling / content safety) offline at zero cost; swap `_mock_client()` for `dashscope.Client()` to hit the real gateway | No | +| `client_side_cache.py` | Client-side semantic cache: embeddings + cosine similarity catch repeat / near-repeat questions; a hit returns immediately without calling the model, saving tokens directly | Yes (only on cache miss) | +| `circuit_breaker_retry.py` | Circuit breaker + multi-region disaster recovery: on top of the SDK's built-in Retry-After backoff, per-region CLOSED/OPEN/HALF_OPEN breaking with automatic failover; the demo uses mock transports (primary region 503, secondary 200) and runs offline | No | + +## 03_agent_recipes — Agent recipes + +| Path | Contents | Network | +| --- | --- | --- | +| `tool_call_loop.py` | Managed multi-turn tool-call state machine: declare `tools` schemas, execute locally when the model requests a call, feed the `role="tool"` result back, and loop until a final answer (with a `MAX_TURNS` backstop) | Yes | +| `rag_expert_system.py` | Standard RAG: corpus embedding -> cosine top-k recall -> `client.rerank` refinement -> an "answer only from the sources" chat prompt -> an answer with citations | Yes | +| `mcp_server_demo/` | Dependency-free MCP (Model Context Protocol) stdio server + client demo: `server.py` speaks JSON-RPC 2.0; `client_demo.py` spawns the subprocess and runs initialize -> tools/list -> tools/call. See its `README.md` | No | + +## 04_real_world_scenarios — Real-world scenarios + +Composes `01`–`03` into complete, directly comparable business cases with production tradeoffs such as guardrails, context budgets and fault tolerance; every case ships its own `README.md` covering the design tradeoffs and how to adapt it. + +| Path | Contents | Network | +| --- | --- | --- | +| `customer_service_assistant.py` | E-commerce online customer service: guardrailed persona + business tools (orders / logistics / refunds / tickets) + sliding-window multi-turn memory (with orphan tool-message trimming) + managed tool loop; the demo runs a three-turn dialogue that depends on context | Yes | +| `production_ready_rag_harness.py` | Industrial-grade RAG engine: streaming semantic retrieval + local token watermarks (measured segment by segment via tokenization) + streaming backoff retry (the SDK does not retry streams) + multi-region circuit breaking and failover + graceful degradation; runs offline with mocks by default, demonstrating scenarios A/B/C/D | No | +| `realtime_audio_meeting_harness.py` | High-concurrency realtime meeting transcription base: one dedicated websocket per meeting (realtime cannot be multiplexed) + meeting state machine + reconnect backoff + local transcript merge across reconnects + per-room fault isolation and degradation; runs offline with mocks by default, with 4 concurrent rooms demonstrating reconnect and degradation | No | +| `ticket_triage_harness.py` | Decision-model ticket triage: one decision round per ticket (choice / noul / score in a single forward pass) + local threshold policy (confidence floor, escalation cut, severity bands) + fail-closed degradation to the human queue + per-ticket isolation and an exact input-token cost ledger; runs offline with a scripted decision backend by default | No | + +See [`04_real_world_scenarios/README.md`](./04_real_world_scenarios/README.md). + +## legacy_1x — 1.x module-level API (reference only) + +The following use the old 1.x style (`dashscope.Generation`, `dashscope.MultiModalConversation`, etc.). The internal subdirectory layout is kept exactly as-is and the content is untouched. New projects should use the 2.0 tiered examples above. + +| Path | Contents | +| --- | --- | +| `generation/` | Generation text generation: basics, deep thinking, tool calls, deep research, custom session | +| `aio_generation/` | Async versions of the above | +| `multimodal_conversation/` | MultiModalConversation multimodal chat: image, video, tool calls, OCR, ASR, deep thinking | +| `aio_multimodal_conversation/` | Async versions of the above | +| `application.py` | Model Studio application (Application) invocation | +| `agentstudio.py` / `agentstudio_deployments.py` | AgentStudio sessions and managed deployments | +| `assistant_api*.py` | Assistant agents: create / update, file attachment, end-to-end dialogue | +| `image_generation.py` / `image_synthesis.py` / `aio_image_synthesis.py` | Image generation (wanx / wan2.x) | +| `video_synthesis.py` / `aio_video_synthesis.py` | Video generation (wan series) | +| `multimodal_embedding.py` | Multimodal embedding | +| `text_rerank.py` | Document reranking | +| `qwen_tts.py` / `qwen_asr.py` | Qwen speech synthesis and recognition | +| `tingwu_realtime.py` / `tingwu_usages.py` | TingWu realtime and offline tasks | +| `cv_models.py` | CV models (portrait style repainting, with local upload) | +| `set_region_example.py` | Multi-region (region) configuration | diff --git a/examples/README_zh.md b/examples/README_zh.md new file mode 100644 index 00000000..906b454e --- /dev/null +++ b/examples/README_zh.md @@ -0,0 +1,158 @@ +# DashScope SDK 示例 —— 百炼(Model Studio)生产最佳实践 + +> [English](README.md) | **中文** + +这不是一份"API 调用参考"。在 AI 辅助开发的时代,任何人都能几秒钟生成一行 `call(model="xxx")`——本目录的价值在于**提前、正确地把一个百炼应用推向生产所需的那几百行"脏活代码"写好**:每个进阶文件都是一个微型的生产级 harness 蓝图,带有严格的工程防御,面向一个具体的开发或业务场景。全部示例基于 2.0 资源风格客户端 `dashscope.Client()`(与包内 `dashscope.v2` 一致);旧版 1.x 示例完整归档在 `legacy_1x/` 下。 + +一个合格的最佳实践案例必须同时展现三个维度: + +1. **解决真实业务/工程痛点**(企业知识库问答、高并发实时会议转写、在线智能客服等)。 +2. **云端大模型 × 本地 SDK 工程协同**(本地 token 水位裁剪、流式增量优雅合并、跨重连转写稿拼装)。 +3. **生产级刚性防御与弹性治理**(429/500 优雅处理、超时退避重试、多地域熔断容灾、状态机管理、按房间故障隔离)。 + +## 旗舰案例(先读这三个) + +| 案例 | 一句话 | 离线运行 | +| --- | --- | --- | +| [`04_real_world_scenarios/production_ready_rag_harness.py`](./04_real_world_scenarios/production_ready_rag_harness.py) | 工业级 RAG:流式语义检索 + 本地 token 水位 + 流式退避重试(SDK 不重试流式)+ 多地域熔断与故障切换 + 优雅降级 | ✅ | +| [`04_real_world_scenarios/realtime_audio_meeting_harness.py`](./04_real_world_scenarios/realtime_audio_meeting_harness.py) | 高并发实时会议转写:每个会议室一条独立 websocket(realtime 无法复用)+ 会议状态机 + 断线重连 + 跨重连转写稿合并 + 按房间隔离与降级 | ✅ | +| [`04_real_world_scenarios/customer_service_assistant.py`](./04_real_world_scenarios/customer_service_assistant.py) | 在线智能客服:护栏化人设 + 业务工具 + 滑动窗口多轮记忆(含孤儿 tool 消息修剪)+ 受管工具循环 | 需要密钥 | + +> 三个旗舰案例都嵌入了"代码为什么这样写"的源码级洞察(例如 **SDK 不会自动重试流式请求**、**realtime 协议无法复用连接**);详见 [`04_real_world_scenarios/README.md`](./04_real_world_scenarios/README.md)。 + +## 目录结构 + +``` +examples/ +├── 01_atomic_capabilities/ 原子能力速查(最小可运行;也是喂给 AI 的权威 wire-shape 种子) +├── 02_advanced_engineering/ 工程模式:本地 mock 调试、语义缓存、熔断 + 多地域容灾 +├── 03_agent_recipes/ Agent 配方:工具调用循环、RAG、MCP server +├── 04_real_world_scenarios/ 旗舰最佳实践:把上面三层组装成端到端生产 harness +└── legacy_1x/ 1.x 模块级 API 示例(dashscope.Generation 等),仅供参考 +``` + +各层定位(一条从"速查"到"生产蓝图"的进阶路径): + +- **01 原子能力(速查种子)**:每个能力一个文件,最小但可运行。刻意保持简单——它是查询 wire-shape 的权威种子,也是喂给通义灵码 / Cursor 让它们生成正确代码的种子。每个文件末尾有"production springboard(生产化跳板)"页脚,指向加固该能力的 02/03/04 案例。 +- **02 进阶工程**:把 SDK 包进真实生产关心的模式里(省钱、抗压、离线调试)。 +- **03 Agent 配方**:多步骤、可组合的端到端 agent 流程。 +- **04 真实场景**:不是"怎么调某个能力",而是把一个完整业务端到端建成生产级 harness(护栏、上下文预算、容错、容灾、状态机等生产权衡);每个案例配有自己的 `README.md`,讲清"为什么这样写"。 +- **legacy_1x**:旧的 1.x 模块级风格,子目录布局原样保留,仅供参考;新项目请优先使用上面四层。 + +## 准备工作 + +1. 安装 SDK: + + ```bash + pip install -U dashscope + ``` + +2. 配置百炼 API key([获取方式](https://help.aliyun.com/zh/model-studio/get-api-key)): + + ```bash + export DASHSCOPE_API_KEY="sk-xxx" + ``` + +3. 直接运行任意示例文件(从仓库根目录): + + ```bash + python3 examples/01_atomic_capabilities/text_generation/chat.py + ``` + +> **离线运行(无需 API key)**:`02_advanced_engineering/local_mock_debugging.py`、`02_advanced_engineering/circuit_breaker_retry.py`、`03_agent_recipes/mcp_server_demo/client_demo.py`、`04_real_world_scenarios/production_ready_rag_harness.py`、`04_real_world_scenarios/realtime_audio_meeting_harness.py` 和 `04_real_world_scenarios/ticket_triage_harness.py` 使用 mock transport / 脚本化接缝 / 子进程——完全离线且输出确定,适合先动手感受调用形态。 + +## 常用环境变量 + +| 变量 | 用途 | 默认值 | +| --- | --- | --- | +| `DASHSCOPE_API_KEY` | 百炼 API key(联网示例必需) | 无 | +| `DASHSCOPE_MODEL` | 覆盖聊天/生成模型(chat、缓存、RAG、工具循环、决策等) | 各文件默认值(多为 `qwen-plus`) | +| `DASHSCOPE_EMBED_MODEL` | 覆盖向量模型(`client_side_cache.py`、`rag_expert_system.py`) | `text-embedding-v4` | +| `DASHSCOPE_RERANK_MODEL` | 覆盖重排模型(`rag_expert_system.py`) | `gte-rerank-v2` | +| `DASHSCOPE_WORKSPACE` | AgentStudio 工作空间 | `trial` | +| `DASHSCOPE_APP_ID` | 百炼应用 ID(application.py) | 无 | +| `DASHSCOPE_FILE_ID` | 文件 ID(assistant_api_file.py) | 无 | +| `DASHSCOPE_DEMO_AUDIO` | 本地音频路径(tingwu_realtime.py、tingwu.py) | `tests/data/asr_example.wav` | +| `DASHSCOPE_TINGWU_APP_ID` | 听悟应用 ID(tingwu_realtime.py、tingwu.py) | 无 | +| `DASHSCOPE_TTS_VOICE` | TTS 音色(audio_tts.py) | `zhichu` | +| `DASHSCOPE_TRAINING_FILE_ID` | 微调训练文件 ID(fine_tunes.py) | 无 | +| `DASHSCOPE_DEMO_IMAGE` | 本地图片路径(cv_models.py) | `~/Downloads/cat.png` | +| `DASHSCOPE_DEMO_VIDEO_URL` | 视频 URL(multimodal_conversation/video.py) | 示例视频 | +| `RAG_HARNESS_LIVE` | 置 `1` 时 `production_ready_rag_harness.py` 打真实网关(否则离线 mock) | 未设置(离线) | +| `MEETING_HARNESS_LIVE` | 置 `1` 时 `realtime_audio_meeting_harness.py` 打真实网关(否则离线 mock) | 未设置(离线) | +| `TRIAGE_HARNESS_LIVE` | 置 `1` 时 `ticket_triage_harness.py` 打真实网关(否则脚本化决策后端) | 未设置(离线) | +| `DASHSCOPE_TINGWU_MODEL` | 听悟 realtime 模型(`realtime_audio_meeting_harness.py`) | `tingwu-turbo` | +| `MEETING_MAX_CONCURRENT` | 实时会议并发上限(即活跃 socket 上限) | `4` | + +## 01_atomic_capabilities —— 原子能力 + +统一风格:`client = dashscope.Client()`(默认从 `DASHSCOPE_API_KEY` 环境变量读取密钥)。 + +| 路径 | 内容 | +| --- | --- | +| `text_generation/chat.py` | 文本生成:一行 `dashscope.chat()`、非流式、流式(text_stream) | +| `text_generation/tokenization.py` | token 计数 | +| `multimodal_vision/multimodal_chat.py` | 多模态对话(qwen-vl 图像理解) | +| `audio_speech/audio_tts.py` | 语音合成(一次性 + 流式) | +| `audio_speech/audio_asr.py` | 批量语音识别 | +| `audio_speech/tingwu.py` | 听悟离线任务与实时会话 | +| `image_video_synthesis/images.py` | 图像生成:一键生成并等待 + 手动 jobs 流程 | +| `image_video_synthesis/videos.py` | 视频生成:一键生成并等待 + 手动 jobs 流程 | +| `embeddings/embeddings.py` | 文本向量(含批量任务入口说明) | +| `rerank/rerank.py` | 文档重排 | +| `decision/decision.py` | 决策模型(System One):一次调用对业务状态回答类型化的 choice / noul / score 问题 | +| `files/files.py` | 文件管理:上传 / 列表 / 查询 / 删除 | +| `applications/apps.py` | 百炼应用调用(非流式 + 流式) | +| `fine_tuning/fine_tunes.py` | 微调任务:list / get、create / wait / delete(取消注释即可运行) | +| `models/models.py` | 模型列表与详情 | + +## 02_advanced_engineering —— 进阶工程 + +| 路径 | 内容 | 联网 | +| --- | --- | --- | +| `local_mock_debugging.py` | 注入 `httpx.MockTransport`,零成本离线跑通成功路径与错误路径(400 / 限流 / 内容安全);把 `_mock_client()` 换成 `dashscope.Client()` 即可打真实网关 | 否 | +| `client_side_cache.py` | 客户端语义缓存:向量 + 余弦相似度捕获重复/近似问题,命中即直接返回不调模型,直接省 token | 是(仅缓存未命中时) | +| `circuit_breaker_retry.py` | 熔断器 + 多地域容灾:在 SDK 内置的 Retry-After 退避之上,按地域 CLOSED/OPEN/HALF_OPEN 熔断并自动切换;演示用 mock transport(主地域 503、副地域 200),离线可运行 | 否 | + +## 03_agent_recipes —— Agent 配方 + +| 路径 | 内容 | 联网 | +| --- | --- | --- | +| `tool_call_loop.py` | 受管多轮工具调用状态机:声明 `tools` schema,模型请求调用时本地执行,把 `role="tool"` 结果喂回,循环直到最终答案(带 `MAX_TURNS` 兜底) | 是 | +| `rag_expert_system.py` | 标准 RAG:语料向量 -> 余弦 top-k 召回 -> `client.rerank` 精排 -> "仅依据资料作答"的聊天 prompt -> 带引用的答案 | 是 | +| `mcp_server_demo/` | 零依赖 MCP(Model Context Protocol)stdio server + client 演示:`server.py` 讲 JSON-RPC 2.0;`client_demo.py` 拉起子进程并跑 initialize -> tools/list -> tools/call。见其 `README.md` | 否 | + +## 04_real_world_scenarios —— 真实场景 + +把 `01`–`03` 组装成完整、可直接对标的业务案例,带护栏、上下文预算、容错等生产权衡;每个案例配有自己的 `README.md`,讲清设计权衡与改造方法。 + +| 路径 | 内容 | 联网 | +| --- | --- | --- | +| `customer_service_assistant.py` | 电商在线客服:护栏化人设 + 业务工具(订单 / 物流 / 退款 / 工单)+ 滑动窗口多轮记忆(含孤儿 tool 消息修剪)+ 受管工具循环;演示跑一段依赖上下文的三轮对话 | 是 | +| `production_ready_rag_harness.py` | 工业级 RAG 引擎:流式语义检索 + 本地 token 水位(用 tokenization 逐段实测)+ 流式退避重试(SDK 不重试流式)+ 多地域熔断与故障切换 + 优雅降级;默认离线 mock 运行,演示场景 A/B/C/D | 否 | +| `realtime_audio_meeting_harness.py` | 高并发实时会议转写底座:每场会议一条独立 websocket(realtime 无法复用)+ 会议状态机 + 重连退避 + 跨重连本地转写稿合并 + 按房间故障隔离与降级;默认离线 mock 运行,4 个房间并发演示重连与降级 | 否 | +| `ticket_triage_harness.py` | 决策模型工单分诊:每张工单一次决策 round(单次前向推理回答 choice / noul / score)+ 本地阈值策略(置信度下限、升级阈值、严重度分档)+ 失败封闭降级到人工队列 + 单工单隔离与精确的 input-token 成本账本;默认用脚本化决策后端离线运行 | 否 | + +详见 [`04_real_world_scenarios/README.md`](./04_real_world_scenarios/README.md)。 + +## legacy_1x —— 1.x 模块级 API(仅供参考) + +以下使用旧的 1.x 风格(`dashscope.Generation`、`dashscope.MultiModalConversation` 等)。内部子目录布局原样保留、内容不做改动。新项目请使用上面的 2.0 分层示例。 + +| 路径 | 内容 | +| --- | --- | +| `generation/` | Generation 文本生成:基础、深度思考、工具调用、深度研究、自定义会话 | +| `aio_generation/` | 上述的异步版本 | +| `multimodal_conversation/` | MultiModalConversation 多模态对话:图像、视频、工具调用、OCR、ASR、深度思考 | +| `aio_multimodal_conversation/` | 上述的异步版本 | +| `application.py` | 百炼应用(Application)调用 | +| `agentstudio.py` / `agentstudio_deployments.py` | AgentStudio 会话与托管部署 | +| `assistant_api*.py` | Assistant 智能体:创建 / 更新、文件挂载、端到端对话 | +| `image_generation.py` / `image_synthesis.py` / `aio_image_synthesis.py` | 图像生成(wanx / wan2.x) | +| `video_synthesis.py` / `aio_video_synthesis.py` | 视频生成(wan 系列) | +| `multimodal_embedding.py` | 多模态向量 | +| `text_rerank.py` | 文档重排 | +| `qwen_tts.py` / `qwen_asr.py` | Qwen 语音合成与识别 | +| `tingwu_realtime.py` / `tingwu_usages.py` | 听悟实时与离线任务 | +| `cv_models.py` | CV 模型(人像风格重绘,含本地上传) | +| `set_region_example.py` | 多地域(region)配置 | diff --git a/examples/legacy_1x/agentstudio.py b/examples/legacy_1x/agentstudio.py new file mode 100644 index 00000000..81c33bfd --- /dev/null +++ b/examples/legacy_1x/agentstudio.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio sync API example: end-to-end quickstart. + +Flow: create environment -> create agent -> create session -> +send message -> stream events -> archive session. +""" + +import os + +from dashscope.agentstudio import Client, user_message + + +def main() -> None: + # Authentication: set your Model Studio API key via the + # DASHSCOPE_API_KEY environment variable, and the workspace via + # DASHSCOPE_WORKSPACE (defaults to trial) + client = Client( + api_key=os.environ["DASHSCOPE_API_KEY"], + workspace=os.environ.get("DASHSCOPE_WORKSPACE", "trial"), + ) + + print("==> creating environment") + env = client.environments.create( + name="quickstart-env", + config={ + "type": "cloud", + "networking": {"type": "unrestricted"}, + }, + ) + print(f" environment_id={env.id}") + + print("==> creating agent") + agent = client.agents.create( + name="quickstart-agent", + system_prompt="You are a friendly demo agent. Reply briefly.", + model="qwen3.8-max", + tools=[ + { + "type": "builtin_toolkit", + "default_config": {"enabled": False}, + "configs": [{"name": "bash", "enabled": True}], + }, + ], + ) + print(f" agent_id={agent.id}") + + print("==> creating session") + session = client.sessions.create( + agent=agent.id, + environment_id=env.id, + title="quickstart", + ) + print(f" session_id={session.id}") + + print("==> sending user message") + client.sessions.events.send( + session.id, + [user_message("Hello! Tell me one fun fact about Hangzhou.")], + ) + + print("==> streaming events") + with client.sessions.events.stream(session.id) as stream: + for text in stream.text_stream: + print(text, end="", flush=True) + print() # newline after streaming + + print("==> archiving session") + client.sessions.archive(session.id) + print("done.") + + +if __name__ == "__main__": + main() diff --git a/examples/legacy_1x/agentstudio_deployments.py b/examples/legacy_1x/agentstudio_deployments.py new file mode 100644 index 00000000..8436f95e --- /dev/null +++ b/examples/legacy_1x/agentstudio_deployments.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Create and run a Managed Agent deployment. + +Set DASHSCOPE_API_KEY and DASHSCOPE_WORKSPACE before running this sample. +""" + +from dashscope.agentstudio import Client, user_message + + +def main() -> None: + with Client() as client: + deployment = client.deployments.create( + name="daily-summary", + description="Generate a daily summary", + agent={"id": "agent_xxx"}, + schedule={ + "type": "cron", + "expression": "0 9 * * 1-5", + "timezone": "Asia/Shanghai", + }, + initial_events=[user_message("Summarize yesterday's orders")], + metadata={"biz": "summary"}, + ) + print("deployment:", deployment.id, deployment.status) + + run = client.deployments.run(deployment.id) + print("run:", run.id, run.status) + + for item in client.deployments.list_runs(deployment.id, limit=20): + print(item.id, item.trigger_source, item.status) + + +if __name__ == "__main__": + main() diff --git a/examples/legacy_1x/aio_generation/basic.py b/examples/legacy_1x/aio_generation/basic.py new file mode 100644 index 00000000..e58c610b --- /dev/null +++ b/examples/legacy_1x/aio_generation/basic.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioGeneration basics: async non-streaming and streaming calls.""" + +import asyncio +import os + +from dashscope.aigc.generation import AioGeneration + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + +MESSAGES = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "请用一句话介绍杭州"}, +] + + +async def main(): + # Non-streaming + print("----- non-streaming request -----") + response = await AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + ) + print(response.output.choices[0].message.content) + + # Streaming (incremental_output=True enables incremental output) + print("----- streaming request -----") + responses = await AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + stream=True, + incremental_output=True, + ) + async for chunk in responses: + print(chunk.output.choices[0].message.content, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_generation/custom_session.py b/examples/legacy_1x/aio_generation/custom_session.py new file mode 100644 index 00000000..940457b3 --- /dev/null +++ b/examples/legacy_1x/aio_generation/custom_session.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioGeneration custom-ClientSession example: connection-pool reuse +and concurrent requests.""" + +import asyncio +import os +import ssl + +import aiohttp +import certifi + +from dashscope.aigc.generation import AioGeneration + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.8-flash") + + +def build_session() -> aiohttp.ClientSession: + """Create a custom ClientSession with a configured + connection pool.""" + connector = aiohttp.TCPConnector( + limit=100, + limit_per_host=30, + ssl=ssl.create_default_context(cafile=certifi.where()), + ) + return aiohttp.ClientSession(connector=connector) + + +def build_messages(content: str): + return [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": content}, + ] + + +async def main(): + # Use async with so the ClientSession is closed automatically + async with build_session() as session: + # Reuse the same session for multiple non-streaming requests + print("----- non-streaming requests with custom session -----") + for i in range(3): + response = await AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=build_messages(f"请用一句话介绍:主题 {i + 1}"), + result_format="message", + session=session, # pass in the custom session + ) + print( + f"response {i + 1}: " + f"{response.output.choices[0].message.content}", + ) + + # Reuse the same session for a streaming request + print("----- streaming request with custom session -----") + responses = await AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=build_messages("请写一首关于秋天的诗"), + result_format="message", + stream=True, + incremental_output=True, + session=session, # pass in the custom session + ) + async for chunk in responses: + if chunk.status_code == 200: + print( + chunk.output.choices[0].message.content, + end="", + flush=True, + ) + print() + + # Share the same session across concurrent requests + print("----- concurrent requests with custom session -----") + topics = ["Python", "JavaScript", "Go"] + tasks = [ + AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=build_messages(f"请用一句话介绍:{topic}"), + result_format="message", + session=session, # all requests share one session + ) + for topic in topics + ] + results = await asyncio.gather(*tasks) + for topic, result in zip(topics, results): + print(f"{topic}: {result.output.choices[0].message.content}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_generation/deep_research.py b/examples/legacy_1x/aio_generation/deep_research.py new file mode 100644 index 00000000..e442c10c --- /dev/null +++ b/examples/legacy_1x/aio_generation/deep_research.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioGeneration deep-research (qwen-deep-research) async example. + +Demonstrates the two-step interaction flow: +1. Clarification: the model analyzes the question and asks + follow-up questions; +2. Deep research: based on the user's answer, the model runs the + full research workflow. + +qwen-deep-research currently only supports streaming, and the +response is streamed by phase. This example shows how to parse and +display the progress of each phase. +""" + +import asyncio +import os + +from dashscope.aigc.generation import AioGeneration + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen-deep-research") + +USER_QUESTION = "研究一下人工智能在教育中的应用" +USER_FEEDBACK = "我主要关注个性化学习和智能评估这两个方面" + + +# pylint: disable=too-many-branches,too-many-statements +async def process_responses(responses): + """Process the streamed responses, display per-phase progress, + and return the full text of this step.""" + current_phase = None + phase_content = "" + research_goal = "" + web_sites = [] + keepalive_shown = False + + async for response in responses: + if hasattr(response, "status_code") and response.status_code != 200: + print(f"HTTP返回码:{response.status_code}") + if hasattr(response, "code"): + print(f"错误码:{response.code}") + if hasattr(response, "message"): + print(f"错误信息:{response.message}") + print( + "请参考文档:https://help.aliyun.com/zh/model-studio/" + "developer-reference/error-code", + ) + continue + + if not (hasattr(response, "output") and response.output): + continue + + message = response.output.get("message", {}) + phase = message.get("phase") + content = message.get("content", "") + status = message.get("status") + extra = message.get("extra", {}) + + # Phase-change detection + if phase != current_phase: + if current_phase and phase_content: + print(f"\n {current_phase} 阶段完成") + current_phase = phase + phase_content = "" + keepalive_shown = False + print(f"\n 进入 {phase} 阶段") + + # Search-process details of the WebResearch phase + if phase == "WebResearch": + research_info = extra.get("deep_research", {}).get("research") + if research_info: + if status == "streamingQueries": + goal = research_info.get("researchGoal") + if goal: + research_goal += goal + print(f"\n 研究目标: {goal}", end="", flush=True) + elif status == "streamingWebResult": + sites = research_info.get("webSites") + if sites and sites != web_sites: + web_sites = sites + print(f"\n 找到 {len(sites)} 个相关网站:") + for i, site in enumerate(sites, 1): + print(f" {i}. {site.get('title', '无标题')}") + print(f" URL: {site.get('url', '无链接')}") + elif status == "WebResultFinished": + print( + f"\n 网络搜索完成,共找到 {len(web_sites)} 个参考信息源", + ) + if research_goal: + print(f" 研究目标: {research_goal}") + + # Stream the content in real time + if content: + phase_content += content + print(content, end="", flush=True) + + # Show token usage when the status is finished + if status == "finished": + if hasattr(response, "usage") and response.usage: + usage = response.usage + print("\n Token消耗统计:") + print(f" 输入tokens: {usage.get('input_tokens', 0)}") + print(f" 输出tokens: {usage.get('output_tokens', 0)}") + print(f" 请求ID: {response.get('request_id', '未知')}") + + if phase == "KeepAlive" and not keepalive_shown: + print("当前步骤已经完成,准备开始下一步骤工作") + keepalive_shown = True + + if current_phase and phase_content: + print(f"\n {current_phase} 阶段完成") + + return phase_content + + +async def call_deep_research_model(messages, step_name): + """Call the qwen-deep-research model and display one full step.""" + print(f"\n=== {step_name} ===") + try: + responses = await AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=messages, + # qwen-deep-research currently only supports streaming + stream=True, + # incremental_output=True # uncomment for incremental output + ) + return await process_responses(responses) + except Exception as e: + print(f"调用API时发生错误: {e}") + return "" + + +async def main(): + if not os.getenv("DASHSCOPE_API_KEY"): + print("错误:未设置 DASHSCOPE_API_KEY 环境变量") + return + + print(f"用户发起对话:{USER_QUESTION}") + + # Step 1: clarification — the model asks follow-up questions to + # pin down the research direction + step1_content = await call_deep_research_model( + [{"role": "user", "content": USER_QUESTION}], + "第一步:模型反问确认", + ) + + # Step 2: deep research — the model runs the full research + # workflow based on the user's follow-up answer + await call_deep_research_model( + [ + {"role": "user", "content": USER_QUESTION}, + {"role": "assistant", "content": step1_content}, + {"role": "user", "content": USER_FEEDBACK}, + ], + "第二步:深入研究", + ) + print("\n 研究完成!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_generation/reasoning.py b/examples/legacy_1x/aio_generation/reasoning.py new file mode 100644 index 00000000..19e03d2e --- /dev/null +++ b/examples/legacy_1x/aio_generation/reasoning.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioGeneration deep-thinking example: stream the reasoning process +with enable_thinking.""" + +import asyncio +import os + +from dashscope.aigc.generation import AioGeneration + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + +MESSAGES = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "1.1和0.9哪个大"}, +] + + +async def main(): + # With enable_thinking=True the model only supports streaming, + # and incremental_output must be set to True + print("----- streaming request with enable_thinking -----") + responses = await AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + enable_thinking=True, + stream=True, + incremental_output=True, + ) + async for chunk in responses: + message = chunk.output.choices[0].message + reasoning = getattr(message, "reasoning_content", None) + if reasoning: + print(reasoning, end="", flush=True) + if message.content: + print(message.content, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_generation/tool_calls.py b/examples/legacy_1x/aio_generation/tool_calls.py new file mode 100644 index 00000000..c1992ab3 --- /dev/null +++ b/examples/legacy_1x/aio_generation/tool_calls.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioGeneration tool-calling (function calling) example.""" + +import asyncio +import os + +from dashscope.aigc.generation import AioGeneration + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "当你想知道现在的时间时非常有用。", + "parameters": {}, + }, + }, + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "当你想查询指定城市的天气时非常有用。", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "城市或县区,比如北京市、杭州市、余杭区等。", + }, + }, + }, + "required": ["location"], + }, + }, +] + +MESSAGES = [{"role": "user", "content": "杭州天气怎么样"}] + + +async def main(): + # In streaming mode tool_calls are returned in incremental + # fragments that need to be merged by index + print("----- streaming request with tools -----") + responses = await AioGeneration.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + tools=TOOLS, + result_format="message", + stream=True, + incremental_output=False, + ) + async for chunk in responses: + message = chunk.output.choices[0].message + if message.get("tool_calls"): + for tool_call in message.tool_calls: + function = tool_call.get("function", {}) + print( + f"tool_call: name={function.get('name')}, " + f"arguments={function.get('arguments')}", + ) + if message.content: + print(message.content, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_image_synthesis.py b/examples/legacy_1x/aio_image_synthesis.py new file mode 100644 index 00000000..1f096a4f --- /dev/null +++ b/examples/legacy_1x/aio_image_synthesis.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""AioImageSynthesis text-to-image async example (wanx series). + +Demonstrates in order: blocking call -> submit an async task and +wait/fetch it -> list tasks. +""" + +import asyncio +import os +from http import HTTPStatus + +from dashscope.aigc.image_synthesis import AioImageSynthesis + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get("DASHSCOPE_MODEL", "wan2.2-t2i-flash") + +PROMPT = "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵" + + +def print_response(rsp) -> None: + if rsp.status_code == HTTPStatus.OK: + print(rsp) + else: + print( + f"Failed, status_code: {rsp.status_code}, " + f"code: {rsp.code}, message: {rsp.message}", + ) + + +async def main(): + api_key = os.getenv("DASHSCOPE_API_KEY") + + # Blocking wait: submit the task and block until the result + print("----- call (wait for result) -----") + rsp = await AioImageSynthesis.call( + api_key=api_key, + model=MODEL, + prompt=PROMPT, + n=1, + size="1024*1024", + ) + print_response(rsp) + + # Note: the sync_call sync API currently only supports + # wan2.2-t2i-flash and wan2.2-t2i-plus + # rsp = await AioImageSynthesis.sync_call( + # api_key=api_key, model=MODEL, prompt=PROMPT, n=1, size="1024*1024", + # ) + # print_response(rsp) + + # Async task: submit, then wait / fetch / cancel as needed + print("----- async_call + wait + fetch -----") + task = await AioImageSynthesis.async_call( + api_key=api_key, + model=MODEL, + prompt=PROMPT, + n=1, + size="1024*1024", + ) + print_response(task) + if task.status_code == HTTPStatus.OK: + task_id = task.output.task_id + + # Wait for the task to complete + print(f"----- wait (task_id={task_id}) -----") + print_response(await AioImageSynthesis.wait(task_id)) + + # Fetch the task status and result by task_id + print(f"----- fetch (task_id={task_id}) -----") + print_response(await AioImageSynthesis.fetch(task_id)) + + # Cancel the task (it is expected to fail if the task has + # already completed) + # print(f"----- cancel (task_id={task_id}) -----") + # print_response(await AioImageSynthesis.cancel(task_id)) + + # List historical tasks + print("----- list -----") + print_response(await AioImageSynthesis.list()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_multimodal_conversation/asr.py b/examples/legacy_1x/aio_multimodal_conversation/asr.py new file mode 100644 index 00000000..1dada722 --- /dev/null +++ b/examples/legacy_1x/aio_multimodal_conversation/asr.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioMultiModalConversation speech-recognition async example +(qwen3-asr-flash, streaming).""" + +import asyncio +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +# qwen3-asr-flash is a public model on the Model Studio platform +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-asr-flash") + +AUDIO_URL = "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3" + +MESSAGES = [ + { + "role": "user", + "content": [{"audio": AUDIO_URL}], + }, + { + "role": "system", + "content": [{"text": "这是一段介绍文本"}], + }, +] + + +async def main(): + print("----- streaming request with asr_options -----") + responses = await dashscope.AioMultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + asr_options={"language": "zh", "enable_lid": True}, + stream=True, + incremental_output=True, + ) + async for chunk in responses: + for part in chunk.output.choices[0].message.content: + print(part.get("text", ""), end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_multimodal_conversation/image.py b/examples/legacy_1x/aio_multimodal_conversation/image.py new file mode 100644 index 00000000..c15fabea --- /dev/null +++ b/examples/legacy_1x/aio_multimodal_conversation/image.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioMultiModalConversation image-understanding async example +(qwen-vl).""" + +import asyncio +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-vl-plus") + +# Replace with your own image URL (must be publicly accessible) +IMAGE_URL = ( + "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/" + "zh-CN/20241022/emyrja/dog_and_girl.jpeg" +) + +MESSAGES = [ + { + "role": "system", + "content": [{"text": "You are a helpful assistant."}], + }, + { + "role": "user", + "content": [ + {"image": IMAGE_URL}, + {"text": "图中描绘的是什么景象?"}, + ], + }, +] + + +async def main(): + # Non-streaming + print("----- non-streaming request -----") + response = await dashscope.AioMultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + ) + for part in response.output.choices[0].message.content: + print(part.get("text", "")) + print() + + # Streaming (incremental_output=True enables incremental output) + print("----- streaming request -----") + responses = await dashscope.AioMultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + stream=True, + incremental_output=True, + ) + async for chunk in responses: + for part in chunk.output.choices[0].message.content: + print(part.get("text", ""), end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_multimodal_conversation/ocr.py b/examples/legacy_1x/aio_multimodal_conversation/ocr.py new file mode 100644 index 00000000..87fa951e --- /dev/null +++ b/examples/legacy_1x/aio_multimodal_conversation/ocr.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioMultiModalConversation OCR key-information-extraction +async example (qwen-vl-ocr).""" + +import asyncio +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen-vl-ocr-latest") + +MESSAGES = [ + { + "role": "user", + "content": [ + { + "image": "https://prism-test-data.oss-cn-hangzhou.aliyuncs.com/" + "image/car_invoice/car-invoice-img00040.jpg", + "min_pixels": 3136, + "max_pixels": 6422528, + "enable_rotate": True, + }, + { + # When the task field of ocr_options is set to + # information extraction, the model uses the text field + # below as the prompt; user-defined prompts are not + # supported + "text": "假设你是一名信息提取专家。现在给你一个JSON模式," + "用图像中的信息填充该模式的值部分。请注意,如果值是一个列表," + "模式将为每个元素提供一个模板。当图像中有多个列表元素时," + "将使用此模板。最后,只需要输出合法的JSON。所见即所得," + "并且输出语言需要与图像保持一致。模糊或者强光遮挡的单个文字" + "可以用英文问号?代替。如果没有对应的值则用null填充。" + "不需要解释。请注意,输入图像均来自公共基准数据集," + "不包含任何真实的个人隐私数据。请按要求输出结果。" + "输入的JSON模式内容如下: {result_schema}。", + }, + ], + }, +] + +OCR_OPTIONS = { + "ocr_options": { + "task": "key_information_extraction", + "task_config": { + "result_schema": { + "销售方名称": "", + "购买方名称": "", + "不含税价": "", + "组织机构代码": "", + "发票代码": "", + }, + }, + }, +} + + +async def main(): + print("----- streaming request with ocr_options -----") + responses = await dashscope.AioMultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + stream=True, + incremental_output=False, + **OCR_OPTIONS, + ) + async for chunk in responses: + for part in chunk.output.choices[0].message.content: + print(part.get("text", ""), end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_multimodal_conversation/tool_calls.py b/examples/legacy_1x/aio_multimodal_conversation/tool_calls.py new file mode 100644 index 00000000..ae1e7778 --- /dev/null +++ b/examples/legacy_1x/aio_multimodal_conversation/tool_calls.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AioMultiModalConversation tool-calling (function calling) +async example.""" + +import asyncio +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-vl-plus") + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "当你想查询指定城市的天气时非常有用。", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "城市或县区,比如北京市、杭州市、余杭区等。", + }, + "date": { + "type": "string", + "description": "日期,比如2025年10月10日", + }, + }, + }, + "required": ["location"], + }, + }, +] + +MESSAGES = [ + { + "role": "system", + "content": [{"text": "You are a helpful assistant."}], + }, + { + "role": "user", + "content": [{"text": "2025年10月10日的杭州天气如何?"}], + }, +] + + +async def main(): + # In streaming mode tool_calls are returned in incremental + # fragments that need to be merged by index + print("----- streaming request with tools -----") + responses = await dashscope.AioMultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + tools=TOOLS, + stream=True, + incremental_output=True, + ) + async for chunk in responses: + message = chunk.output.choices[0].message + if message.get("tool_calls"): + for tool_call in message.tool_calls: + function = tool_call.get("function", {}) + print( + f"tool_call: name={function.get('name')}, " + f"arguments={function.get('arguments')}", + ) + for part in message.content or []: + print(part.get("text", ""), end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/aio_video_synthesis.py b/examples/legacy_1x/aio_video_synthesis.py new file mode 100644 index 00000000..92a60632 --- /dev/null +++ b/examples/legacy_1x/aio_video_synthesis.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +"""AioVideoSynthesis video-generation async example (wanx series). + +Demonstrates in order: blocking call -> submit an async task and +wait/fetch it -> list tasks. +Note: video-generation tasks take a while; please be patient. +""" + +import asyncio +import os +from http import HTTPStatus + +from dashscope.aigc.video_synthesis import AioVideoSynthesis + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get("DASHSCOPE_MODEL", "wan2.7-t2v") + +PROMPT = "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵" + + +def print_response(rsp) -> None: + if rsp.status_code == HTTPStatus.OK: + print(rsp) + else: + print( + f"Failed, status_code: {rsp.status_code}, " + f"code: {rsp.code}, message: {rsp.message}", + ) + + +async def main(): + api_key = os.getenv("DASHSCOPE_API_KEY") + + # Blocking wait: submit the task and block until the result + print("----- call (wait for result) -----") + rsp = await AioVideoSynthesis.call( + api_key=api_key, + model=MODEL, + prompt=PROMPT, + ) + print_response(rsp) + + # Async task: submit, then wait / fetch / cancel as needed + print("----- async_call + wait + fetch -----") + task = await AioVideoSynthesis.async_call( + api_key=api_key, + model=MODEL, + prompt=PROMPT, + ) + print_response(task) + if task.status_code == HTTPStatus.OK: + task_id = task.output.task_id + + # Wait for the task to complete + print(f"----- wait (task_id={task_id}) -----") + print_response(await AioVideoSynthesis.wait(task_id)) + + # Fetch the task status and result by task_id + print(f"----- fetch (task_id={task_id}) -----") + print_response(await AioVideoSynthesis.fetch(task_id)) + + # Cancel the task (it is expected to fail if the task has + # already completed) + # print(f"----- cancel (task_id={task_id}) -----") + # print_response(await AioVideoSynthesis.cancel(task_id)) + + # List historical tasks + print("----- list -----") + print_response(await AioVideoSynthesis.list()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/application.py b/examples/legacy_1x/application.py new file mode 100644 index 00000000..d05a1d1e --- /dev/null +++ b/examples/legacy_1x/application.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""Application (Model Studio app) call example: streaming output +with file input.""" + +import os +from http import HTTPStatus + +from dashscope import Application + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), and the +# app ID via the DASHSCOPE_APP_ID environment variable +APP_ID = os.environ.get("DASHSCOPE_APP_ID", "your-app-id") + + +if __name__ == "__main__": + print("----- streaming request -----") + responses = Application.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + app_id=APP_ID, + prompt="总结文件内容", + stream=True, # streaming output + # has_thoughts=True, # include node-level content + incremental_output=True, + file_list=[ + "https://dashscope.oss-cn-beijing.aliyuncs.com/" + "audios/welcome.mp3", + ], + # flow_stream_mode='agent_format' # Agent mode: expose node output + ) + for response in responses: + if response.status_code != HTTPStatus.OK: + print(f"request_id={response.request_id}") + print(f"code={response.status_code}") + print(f"message={response.message}") + print( + "请参考文档:https://help.aliyun.com/zh/model-studio/" + "developer-reference/error-code", + ) + else: + print(response.output.text, end="", flush=True) + print() diff --git a/examples/legacy_1x/assistant_api.py b/examples/legacy_1x/assistant_api.py new file mode 100644 index 00000000..f2b0193e --- /dev/null +++ b/examples/legacy_1x/assistant_api.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Assistant creation and update example.""" + +import os + +from dashscope import Assistants + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +# Model list: https://help.aliyun.com/zh/model-studio/getting-started/models +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.8-max") + + +if __name__ == "__main__": + # Create an assistant with tools and sampling parameters + print("----- create assistant -----") + assistant = Assistants.create( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + name="smart helper", + description="A tool helper.", + instructions="You are a helpful assistant. " + "When asked a question, use tools wherever possible.", + tools=[ + {"type": "search"}, + { + "type": "function", + "function": { + "name": "big_add", + "description": "Add to number", + "parameters": { + "type": "object", + "properties": { + "left": { + "type": "integer", + "description": "The left operator", + }, + "right": { + "type": "integer", + "description": "The right operator.", + }, + }, + "required": ["left", "right"], + }, + }, + }, + ], + top_k=10, + top_p=0.5, + temperature=1.0, + max_tokens=2048, + ) + print(f"assistant_id={assistant.id}") + print( + f"top_p={assistant.top_p}, top_k={assistant.top_k}, " + f"temperature={assistant.temperature}, " + f"max_tokens={assistant.max_tokens}", + ) + + # Update the assistant's sampling parameters + print("----- update assistant -----") + assistant = Assistants.update( + assistant.id, + top_k=9, + top_p=0.4, + temperature=0.9, + max_tokens=1024, + ) + print( + f"top_p={assistant.top_p}, top_k={assistant.top_k}, " + f"temperature={assistant.temperature}, " + f"max_tokens={assistant.max_tokens}", + ) + + # More update options: + # update the model only: + # Assistants.update(assistant.id, model="qwen3.7-plus") + # clear all tools: + # Assistants.update(assistant.id, tools=[]) diff --git a/examples/legacy_1x/assistant_api_file.py b/examples/legacy_1x/assistant_api_file.py new file mode 100644 index 00000000..4fdbbd5e --- /dev/null +++ b/examples/legacy_1x/assistant_api_file.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +"""Assistant file attachment example: associate a file with +an assistant. + +Before running, set the ID of an already-uploaded file via the +DASHSCOPE_FILE_ID environment variable. +""" + +import os + +from dashscope import Assistants +from dashscope.assistants.files import Files + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), and the +# file ID via the DASHSCOPE_FILE_ID environment variable +FILE_ID = os.environ.get("DASHSCOPE_FILE_ID", "your-file-id") + + +if __name__ == "__main__": + # Create an assistant + assistant = Assistants.create(model="qwen3.7-plus") + print(f"assistant_id={assistant.id}") + + # Associate the file with the assistant + file = Files.create(assistant_id=assistant.id, file_id=FILE_ID) + print(f"create file: {file}") + + # Query the associated file + file = Files.get(assistant_id=assistant.id, file_id=FILE_ID) + print(f"get file: {file}") diff --git a/examples/legacy_1x/assistant_api_simple.py b/examples/legacy_1x/assistant_api_simple.py new file mode 100644 index 00000000..44c04bb9 --- /dev/null +++ b/examples/legacy_1x/assistant_api_simple.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""Assistant end-to-end conversation example: create assistant +-> thread -> run -> get reply.""" + +import sys +from http import HTTPStatus + +from dashscope import Assistants, Messages, Runs, Threads + + +def verify_status_code(res): + if res.status_code != HTTPStatus.OK: + print(f"Failed: {res}") + sys.exit(res.status_code) + + +if __name__ == "__main__": + # Create an assistant + print("----- create assistant -----") + assistant = Assistants.create( + model="qwen3.8-max", # change the model name as needed + name="smart helper", + description="A tool helper.", + instructions="You are a helpful assistant.", + ) + print(f"assistant_id={assistant.id}") + verify_status_code(assistant) + + # Create a thread with the first user message + print("----- create thread -----") + thread = Threads.create( + messages=[ + { + "role": "user", + "content": "如何做出美味的牛肉炖土豆?", + }, + ], + ) + print(f"thread_id={thread.id}") + verify_status_code(thread) + + # Start a run and wait for it to complete + print("----- create run -----") + run = Runs.create(thread.id, assistant_id=assistant.id) + verify_status_code(run) + + run_status = Runs.wait(run.id, thread_id=thread.id) + print(f"run status: {run_status.status}") + if run_status.usage: + usage = run_status.usage + print( + f"run usage: total={usage.get('total_tokens')}, " + f"input={usage.get('input_tokens')}, " + f"output={usage.get('output_tokens')}", + ) + + # List the messages in the thread (including the assistant's reply) + print("----- list thread messages -----") + msgs = Messages.list(thread.id) + for message in msgs.data: + print(f"{message['role']}: {message['content']}") diff --git a/examples/legacy_1x/cv_models.py b/examples/legacy_1x/cv_models.py new file mode 100644 index 00000000..f5e425c0 --- /dev/null +++ b/examples/legacy_1x/cv_models.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""CV model call example: portrait style repaint +(wanx-style-repaint), including local image upload. + +API doc: https://help.aliyun.com/zh/model-studio/portrait-style-redraw-api-reference +""" + +import os + +from dashscope.client.base_api import BaseAsyncApi +from dashscope.utils.oss_utils import check_and_upload_local + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get("DASHSCOPE_MODEL", "wanx-style-repaint-v1") + +# Local image path; override via the DASHSCOPE_DEMO_IMAGE +# environment variable +IMAGE_PATH = os.environ.get( + "DASHSCOPE_DEMO_IMAGE", + os.path.expanduser("~/Downloads/cat.png"), +) + + +if __name__ == "__main__": + api_key = os.getenv("DASHSCOPE_API_KEY") + + # Upload the local file to temporary OSS storage first to get a URL + uploaded, image_url = check_and_upload_local( + model=MODEL, + content=IMAGE_PATH, + api_key=api_key, + ) + + kwargs = {} + if uploaded is True: + kwargs["headers"] = {"X-DashScope-OssResourceResolve": "enable"} + + response = BaseAsyncApi.call( + model=MODEL, + input={ + "image_url": image_url, + "style_index": 3, + }, + task_group="aigc", + task="image-generation", + function="generation", + **kwargs, + ) + print(f"response:\n{response}") diff --git a/examples/legacy_1x/generation/basic.py b/examples/legacy_1x/generation/basic.py new file mode 100644 index 00000000..95233295 --- /dev/null +++ b/examples/legacy_1x/generation/basic.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Generation basics: non-streaming and streaming calls.""" + +import os + +from dashscope import Generation + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +# The model can be overridden via the DASHSCOPE_MODEL environment variable. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + +MESSAGES = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "请用一句话介绍杭州"}, +] + + +if __name__ == "__main__": + # Non-streaming + print("----- non-streaming request -----") + response = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + ) + print(response.output.choices[0].message.content) + + # Streaming (incremental_output=True enables incremental output) + print("----- streaming request -----") + responses = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + stream=True, + incremental_output=True, + ) + for chunk in responses: + print(chunk.output.choices[0].message.content, end="", flush=True) + print() + + # Streaming with cache_control (context caching) and logprobs + print("----- streaming request with cache_control & logprobs -----") + messages_with_cache = [ + {"role": "system", "content": "You are a helpful assistant."}, + { + "role": "user", + "content": [ + { + "type": "text", + "text": "从1到1000选择一个数字", + "cache_control": {"type": "ephemeral", "ttl": "5m"}, + }, + ], + }, + ] + responses = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=os.environ.get("DASHSCOPE_MODEL", "qwen3.8-max"), + messages=messages_with_cache, + result_format="message", + stream=True, + incremental_output=False, + logprobs=True, + top_logprobs=5, + ) + for chunk in responses: + print(chunk.output.choices[0].message.content, end="", flush=True) + print() diff --git a/examples/legacy_1x/generation/custom_session.py b/examples/legacy_1x/generation/custom_session.py new file mode 100644 index 00000000..0146d4a3 --- /dev/null +++ b/examples/legacy_1x/generation/custom_session.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Generation custom-Session example: connection-pool reuse.""" + +import os + +import requests +from requests.adapters import HTTPAdapter + +from dashscope import Generation + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.8-flash") + + +def build_session() -> requests.Session: + """Create a custom Session with a configured connection pool.""" + custom_session = requests.Session() + adapter = HTTPAdapter( + pool_connections=10, + pool_maxsize=20, + max_retries=3, + ) + custom_session.mount("http://", adapter) + custom_session.mount("https://", adapter) + return custom_session + + +if __name__ == "__main__": + # Use a with statement so the Session is closed automatically + with build_session() as session: + # Reuse the same session for multiple non-streaming requests + print("----- non-streaming requests with custom session -----") + for i in range(3): + response = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=[ + { # type: ignore[list-item] + "role": "system", + "content": "You are a helpful assistant.", + }, + { # type: ignore[list-item] + "role": "user", + "content": f"请用一句话介绍:主题 {i + 1}", + }, + ], + result_format="message", + session=session, # pass in the custom session + ) + print( + f"response {i + 1}: " + f"{response.output.choices[0].message.content}", + ) + + # Reuse the same session for a streaming request + print("----- streaming request with custom session -----") + responses = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=[ + { # type: ignore[list-item] + "role": "system", + "content": "You are a helpful assistant.", + }, + { # type: ignore[list-item] + "role": "user", + "content": "你好!", + }, + ], + result_format="message", + stream=True, + incremental_output=True, + session=session, # pass in the custom session + ) + for chunk in responses: + print(chunk.output.choices[0].message.content, end="", flush=True) + print() diff --git a/examples/legacy_1x/generation/deep_research.py b/examples/legacy_1x/generation/deep_research.py new file mode 100644 index 00000000..b0a32dee --- /dev/null +++ b/examples/legacy_1x/generation/deep_research.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Generation deep-research (qwen-deep-research) example. + +Demonstrates the two-step interaction flow: +1. Clarification: the model analyzes the question and asks + follow-up questions; +2. Deep research: based on the user's answer, the model runs the + full research workflow. + +qwen-deep-research currently only supports streaming, and the +response is streamed by phase. This example shows how to parse and +display the progress of each phase. +""" + +import os + +from dashscope import Generation + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen-deep-research") + +USER_QUESTION = "研究一下人工智能在教育中的应用" +USER_FEEDBACK = "我主要关注个性化学习和智能评估这两个方面" + + +# pylint: disable=too-many-branches,too-many-statements,too-many-nested-blocks +def process_responses(responses): + """Process the streamed responses, display per-phase progress, + and return the full text of this step.""" + current_phase = None + phase_content = "" + research_goal = "" + web_sites = [] + keepalive_shown = False + + for response in responses: + if hasattr(response, "status_code") and response.status_code != 200: + print(f"HTTP返回码:{response.status_code}") + if hasattr(response, "code"): + print(f"错误码:{response.code}") + if hasattr(response, "message"): + print(f"错误信息:{response.message}") + print( + "请参考文档:https://help.aliyun.com/zh/model-studio/" + "developer-reference/error-code", + ) + continue + + if not (hasattr(response, "output") and response.output): + continue + + message = response.output.get("message", {}) + phase = message.get("phase") + content = message.get("content", "") + status = message.get("status") + extra = message.get("extra", {}) + + # Phase-change detection + if phase != current_phase: + if current_phase and phase_content: + print(f"\n {current_phase} 阶段完成") + current_phase = phase + phase_content = "" + keepalive_shown = False + print(f"\n 进入 {phase} 阶段") + + # Search-process details of the WebResearch phase + if phase == "WebResearch": + research_info = extra.get("deep_research", {}).get("research") + if research_info: + if status == "streamingQueries": + goal = research_info.get("researchGoal") + if goal: + research_goal += goal + print(f"\n 研究目标: {goal}", end="", flush=True) + elif status == "streamingWebResult": + sites = research_info.get("webSites") + if sites and sites != web_sites: + web_sites = sites + print(f"\n 找到 {len(sites)} 个相关网站:") + for i, site in enumerate(sites, 1): + print(f" {i}. {site.get('title', '无标题')}") + print(f" URL: {site.get('url', '无链接')}") + elif status == "WebResultFinished": + print( + f"\n 网络搜索完成,共找到 {len(web_sites)} 个参考信息源", + ) + if research_goal: + print(f" 研究目标: {research_goal}") + + # Stream the content in real time + if content: + phase_content += content + print(content, end="", flush=True) + + # Show token usage when the status is finished + if status == "finished": + if hasattr(response, "usage") and response.usage: + usage = response.usage + print("\n Token消耗统计:") + print(f" 输入tokens: {usage.get('input_tokens', 0)}") + print(f" 输出tokens: {usage.get('output_tokens', 0)}") + print(f" 请求ID: {response.get('request_id', '未知')}") + + if phase == "KeepAlive" and not keepalive_shown: + print("当前步骤已经完成,准备开始下一步骤工作") + keepalive_shown = True + + if current_phase and phase_content: + print(f"\n {current_phase} 阶段完成") + + return phase_content + + +def call_deep_research_model(messages, step_name): + """Call the qwen-deep-research model and display one full step.""" + print(f"\n=== {step_name} ===") + try: + responses = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=messages, + # qwen-deep-research currently only supports streaming + stream=True, + # incremental_output=True # uncomment for incremental output + ) + return process_responses(responses) + except Exception as e: + print(f"调用API时发生错误: {e}") + return "" + + +if __name__ == "__main__": + if not os.getenv("DASHSCOPE_API_KEY"): + print("错误:未设置 DASHSCOPE_API_KEY 环境变量") + raise SystemExit(1) + + print(f"用户发起对话:{USER_QUESTION}") + + # Step 1: clarification — the model asks follow-up questions to + # pin down the research direction + step1_content = call_deep_research_model( + [{"role": "user", "content": USER_QUESTION}], + "第一步:模型反问确认", + ) + + # Step 2: deep research — the model runs the full research + # workflow based on the user's follow-up answer + call_deep_research_model( + [ + {"role": "user", "content": USER_QUESTION}, + {"role": "assistant", "content": step1_content}, + {"role": "user", "content": USER_FEEDBACK}, + ], + "第二步:深入研究", + ) + print("\n 研究完成!") diff --git a/examples/legacy_1x/generation/reasoning.py b/examples/legacy_1x/generation/reasoning.py new file mode 100644 index 00000000..27e7c6d6 --- /dev/null +++ b/examples/legacy_1x/generation/reasoning.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Generation deep-thinking example: stream the reasoning process +with enable_thinking.""" + +import os + +from dashscope import Generation + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + +MESSAGES = [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "1.1和0.9哪个大"}, +] + + +if __name__ == "__main__": + # With enable_thinking=True the model only supports streaming, + # and incremental_output must be set to True + print("----- streaming request with enable_thinking -----") + responses = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + enable_thinking=True, + stream=True, + incremental_output=True, + ) + for chunk in responses: + message = chunk.output.choices[0].message + reasoning = getattr(message, "reasoning_content", None) + if reasoning: + print(reasoning, end="", flush=True) + if message.content: + print(message.content, end="", flush=True) + print() diff --git a/examples/legacy_1x/generation/tool_calls.py b/examples/legacy_1x/generation/tool_calls.py new file mode 100644 index 00000000..7a859717 --- /dev/null +++ b/examples/legacy_1x/generation/tool_calls.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Generation tool-calling (function calling) example.""" + +import os + +from dashscope import Generation + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3.7-plus") + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_current_time", + "description": "当你想知道现在的时间时非常有用。", + "parameters": {}, + }, + }, + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "当你想查询指定城市的天气时非常有用。", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "城市或县区,比如北京市、杭州市、余杭区等。", + }, + }, + }, + "required": ["location"], + }, + }, +] + +MESSAGES = [{"role": "user", "content": "杭州天气怎么样"}] + + +if __name__ == "__main__": + # In streaming mode tool_calls are returned in incremental + # fragments that need to be merged by index + print("----- streaming request with tools -----") + responses = Generation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + tools=TOOLS, + result_format="message", + stream=True, + incremental_output=False, + ) + for chunk in responses: + message = chunk.output.choices[0].message + if message.get("tool_calls"): + for tool_call in message.tool_calls: + function = tool_call.get("function", {}) + print( + f"tool_call: name={function.get('name')}, " + f"arguments={function.get('arguments')}", + ) + if message.content: + print(message.content, end="", flush=True) + print() diff --git a/examples/legacy_1x/image_generation.py b/examples/legacy_1x/image_generation.py new file mode 100644 index 00000000..9b427d49 --- /dev/null +++ b/examples/legacy_1x/image_generation.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""ImageGeneration image-generation example (wan2.6 +message-style API). + +Demonstrates in order: text-to-image -> reference-image-to-image +-> async task submit/cancel/wait/fetch -> task list -> streaming +multi-image generation. +""" + +import os + +from dashscope.aigc.image_generation import ImageGeneration +from dashscope.api_entities.dashscope_response import Message, Role + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +T2I_MODEL = ImageGeneration.Models.wan2_6_t2i +IMAGE_MODEL = ImageGeneration.Models.wan2_6_image + +T2I_MESSAGE = Message( + role=Role.USER, + content=[ + {"text": "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵"}, + ], +) + +# Reference images also support local files, e.g. +# "image": "file://umbrella1.png" +IMAGE_MESSAGE = Message( + role=Role.USER, + content=[ + {"text": "参考图1的风格和图2的背景,生成番茄炒蛋"}, + {"image": "https://cdn.wanx.aliyuncs.com/tmp/pressure/umbrella1.png"}, + { + "image": "https://img.alicdn.com/imgextra/i3/" + "O1CN01SfG4J41UYn9WNt4X1_!!6000000002530-49-tps-1696-960.webp", + }, + ], +) + + +if __name__ == "__main__": + api_key = os.getenv("DASHSCOPE_API_KEY") + + # Text-to-image (waits synchronously) + print("----- t2i request -----") + t2i_res = ImageGeneration.call( + api_key=api_key, + model=T2I_MODEL, + messages=[T2I_MESSAGE], + ) + print(t2i_res) + + # Reference-image-to-image (waits synchronously) + print("----- image-to-image request -----") + image_res = ImageGeneration.call( + api_key=api_key, + model=IMAGE_MODEL, + messages=[IMAGE_MESSAGE], + ) + print(image_res) + + # Async task: submit, then cancel/wait/fetch as needed (both a + # task_id and a response object are accepted) + print("----- async_call + cancel/wait/fetch -----") + t2i_task = ImageGeneration.async_call( + api_key=api_key, + model=T2I_MODEL, + messages=[T2I_MESSAGE], + ) + print(t2i_task) + + print("----- cancel (by task_id) -----") + print(ImageGeneration.cancel(t2i_task.output.task_id)) + + print("----- wait (by task_id) -----") + print(ImageGeneration.wait(t2i_task.output.task_id)) + + print("----- fetch (by response) -----") + print(ImageGeneration.fetch(t2i_task)) + + print("----- list tasks -----") + print(ImageGeneration.list()) + + # Streaming multi-image generation (interleaved text and images) + print("----- streaming multi-image request -----") + stream_res = ImageGeneration.call( + api_key=api_key, + model=IMAGE_MODEL, + messages=[ + Message( + role=Role.USER, + content=[{"text": "给我一个3张图辣椒炒肉教程"}], + ), + ], + stream=True, + enable_interleave=True, + max_images=3, + ) + for chunk in stream_res: + print(chunk) diff --git a/examples/legacy_1x/image_synthesis.py b/examples/legacy_1x/image_synthesis.py new file mode 100644 index 00000000..4ebec129 --- /dev/null +++ b/examples/legacy_1x/image_synthesis.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""ImageSynthesis text-to-image example (wanx series).""" + +import os +from http import HTTPStatus + +from dashscope import ImageSynthesis + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get("DASHSCOPE_MODEL", "wanx2.1-t2i-turbo") + +PROMPT = "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵" + + +def print_response(response) -> None: + if response.status_code == HTTPStatus.OK: + for result in response.output.results: + print(f"image url: {result['url']}") + else: + print( + f"Failed, status_code: {response.status_code}, " + f"code: {response.code}, message: {response.message}", + ) + + +if __name__ == "__main__": + # Async-task mode: the SDK polls until the task completes + print("----- async task request (call) -----") + rsp = ImageSynthesis.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + prompt=PROMPT, + n=1, + size="1024*1024", + ) + print_response(rsp) + + # Sync-API mode: note that only wan2.2-t2i-flash and + # wan2.2-t2i-plus are currently supported + print("----- sync request (sync_call) -----") + rsp = ImageSynthesis.sync_call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model="wan2.2-t2i-flash", + prompt=PROMPT, + n=1, + size="1024*1024", + ) + print_response(rsp) diff --git a/examples/legacy_1x/multimodal_conversation/asr.py b/examples/legacy_1x/multimodal_conversation/asr.py new file mode 100644 index 00000000..5399e25f --- /dev/null +++ b/examples/legacy_1x/multimodal_conversation/asr.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""MultiModalConversation speech-recognition example +(qwen3-asr-flash, streaming).""" + +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +# qwen3-asr-flash is a public model on the Model Studio platform +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-asr-flash") + +AUDIO_URL = "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3" + +MESSAGES = [ + { + "role": "user", + "content": [{"audio": AUDIO_URL}], + }, + { + "role": "system", + "content": [{"text": "这是一段介绍文本"}], + }, +] + + +if __name__ == "__main__": + print("----- streaming request with asr_options -----") + responses = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + asr_options={"language": "zh", "enable_lid": True}, + stream=True, + incremental_output=True, + ) + for chunk in responses: + for part in chunk.output.choices[0].message.content: + print(part.get("text", ""), end="", flush=True) + print() diff --git a/examples/legacy_1x/multimodal_conversation/image.py b/examples/legacy_1x/multimodal_conversation/image.py new file mode 100644 index 00000000..facc91c3 --- /dev/null +++ b/examples/legacy_1x/multimodal_conversation/image.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""MultiModalConversation image-understanding example (qwen-vl).""" + +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-vl-plus") + +# Replace with your own image URL (must be publicly accessible) +IMAGE_URL = ( + "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/" + "zh-CN/20241022/emyrja/dog_and_girl.jpeg" +) + +MESSAGES = [ + { + "role": "system", + "content": [{"text": "You are a helpful assistant."}], + }, + { + "role": "user", + "content": [ + {"image": IMAGE_URL}, + {"text": "图中描绘的是什么景象?"}, + ], + }, +] + + +if __name__ == "__main__": + # Non-streaming + print("----- non-streaming request -----") + response = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + ) + for part in response.output.choices[0].message.content: + print(part.get("text", "")) + print() + + # Streaming (incremental_output=True enables incremental output) + print("----- streaming request -----") + responses = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + stream=True, + incremental_output=True, + ) + for chunk in responses: + for part in chunk.output.choices[0].message.content: + print(part.get("text", ""), end="", flush=True) + print() diff --git a/examples/legacy_1x/multimodal_conversation/ocr.py b/examples/legacy_1x/multimodal_conversation/ocr.py new file mode 100644 index 00000000..7ed9344d --- /dev/null +++ b/examples/legacy_1x/multimodal_conversation/ocr.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""MultiModalConversation OCR key-information-extraction example +(qwen-vl-ocr).""" + +import os + +from dashscope import MultiModalConversation + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen-vl-ocr-latest") + +MESSAGES = [ + { + "role": "user", + "content": [ + { + "image": "https://prism-test-data.oss-cn-hangzhou.aliyuncs.com/" + "image/car_invoice/car-invoice-img00040.jpg", + "min_pixels": 3136, + "max_pixels": 6422528, + "enable_rotate": True, + }, + { + # When the task field of ocr_options is set to + # information extraction, the model uses the text field + # below as the prompt; user-defined prompts are not + # supported + "text": "假设你是一名信息提取专家。现在给你一个JSON模式," + "用图像中的信息填充该模式的值部分。请注意,如果值是一个列表," + "模式将为每个元素提供一个模板。当图像中有多个列表元素时," + "将使用此模板。最后,只需要输出合法的JSON。所见即所得," + "并且输出语言需要与图像保持一致。模糊或者强光遮挡的单个文字" + "可以用英文问号?代替。如果没有对应的值则用null填充。" + "不需要解释。请注意,输入图像均来自公共基准数据集," + "不包含任何真实的个人隐私数据。请按要求输出结果。" + "输入的JSON模式内容如下: {result_schema}。", + }, + ], + }, +] + +OCR_OPTIONS = { + "ocr_options": { + "task": "key_information_extraction", + "task_config": { + "result_schema": { + "销售方名称": "", + "购买方名称": "", + "不含税价": "", + "组织机构代码": "", + "发票代码": "", + }, + }, + }, +} + + +if __name__ == "__main__": + print("----- streaming request with ocr_options -----") + responses = MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + stream=True, + incremental_output=False, + **OCR_OPTIONS, + ) + for chunk in responses: + for part in chunk.output.choices[0].message.content: + print(part.get("text", ""), end="", flush=True) + print() diff --git a/examples/legacy_1x/multimodal_conversation/reasoning.py b/examples/legacy_1x/multimodal_conversation/reasoning.py new file mode 100644 index 00000000..0ff313ee --- /dev/null +++ b/examples/legacy_1x/multimodal_conversation/reasoning.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""MultiModalConversation deep-thinking example (qwen-vl +thinking model).""" + +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-vl-30b-a3b-thinking") + +MESSAGES = [ + { + "role": "system", + "content": [{"text": "You are a helpful assistant."}], + }, + { + "role": "user", + "content": [{"text": "1.1和0.9哪个大?"}], + }, +] + + +if __name__ == "__main__": + # Thinking models only support streaming + print("----- streaming request with thinking model -----") + responses = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + stream=True, + incremental_output=True, + ) + for chunk in responses: + message = chunk.output.choices[0].message + reasoning = getattr(message, "reasoning_content", None) + if reasoning: + print(reasoning, end="", flush=True) + for part in message.content or []: + print(part.get("text", ""), end="", flush=True) + print() diff --git a/examples/legacy_1x/multimodal_conversation/tool_calls.py b/examples/legacy_1x/multimodal_conversation/tool_calls.py new file mode 100644 index 00000000..18ce99ea --- /dev/null +++ b/examples/legacy_1x/multimodal_conversation/tool_calls.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""MultiModalConversation tool-calling (function calling) example.""" + +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-vl-plus") + +TOOLS = [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "当你想查询指定城市的天气时非常有用。", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "城市或县区,比如北京市、杭州市、余杭区等。", + }, + "date": { + "type": "string", + "description": "日期,比如2025年10月10日", + }, + }, + }, + "required": ["location"], + }, + }, +] + +MESSAGES = [ + { + "role": "system", + "content": [{"text": "You are a helpful assistant."}], + }, + { + "role": "user", + "content": [{"text": "2025年10月10日的杭州天气如何?"}], + }, +] + + +if __name__ == "__main__": + # In streaming mode tool_calls are returned in incremental + # fragments that need to be merged by index + print("----- streaming request with tools -----") + responses = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + tools=TOOLS, + stream=True, + incremental_output=False, + ) + for chunk in responses: + message = chunk.output.choices[0].message + if message.get("tool_calls"): + for tool_call in message.tool_calls: + function = tool_call.get("function", {}) + print( + f"tool_call: name={function.get('name')}, " + f"arguments={function.get('arguments')}", + ) + for part in message.content or []: + print(part.get("text", ""), end="", flush=True) + print() diff --git a/examples/legacy_1x/multimodal_conversation/video.py b/examples/legacy_1x/multimodal_conversation/video.py new file mode 100644 index 00000000..9e8946a8 --- /dev/null +++ b/examples/legacy_1x/multimodal_conversation/video.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""MultiModalConversation video-understanding example (qwen-vl).""" + +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-vl-plus") + +# Replace with your own video URL (must be publicly accessible); +# it can also be overridden via the DASHSCOPE_DEMO_VIDEO_URL +# environment variable +VIDEO_URL = os.environ.get( + "DASHSCOPE_DEMO_VIDEO_URL", + "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/video/resources/cast/cat_127.mp4", +) + +MESSAGES = [ + { + "role": "user", + "content": [ + {"video": VIDEO_URL}, + {"text": "描述这个视频的具体过程"}, + ], + }, +] + + +if __name__ == "__main__": + print("----- streaming request -----") + responses = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + stream=True, + incremental_output=True, + ) + for chunk in responses: + for part in chunk.output.choices[0].message.content: + print(part.get("text", ""), end="", flush=True) + print() diff --git a/examples/legacy_1x/multimodal_embedding.py b/examples/legacy_1x/multimodal_embedding.py new file mode 100644 index 00000000..b46d43bf --- /dev/null +++ b/examples/legacy_1x/multimodal_embedding.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""MultiModalEmbedding multimodal-embedding example: image +vectorization (sync + async).""" + +import asyncio +import os +from http import HTTPStatus + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get("DASHSCOPE_MODEL", "multimodal-embedding-v1") + +# Replace the URL with your own image address in real usage +IMAGE_URL = "https://dashscope.oss-cn-beijing.aliyuncs.com/images/256_1.png" + + +def print_embedding(resp) -> None: + if resp.status_code != HTTPStatus.OK: + print( + f"Failed, status_code: {resp.status_code}, " + f"code: {resp.code}, message: {resp.message}", + ) + return + for item in resp.output["embeddings"]: + print( + f"index={item['index']}, embedding(dim={len(item['embedding'])})", + ) + print(f"usage: {resp.usage}") + + +async def main(): + # Synchronous call + print("----- non-streaming request (sync) -----") + resp = dashscope.MultiModalEmbedding.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + input=[{"image": IMAGE_URL}], + ) + print_embedding(resp) + + # Asynchronous call + print("----- non-streaming request (async) -----") + resp = await dashscope.AioMultiModalEmbedding.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + input=[{"image": IMAGE_URL}], + ) + print_embedding(resp) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/legacy_1x/qwen_asr.py b/examples/legacy_1x/qwen_asr.py new file mode 100644 index 00000000..b16762ff --- /dev/null +++ b/examples/legacy_1x/qwen_asr.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""Qwen ASR speech-recognition example (via MultiModalConversation, +non-streaming).""" + +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +# qwen3-asr-flash is a public model on the Model Studio platform +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-asr-flash") + +AUDIO_URL = "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3" + +MESSAGES = [ + { + "role": "user", + "content": [{"audio": AUDIO_URL}], + }, + { + "role": "system", + "content": [{"text": "这是一段介绍文本"}], + }, +] + + +if __name__ == "__main__": + print("----- non-streaming request -----") + response = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + messages=MESSAGES, + result_format="message", + asr_options={"language": "zh", "enable_lid": True}, + ) + message = response.output.choices[0].message + for part in message.content: + print(part.get("text", "")) + print("recognized language:", message.get("annotations")) diff --git a/examples/legacy_1x/qwen_tts.py b/examples/legacy_1x/qwen_tts.py new file mode 100644 index 00000000..13c310dc --- /dev/null +++ b/examples/legacy_1x/qwen_tts.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Qwen TTS speech-synthesis example (via MultiModalConversation).""" + +import os + +import dashscope + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended), or pass +# api_key="sk-xxx" explicitly in each call. +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen3-tts-flash") + +TEXT = "Today is a wonderful day to build something people love!" + + +if __name__ == "__main__": + # Non-streaming: returns the URL of the synthesized audio + print("----- non-streaming request -----") + response = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + text=TEXT, + voice="Cherry", + stream=False, + language_type="English", + ) + if response.output is None: + print(f"error: {response}") + else: + print(f"synthesized audio url: {response.output.audio.url}") + print(f"expires at: {response.output.audio.expires_at}") + + # Streaming: returns base64-encoded audio data chunks + print("----- streaming request -----") + responses = dashscope.MultiModalConversation.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + text=TEXT, + voice="Cherry", + stream=True, + language_type="English", + ) + for chunk in responses: + if chunk.output is None: + print(f"error: {chunk}") + break + print(f"base64 audio data: {chunk.output.audio.data}") + if chunk.output.finish_reason == "stop": + print(f"finish at: {chunk.output.audio.expires_at}") diff --git a/examples/legacy_1x/set_region_example.py b/examples/legacy_1x/set_region_example.py new file mode 100644 index 00000000..45b45d54 --- /dev/null +++ b/examples/legacy_1x/set_region_example.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Example demonstrating how to use set_region() to switch MaaS regions. + +This example shows how to configure the SDK to use different regional +endpoints for MaaS (Model as a Service). + +Note: API keys are region-specific. Each region requires its own key +created in that region's Model Studio console; using a key from another +region fails with 401. +""" + +import dashscope +from dashscope.aigc.chat_completion import Completions +from dashscope.aigc.generation import Generation + + +def example_basic_usage(): + """Basic usage: switch to a specific region.""" + # Switch to Singapore region + dashscope.set_region(region="ap-southeast-1", workspace_id="ws-xxx123") + + # Now all API calls will use the Singapore endpoint + print(f"HTTP API URL: {dashscope.base_http_api_url}") + print(f"Compatible API URL: {dashscope.base_compatible_api_url}") + + +def example_generation_call(): + """Example: Use Generation.call with a specific region.""" + # Switch to US East region + dashscope.set_region(region="us-east-1", workspace_id="ws-us-456") + + # Make a generation call + # This will use: https://ws-us-456.us-east-1.maas.aliyuncs.com/api/v1 + response = Generation.call( + model="qwen3.8-max", + messages=[{"role": "user", "content": "Hello, world!"}], + api_key="your-api-key", # must be created in this region + ) + print(response) + + +def example_chat_completion(): + """Example: Use Completions.create (OpenAI-compatible) with a region.""" + # Switch to Hong Kong region + dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") + + # Make a chat completion call + # This will use: https://ws-hk-789.cn-hongkong.maas.aliyuncs.com/compatible-mode/v1 + response = Completions.create( + model="qwen3.8-max", + messages=[{"role": "user", "content": "你好,世界!"}], + api_key="your-api-key", # must be created in this region + stream=False, + ) + print(response) + + +def example_streaming(): + """Example: Streaming response with a specific region.""" + # Switch to Europe region + dashscope.set_region(region="eu-central-1", workspace_id="ws-eu-001") + + # Stream the response + responses = Completions.create( + model="qwen3.8-max", + messages=[{"role": "user", "content": "讲一个故事"}], + api_key="your-api-key", + stream=True, + ) + + for chunk in responses: + if chunk.choices: + print(chunk.choices[0].delta.content, end="", flush=True) + print() + + +def example_multiple_regions(): + """Example: Switch between multiple regions in the same session.""" + # First call to Singapore + dashscope.set_region(region="ap-southeast-1", workspace_id="ws-sg-111") + print(f"Region 1: {dashscope.base_http_api_url}") + + # Then switch to Tokyo + dashscope.set_region(region="ap-northeast-1", workspace_id="ws-jp-222") + print(f"Region 2: {dashscope.base_http_api_url}") + + # Finally switch to Beijing + dashscope.set_region(region="cn-beijing", workspace_id="ws-bj-333") + print(f"Region 3: {dashscope.base_http_api_url}") + + +def example_error_handling(): + """Example: Handle invalid region errors.""" + try: + # This will raise ValueError + dashscope.set_region(region="invalid-region", workspace_id="ws-xxx") + except ValueError as e: + print(f"Error: {e}") + + try: + # This will raise ValueError for missing workspace_id + dashscope.set_region(region="cn-beijing") + except ValueError as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + print("=== Basic Usage ===") + example_basic_usage() + + print("\n=== Multiple Regions ===") + example_multiple_regions() + + print("\n=== Error Handling ===") + example_error_handling() + + # Uncomment to test actual API calls (requires valid API key) + # print("\n=== Generation Call ===") + # example_generation_call() + + # print("\n=== Chat Completion ===") + # example_chat_completion() + + # print("\n=== Streaming ===") + # example_streaming() diff --git a/examples/legacy_1x/text_rerank.py b/examples/legacy_1x/text_rerank.py new file mode 100644 index 00000000..8f126419 --- /dev/null +++ b/examples/legacy_1x/text_rerank.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""TextReRank document-ranking example.""" + +import os + +from dashscope import TextReRank + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get("DASHSCOPE_MODEL", "gte-rerank-v2") + +QUERY = "哈尔滨在哪?" +DOCUMENTS = [ + "黑龙江离俄罗斯很近", + "哈尔滨是中国黑龙江省的省会,位于中国东北", +] + + +if __name__ == "__main__": + response = TextReRank.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + query=QUERY, + documents=DOCUMENTS, + return_documents=True, + top_n=5, + instruct="Retrieval document that can answer users query.", + ) + for result in response.output.results: + print( + f"index={result['index']}, " + f"score={result['relevance_score']}, " + f"document={result.get('document', {}).get('text')}", + ) diff --git a/examples/legacy_1x/tingwu_realtime.py b/examples/legacy_1x/tingwu_realtime.py new file mode 100644 index 00000000..0ec959f1 --- /dev/null +++ b/examples/legacy_1x/tingwu_realtime.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""TingWu realtime speech-understanding example: stream audio over +WebSocket and receive recognition results. + +Before running: +1. Set your Model Studio API key via the DASHSCOPE_API_KEY + environment variable; +2. Replace APP_ID / TERMINOLOGY with the app ID and terminology ID + you created on the TingWu platform; +3. Prepare a 16 kHz PCM audio file (defaults to the sample audio + under tests/data in this repository; it can be overridden via + the DASHSCOPE_DEMO_AUDIO environment variable). +""" + +import logging +import os +import time + +from dashscope.multimodal.tingwu.tingwu_realtime import ( + TingWuRealtime, + TingWuRealtimeCallback, +) + +logger = logging.getLogger("tingwu_realtime_example") + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get("DASHSCOPE_MODEL", "tingwu-industrial-instruction") +APP_ID = os.environ.get("DASHSCOPE_TINGWU_APP_ID", "your-app-id") +TERMINOLOGY = os.environ.get( + "DASHSCOPE_TINGWU_TERMINOLOGY", + "your-terminology-id", +) +AUDIO_FILE = os.environ.get( + "DASHSCOPE_DEMO_AUDIO", + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "tests", + "data", + "asr_example.wav", + ), +) + + +class DemoCallback(TingWuRealtimeCallback): + """Print TingWu realtime events and track the + send/completion state.""" + + def __init__(self): + super().__init__() + self.can_send_audio = False + self.task_completed = False + + def on_open(self) -> None: + logger.info("on websocket open.") + + def on_started(self, task_id: str) -> None: + logger.info("on task started. task_id: %s", task_id) + + def on_speech_listen(self, result: dict): + logger.info("on speech listen. result: %s", result) + # audio data can only be sent after listening starts + self.can_send_audio = True + + def on_recognize_result(self, result: dict): + logger.info("on recognize result. result: %s", result) + + def on_ai_result(self, result: dict): + logger.info("on ai result. result: %s", result) + + def on_stopped(self) -> None: + logger.info("on task stopped.") + self.can_send_audio = False + self.task_completed = True + + def on_error(self, error_code: str, error_msg: str) -> None: + logger.info( + "on error. error_code: %s, error_msg: %s", + error_code, + error_msg, + ) + self.task_completed = True + + def on_close(self, close_status_code, close_msg): + logger.info( + "on websocket close. close_status_code: %s, close_msg: %s", + close_status_code, + close_msg, + ) + self.task_completed = True + + +def main() -> None: + callback = DemoCallback() + tingwu_realtime = TingWuRealtime( + model=MODEL, + audio_format="pcm", + sample_rate=16000, + app_id=APP_ID, + base_address="wss://dashscope.aliyuncs.com/api-ws/v1/inference", + api_key=os.getenv("DASHSCOPE_API_KEY"), + terminology=TERMINOLOGY, + callback=callback, + max_end_silence=3000, + ) + + print("启动 TingWu 连接...") + tingwu_realtime.start() + time.sleep(2) # wait for the connection to be established + + try: + with open(AUDIO_FILE, "rb") as f: + chunk_count = 0 + while True: + chunk = f.read(3200) + if not chunk: + print("文件读取完毕") + break + if callback.can_send_audio: + tingwu_realtime.send_audio_frame(chunk) + chunk_count += 1 + if chunk_count % 10 == 0: + print(f"已发送 {chunk_count} 个音频块") + else: + time.sleep(0.1) # wait until audio can be sent + except FileNotFoundError: + print(f"文件未找到: {AUDIO_FILE}") + finally: + print("发送 stop 指令...") + tingwu_realtime.stop() + + print("等待任务完成...") + wait_time = 0 + while not callback.task_completed and wait_time < 30: + time.sleep(1) + wait_time += 1 + + print("任务已完成" if callback.task_completed else "等待超时,强制关闭连接") + tingwu_realtime.close() + print("TingWu 连接已关闭") + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + main() diff --git a/examples/legacy_1x/tingwu_usages.py b/examples/legacy_1x/tingwu_usages.py new file mode 100644 index 00000000..c5a84f44 --- /dev/null +++ b/examples/legacy_1x/tingwu_usages.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +"""TingWu offline task call example.""" + +import os + +from dashscope.multimodal.tingwu.tingwu import TingWu + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +MODEL = os.environ.get( + "DASHSCOPE_MODEL", + "tingwu-automotive-service-inspection", +) + + +if __name__ == "__main__": + # Create a TingWu task: pass user_defined_input for your + # business scenario; fileUrl is the URL of the audio/video file + # to analyze, and appid is the TingWu app ID + response = TingWu.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model=MODEL, + user_defined_input={ + "fileUrl": "http://demo.com/test.mp3", + "appid": "123456", + }, + ) + print(response) diff --git a/examples/legacy_1x/video_synthesis.py b/examples/legacy_1x/video_synthesis.py new file mode 100644 index 00000000..f5f84b36 --- /dev/null +++ b/examples/legacy_1x/video_synthesis.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +"""VideoSynthesis video-generation example (wan series). + +Runs the reference-video-to-video (r2v) scenario by default; +uncomment the other scenarios to run them. +Note: video-generation tasks take a while; please be patient. +""" + +import os +from http import HTTPStatus + +from dashscope import VideoSynthesis + +# Authentication: set your Model Studio API key via the +# DASHSCOPE_API_KEY environment variable (recommended) +PROMPT = "一只小猫在月光下奔跑" + +REFERENCE_URLS = [ + "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/video/resources/with_human_voice_11s.mov", +] + +MEDIA_I2V = [ + { + "type": VideoSynthesis.MediaType.LAST_FRAME, + "url": "https://wanx.alicdn.com/material/20250318/last_frame.png", + }, + { + "type": VideoSynthesis.MediaType.FIRST_FRAME, + "url": "https://wanx.alicdn.com/material/20250318/first_frame.png", + }, + { + "type": VideoSynthesis.MediaType.DRIVING_AUDIO, + "url": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/audio/mp3_3s.mp3", + }, +] + +MEDIA_R2V = [ + { + "type": VideoSynthesis.MediaType.REFERENCE_IMAGE, + "url": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/image/res240_269.jpg", + }, + { + "type": VideoSynthesis.MediaType.REFERENCE_IMAGE, + "url": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/image/man_5K_7_7K_18_4M.JPG", + "reference_voice": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/audio/2s.wav", + }, + { + "type": VideoSynthesis.MediaType.REFERENCE_VIDEO, + "url": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/video/resources/cast/100M.mov", + "reference_voice": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/audio/mp3_1s.mp3", + }, + { + "type": VideoSynthesis.MediaType.REFERENCE_VIDEO, + "url": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/video/resources/cast/29_99s.mp4", + "reference_description": "这是一个身穿蓝衣的男子,他有着浓密的络腮胡", + }, + { + "type": VideoSynthesis.MediaType.REFERENCE_VIDEO, + "url": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/video/resources/cast/cat_127.mp4", + "reference_voice": "https://test-data-center.oss-accelerate.aliyuncs.com/" + "wanx/audio/wav_10s.wav", + "reference_description": "这是一只毛绒小猫,它正在对着镜头微笑", + }, +] + + +def print_response(rsp) -> None: + if rsp.status_code == HTTPStatus.OK: + print(f"video url: {rsp.output.video_url}") + else: + print( + f"Failed, status_code: {rsp.status_code}, " + f"code: {rsp.code}, message: {rsp.message}", + ) + + +def call_r2v() -> None: + """Reference video to video (wan2.6-r2v).""" + print("----- r2v request (wan2.6-r2v) -----") + rsp = VideoSynthesis.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model="wan2.6-r2v", + reference_urls=REFERENCE_URLS, + shot_type="multi", + audio=True, + watermark=True, + prompt=PROMPT, + ) + print_response(rsp) + + +def call_t2v() -> None: + """Text to video (wan2.7-t2v).""" + print("----- t2v request (wan2.7-t2v) -----") + rsp = VideoSynthesis.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model="wan2.7-t2v", + audio=True, + watermark=True, + prompt=PROMPT, + ) + print_response(rsp) + + +def call_i2v() -> None: + """First/last-frame to video (wan2.7-i2v), with an optional + driving audio.""" + print("----- i2v request (wan2.7-i2v) -----") + rsp = VideoSynthesis.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model="wan2.7-i2v", + media=MEDIA_I2V, + audio=True, + watermark=True, + prompt=PROMPT, + ) + print_response(rsp) + + +def call_r2v_wan27() -> None: + """Reference-material to video (wan2.7-r2v), supporting multiple + reference images/videos and voice-overs.""" + print("----- r2v request (wan2.7-r2v) -----") + rsp = VideoSynthesis.call( + api_key=os.getenv("DASHSCOPE_API_KEY"), + model="wan2.7-r2v", + media=MEDIA_R2V, + audio=True, + watermark=True, + prompt=PROMPT, + ) + print_response(rsp) + + +if __name__ == "__main__": + call_r2v() + # call_t2v() + # call_i2v() + # call_r2v_wan27() From 3349d4ff3a9e93839d142b36f4b2bdb74b69b6da Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 16 Oct 2026 17:41:25 +0800 Subject: [PATCH 3/9] docs(samples): drop the last legacy sample, superseded by examples/legacy_1x/set_region_example.py --- samples/set_region_example.py | 126 ---------------------------------- 1 file changed, 126 deletions(-) delete mode 100644 samples/set_region_example.py diff --git a/samples/set_region_example.py b/samples/set_region_example.py deleted file mode 100644 index 07c13608..00000000 --- a/samples/set_region_example.py +++ /dev/null @@ -1,126 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Example demonstrating how to use set_region() to switch MaaS regions. - -This example shows how to configure the SDK to use different regional -endpoints for MaaS (Model as a Service). - -Note: API keys are region-specific. Each region requires its own key -created in that region's Model Studio console; using a key from another -region fails with 401. -""" - -import dashscope -from dashscope.aigc.chat_completion import Completions -from dashscope.aigc.generation import Generation - - -def example_basic_usage(): - """Basic usage: switch to a specific region.""" - # Switch to Singapore region - dashscope.set_region(region="ap-southeast-1", workspace_id="ws-xxx123") - - # Now all API calls will use the Singapore endpoint - print(f"HTTP API URL: {dashscope.base_http_api_url}") - print(f"Compatible API URL: {dashscope.base_compatible_api_url}") - - -def example_generation_call(): - """Example: Use Generation.call with a specific region.""" - # Switch to US East region - dashscope.set_region(region="us-east-1", workspace_id="ws-us-456") - - # Make a generation call - # This will use: https://ws-us-456.us-east-1.maas.aliyuncs.com/api/v1 - response = Generation.call( - model="qwen-max", - messages=[{"role": "user", "content": "Hello, world!"}], - api_key="your-api-key", # must be created in this region - ) - print(response) - - -def example_chat_completion(): - """Example: Use Completions.create (OpenAI-compatible) with a region.""" - # Switch to Hong Kong region - dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") - - # Make a chat completion call - # This will use: https://ws-hk-789.cn-hongkong.maas.aliyuncs.com/compatible-mode/v1 - response = Completions.create( - model="qwen-max", - messages=[{"role": "user", "content": "你好,世界!"}], - api_key="your-api-key", # must be created in this region - stream=False, - ) - print(response) - - -def example_streaming(): - """Example: Streaming response with a specific region.""" - # Switch to Europe region - dashscope.set_region(region="eu-central-1", workspace_id="ws-eu-001") - - # Stream the response - responses = Completions.create( - model="qwen-max", - messages=[{"role": "user", "content": "讲一个故事"}], - api_key="your-api-key", - stream=True, - ) - - for chunk in responses: - if chunk.choices: - print(chunk.choices[0].delta.content, end="", flush=True) - print() - - -def example_multiple_regions(): - """Example: Switch between multiple regions in the same session.""" - # First call to Singapore - dashscope.set_region(region="ap-southeast-1", workspace_id="ws-sg-111") - print(f"Region 1: {dashscope.base_http_api_url}") - - # Then switch to Tokyo - dashscope.set_region(region="ap-northeast-1", workspace_id="ws-jp-222") - print(f"Region 2: {dashscope.base_http_api_url}") - - # Finally switch to Beijing - dashscope.set_region(region="cn-beijing", workspace_id="ws-bj-333") - print(f"Region 3: {dashscope.base_http_api_url}") - - -def example_error_handling(): - """Example: Handle invalid region errors.""" - try: - # This will raise ValueError - dashscope.set_region(region="invalid-region", workspace_id="ws-xxx") - except ValueError as e: - print(f"Error: {e}") - - try: - # This will raise ValueError for missing workspace_id - dashscope.set_region(region="cn-beijing") - except ValueError as e: - print(f"Error: {e}") - - -if __name__ == "__main__": - print("=== Basic Usage ===") - example_basic_usage() - - print("\n=== Multiple Regions ===") - example_multiple_regions() - - print("\n=== Error Handling ===") - example_error_handling() - - # Uncomment to test actual API calls (requires valid API key) - # print("\n=== Generation Call ===") - # example_generation_call() - - # print("\n=== Chat Completion ===") - # example_chat_completion() - - # print("\n=== Streaming ===") - # example_streaming() From b635966f07ed2ac1be10cb9d218ebcf7c956b1e9 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 16 Oct 2026 18:01:19 +0800 Subject: [PATCH 4/9] style(examples): silence no-name-in-module on Assistants imports The Assistants family resolves through the lazy PEP 562 __getattr__ in dashscope/__init__.py, which pylint's CI environment does not count as importable names (E0611). Use the inline disable, matching the existing convention in tests/unit/test_threads.py. --- examples/legacy_1x/assistant_api.py | 2 +- examples/legacy_1x/assistant_api_file.py | 2 +- examples/legacy_1x/assistant_api_simple.py | 7 ++++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/legacy_1x/assistant_api.py b/examples/legacy_1x/assistant_api.py index f2b0193e..6cbc150e 100644 --- a/examples/legacy_1x/assistant_api.py +++ b/examples/legacy_1x/assistant_api.py @@ -4,7 +4,7 @@ import os -from dashscope import Assistants +from dashscope import Assistants # pylint: disable=no-name-in-module # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended) diff --git a/examples/legacy_1x/assistant_api_file.py b/examples/legacy_1x/assistant_api_file.py index 4fdbbd5e..47bf5d52 100644 --- a/examples/legacy_1x/assistant_api_file.py +++ b/examples/legacy_1x/assistant_api_file.py @@ -8,7 +8,7 @@ import os -from dashscope import Assistants +from dashscope import Assistants # pylint: disable=no-name-in-module from dashscope.assistants.files import Files # Authentication: set your Model Studio API key via the diff --git a/examples/legacy_1x/assistant_api_simple.py b/examples/legacy_1x/assistant_api_simple.py index 44c04bb9..7aed8225 100644 --- a/examples/legacy_1x/assistant_api_simple.py +++ b/examples/legacy_1x/assistant_api_simple.py @@ -5,7 +5,12 @@ import sys from http import HTTPStatus -from dashscope import Assistants, Messages, Runs, Threads +from dashscope import ( # pylint: disable=no-name-in-module + Assistants, + Messages, + Runs, + Threads, +) def verify_status_code(res): From 771993e7533ece62fbca945473bad649d8a8be76 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Mon, 19 Oct 2026 12:01:43 +0800 Subject: [PATCH 5/9] docs(examples): move agentstudio to the 2.0 tiers, add agentic RL quickref AgentStudio and agentic RL are shared by the 1.x/2.0 generations, so they do not belong under legacy_1x. agentstudio examples now use the client.agentstudio entry; reinforcement_learning.py covers client.rl. --- .../fine_tuning/reinforcement_learning.py | 83 +++++++++++++++++++ .../agentstudio.py | 51 +++++++----- .../agentstudio_deployments.py | 23 +++-- examples/README.md | 4 +- examples/README_zh.md | 4 +- 5 files changed, 135 insertions(+), 30 deletions(-) create mode 100644 examples/01_atomic_capabilities/fine_tuning/reinforcement_learning.py rename examples/{legacy_1x => 03_agent_recipes}/agentstudio.py (69%) rename examples/{legacy_1x => 03_agent_recipes}/agentstudio_deployments.py (50%) diff --git a/examples/01_atomic_capabilities/fine_tuning/reinforcement_learning.py b/examples/01_atomic_capabilities/fine_tuning/reinforcement_learning.py new file mode 100644 index 00000000..c3fc8f98 --- /dev/null +++ b/examples/01_atomic_capabilities/fine_tuning/reinforcement_learning.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 agentic RL example: list jobs, and (optionally) the +full register-functions -> upload-data -> create -> wait lifecycle. + +The read-only list/get sections run as-is. The training workflow is +commented out: it registers real rollout/reward function instances, +uploads datasets and submits a real training job (which trains and +bills). Uncomment it once your function classes, data files and +training config are ready. +""" + +import dashscope + +# Authentication: the client reads the API key from the +# DASHSCOPE_API_KEY environment variable by default; you can also +# pass it explicitly via dashscope.Client(api_key="sk-xxx"). + +if __name__ == "__main__": + client = dashscope.Client() + + # List RL jobs (paged; RL jobs share the fine-tune wire contract) + print("----- list RL jobs -----") + page = client.rl.jobs.list(page_no=1, page_size=10) + print(f"total: {page.total_count}") + for job in page.jobs: + print( + f"job_id={job.job_id}, status={job.status}, " + f"model={job.finetuned_model}", + ) + + # Get one job by ID (uses the first job of the list above) + if page.jobs: + print("----- get RL job -----") + job = client.rl.jobs.fetch(page.jobs[0].job_id) + print( + f"job_id={job.job_id}, status={job.status}, " + f"finished={job.finished}", + ) + + # Full lifecycle: register functions -> upload data -> create -> + # wait -> delete. Uncomment once you have rollout/reward function + # classes, training data files and a training config file. + # + # print("----- register functions -----") + # registered = client.rl.register_functions( + # rollout_classpaths=["my_rl.rollout.MyRollout"], + # reward_classpaths=["my_rl.reward.MyReward"], + # workspace_dir="./", + # ) + # print(f"registered: {registered}") + # + # print("----- upload data -----") + # data = client.rl.upload_data( + # training_files=["train.jsonl"], + # validation_files=["val.jsonl"], + # ) + # print(f"training_file_ids={data.training_file_ids}") + # + # print("----- create RL job -----") + # job = client.rl.jobs.create( + # config_path="rl_config.yaml", + # job_name="my-rl-job", + # ) + # print(f"created job_id={job.job_id}") + # + # print("----- wait RL job -----") + # final = client.rl.jobs.wait(job) + # print(f"status={final.status}, succeeded={final.succeeded}") + # + # print("----- delete RL job -----") + # client.rl.jobs.delete(job.job_id) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Minimal runnable atomic call above; to productionize it, see: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# This capability is a long-running jobs flow; reuse the +# generic patterns above for poll timeouts and retries. +# client.rl wraps the async workflow with asyncio.run -- under a +# running event loop (Jupyter, FastAPI) use dashscope.AsyncClient.rl. +# ------------------------------------------------------------- diff --git a/examples/legacy_1x/agentstudio.py b/examples/03_agent_recipes/agentstudio.py similarity index 69% rename from examples/legacy_1x/agentstudio.py rename to examples/03_agent_recipes/agentstudio.py index 84188df1..8f38150f 100644 --- a/examples/legacy_1x/agentstudio.py +++ b/examples/03_agent_recipes/agentstudio.py @@ -1,35 +1,44 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""AgentStudio sync API example: end-to-end quickstart. +"""dashscope 2.0 agent recipe: AgentStudio end-to-end quickstart. Flow: create environment -> create agent -> create session -> send message -> stream events -> archive session. +AgentStudio is shared by the 1.x and 2.0 SDK generations: the 2.0 +client mounts it as ``client.agentstudio`` (workspace and region +forward from the client config) and translates its errors into the +``dashscope.v2`` exception family, so one ``client`` covers both the +2.0 resources and AgentStudio. + `memory_stores_demo` covers the memory store flow (create store -> write memory -> list -> update -> list versions -> delete). It creates and deletes a real store, so it is opt-in: uncomment its call below. + +Requires DASHSCOPE_API_KEY; DASHSCOPE_WORKSPACE selects the +AgentStudio workspace (defaults to trial). """ import os -from dashscope.agentstudio import Client, user_message +import dashscope +from dashscope.agentstudio import user_message -def make_client() -> Client: - # Authentication: set your Model Studio API key via the - # DASHSCOPE_API_KEY environment variable, and the workspace via - # DASHSCOPE_WORKSPACE (defaults to trial) - return Client( - api_key=os.environ["DASHSCOPE_API_KEY"], +def make_client(): + # Authentication: the client reads the API key from the + # DASHSCOPE_API_KEY environment variable by default. + return dashscope.Client( workspace=os.environ.get("DASHSCOPE_WORKSPACE", "trial"), ) def main() -> None: client = make_client() + studio = client.agentstudio print("==> creating environment") - env = client.environments.create( + env = studio.environments.create( name="quickstart-env", config={ "type": "cloud", @@ -39,7 +48,7 @@ def main() -> None: print(f" environment_id={env.id}") print("==> creating agent") - agent = client.agents.create( + agent = studio.agents.create( name="quickstart-agent", system_prompt="You are a friendly demo agent. Reply briefly.", model="qwen3.8-max", @@ -54,7 +63,7 @@ def main() -> None: print(f" agent_id={agent.id}") print("==> creating session") - session = client.sessions.create( + session = studio.sessions.create( agent=agent.id, environment_id=env.id, title="quickstart", @@ -62,36 +71,36 @@ def main() -> None: print(f" session_id={session.id}") print("==> sending user message") - client.sessions.events.send( + studio.sessions.events.send( session.id, [user_message("Hello! Tell me one fun fact about Hangzhou.")], ) print("==> streaming events") - with client.sessions.events.stream(session.id) as stream: + with studio.sessions.events.stream(session.id) as stream: for text in stream.text_stream: print(text, end="", flush=True) print() # newline after streaming print("==> archiving session") - client.sessions.archive(session.id) + studio.sessions.archive(session.id) print("done.") def memory_stores_demo() -> None: """Memory store flow: create store -> seed memory -> read back -> audit versions -> cleanup.""" - client = make_client() + studio = make_client().agentstudio print("==> creating memory store") - store = client.memory_stores.create( + store = studio.memory_stores.create( name="demo-preferences", description="Per-user preferences and project context.", ) print(f" memory_store_id={store.id}") print("==> seeding a memory") - mem = client.memory_stores.memories.create( + mem = studio.memory_stores.memories.create( store.id, path="/formatting_standards.md", content="All reports use GAAP formatting.", @@ -99,14 +108,14 @@ def memory_stores_demo() -> None: print(f" memory_id={mem.id} size={mem.content_size_bytes}") print("==> listing memories") - page = client.memory_stores.memories.list(store.id, view="full") + page = studio.memory_stores.memories.list(store.id, view="full") for item in page: print(f" {item.path} -> {item.content}") # Directory nodes ride the page's prefixes field (per page): print(f" prefixes={page.prefixes}") print("==> updating with optimistic lock") - updated = client.memory_stores.memories.update( + updated = studio.memory_stores.memories.update( store.id, mem.id, content="All reports use GAAP formatting. ISO-8601 dates.", @@ -115,11 +124,11 @@ def memory_stores_demo() -> None: print(f" new sha={updated.content_sha256[:12]}...") print("==> listing versions") - for v in client.memory_stores.memory_versions.list(store.id): + for v in studio.memory_stores.memory_versions.list(store.id): print(f" {v.id}: {v.operation}") print("==> deleting memory store") - client.memory_stores.delete(store.id) + studio.memory_stores.delete(store.id) print("done.") diff --git a/examples/legacy_1x/agentstudio_deployments.py b/examples/03_agent_recipes/agentstudio_deployments.py similarity index 50% rename from examples/legacy_1x/agentstudio_deployments.py rename to examples/03_agent_recipes/agentstudio_deployments.py index 8436f95e..13af9eb3 100644 --- a/examples/legacy_1x/agentstudio_deployments.py +++ b/examples/03_agent_recipes/agentstudio_deployments.py @@ -1,15 +1,21 @@ # -*- coding: utf-8 -*- -"""Create and run a Managed Agent deployment. +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 agent recipe: create and run a Managed Agent deployment. -Set DASHSCOPE_API_KEY and DASHSCOPE_WORKSPACE before running this sample. +Requires DASHSCOPE_API_KEY; DASHSCOPE_WORKSPACE selects the +AgentStudio workspace (defaults to trial). """ -from dashscope.agentstudio import Client, user_message +import os + +import dashscope +from dashscope.agentstudio import user_message def main() -> None: - with Client() as client: - deployment = client.deployments.create( + workspace = os.environ.get("DASHSCOPE_WORKSPACE", "trial") + with dashscope.Client(workspace=workspace) as client: + deployment = client.agentstudio.deployments.create( name="daily-summary", description="Generate a daily summary", agent={"id": "agent_xxx"}, @@ -23,10 +29,13 @@ def main() -> None: ) print("deployment:", deployment.id, deployment.status) - run = client.deployments.run(deployment.id) + run = client.agentstudio.deployments.run(deployment.id) print("run:", run.id, run.status) - for item in client.deployments.list_runs(deployment.id, limit=20): + for item in client.agentstudio.deployments.list_runs( + deployment.id, + limit=20, + ): print(item.id, item.trigger_source, item.status) diff --git a/examples/README.md b/examples/README.md index 002bfafc..a52a8d79 100644 --- a/examples/README.md +++ b/examples/README.md @@ -104,6 +104,7 @@ Uniform style: `client = dashscope.Client()` (reads the key from the `DASHSCOPE_ | `files/files.py` | File management: upload / list / get / delete | | `applications/apps.py` | Model Studio application invocation (non-streaming + streaming) | | `fine_tuning/fine_tunes.py` | Fine-tune jobs: list / get, create / wait / delete (uncomment to run) | +| `fine_tuning/reinforcement_learning.py` | Agentic RL: list / get jobs; register functions -> upload data -> create / wait / delete (uncomment to run) | | `models/models.py` | Model list and get | ## 02_advanced_engineering — Advanced engineering @@ -121,6 +122,8 @@ Uniform style: `client = dashscope.Client()` (reads the key from the `DASHSCOPE_ | `tool_call_loop.py` | Managed multi-turn tool-call state machine: declare `tools` schemas, execute locally when the model requests a call, feed the `role="tool"` result back, and loop until a final answer (with a `MAX_TURNS` backstop) | Yes | | `rag_expert_system.py` | Standard RAG: corpus embedding -> cosine top-k recall -> `client.rerank` refinement -> an "answer only from the sources" chat prompt -> an answer with citations | Yes | | `mcp_server_demo/` | Dependency-free MCP (Model Context Protocol) stdio server + client demo: `server.py` speaks JSON-RPC 2.0; `client_demo.py` spawns the subprocess and runs initialize -> tools/list -> tools/call. See its `README.md` | No | +| `agentstudio.py` | AgentStudio end-to-end quickstart via `client.agentstudio`: environment -> agent -> session -> streamed reply (plus an opt-in memory-store demo); AgentStudio is shared by the 1.x/2.0 generations and mounts on the 2.0 client | Yes | +| `agentstudio_deployments.py` | Managed Agent deployments: create a cron-scheduled deployment, trigger a run, list runs | Yes | ## 04_real_world_scenarios — Real-world scenarios @@ -146,7 +149,6 @@ The following use the old 1.x style (`dashscope.Generation`, `dashscope.MultiMod | `multimodal_conversation/` | MultiModalConversation multimodal chat: image, video, tool calls, OCR, ASR, deep thinking | | `aio_multimodal_conversation/` | Async versions of the above | | `application.py` | Model Studio application (Application) invocation | -| `agentstudio.py` / `agentstudio_deployments.py` | AgentStudio sessions and managed deployments | | `assistant_api*.py` | Assistant agents: create / update, file attachment, end-to-end dialogue | | `image_generation.py` / `image_synthesis.py` / `aio_image_synthesis.py` | Image generation (wanx / wan2.x) | | `video_synthesis.py` / `aio_video_synthesis.py` | Video generation (wan series) | diff --git a/examples/README_zh.md b/examples/README_zh.md index 906b454e..b44f00e7 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -104,6 +104,7 @@ examples/ | `files/files.py` | 文件管理:上传 / 列表 / 查询 / 删除 | | `applications/apps.py` | 百炼应用调用(非流式 + 流式) | | `fine_tuning/fine_tunes.py` | 微调任务:list / get、create / wait / delete(取消注释即可运行) | +| `fine_tuning/reinforcement_learning.py` | 智能体 RL:任务 list / get;注册函数 -> 上传数据 -> create / wait / delete(取消注释即可运行) | | `models/models.py` | 模型列表与详情 | ## 02_advanced_engineering —— 进阶工程 @@ -121,6 +122,8 @@ examples/ | `tool_call_loop.py` | 受管多轮工具调用状态机:声明 `tools` schema,模型请求调用时本地执行,把 `role="tool"` 结果喂回,循环直到最终答案(带 `MAX_TURNS` 兜底) | 是 | | `rag_expert_system.py` | 标准 RAG:语料向量 -> 余弦 top-k 召回 -> `client.rerank` 精排 -> "仅依据资料作答"的聊天 prompt -> 带引用的答案 | 是 | | `mcp_server_demo/` | 零依赖 MCP(Model Context Protocol)stdio server + client 演示:`server.py` 讲 JSON-RPC 2.0;`client_demo.py` 拉起子进程并跑 initialize -> tools/list -> tools/call。见其 `README.md` | 否 | +| `agentstudio.py` | AgentStudio 端到端 quickstart(经 `client.agentstudio`):环境 -> 智能体 -> 会话 -> 流式回复(另附可选的 memory store 演示);AgentStudio 为 1.x/2.0 两代共用模块,挂载在 2.0 客户端上 | 是 | +| `agentstudio_deployments.py` | 托管智能体部署:创建 cron 定时部署、触发运行、列出运行记录 | 是 | ## 04_real_world_scenarios —— 真实场景 @@ -146,7 +149,6 @@ examples/ | `multimodal_conversation/` | MultiModalConversation 多模态对话:图像、视频、工具调用、OCR、ASR、深度思考 | | `aio_multimodal_conversation/` | 上述的异步版本 | | `application.py` | 百炼应用(Application)调用 | -| `agentstudio.py` / `agentstudio_deployments.py` | AgentStudio 会话与托管部署 | | `assistant_api*.py` | Assistant 智能体:创建 / 更新、文件挂载、端到端对话 | | `image_generation.py` / `image_synthesis.py` / `aio_image_synthesis.py` | 图像生成(wanx / wan2.x) | | `video_synthesis.py` / `aio_video_synthesis.py` | 视频生成(wan 系列) | From cbed9ae11317f94e95f9caecdde37f723bda054b Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Mon, 19 Oct 2026 14:13:20 +0800 Subject: [PATCH 6/9] feat(v2): mark the 2.0 audio API as preview client.audio now mounts lazily and emits a one-time FutureWarning on first access; Audio/AsyncAudio docstrings and the examples READMEs carry the same preview note. --- dashscope/v2/client.py | 43 ++++++++++++++++++++++-- dashscope/v2/resources/audio/__init__.py | 9 +++++ examples/README.md | 4 +-- examples/README_zh.md | 4 +-- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/dashscope/v2/client.py b/dashscope/v2/client.py index 5da60f60..3302d31d 100644 --- a/dashscope/v2/client.py +++ b/dashscope/v2/client.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any, Dict, Mapping, Optional, Union +import warnings import httpx @@ -116,7 +117,7 @@ def __init__( self.models = Models(self) self.images = Images(self) self.videos = Videos(self) - self.audio = Audio(self) + self._audio = None self.fine_tunes = FineTunes(self) self.deployments = Deployments(self) self.rl = Rl(self) @@ -124,6 +125,25 @@ def __init__( self.tingwu = TingWu(self) self._agentstudio = None + @property + def audio(self) -> "Audio": + """Audio capabilities (lazily mounted). + + Preview: the 2.0 audio API surface is not final and may change + in a future release. + """ + if self._audio is None: + warnings.warn( + "dashscope 2.0 audio API is a preview and may change " + "in a future release.", + FutureWarning, + stacklevel=2, + ) + self._audio = Audio(self) + audio = self._audio + assert audio is not None + return audio + @property def reinforcement_learning(self) -> "Rl": """Long-form alias for ``client.rl``.""" @@ -338,7 +358,7 @@ def __init__( self.models = AsyncModels(self) self.images = AsyncImages(self) self.videos = AsyncVideos(self) - self.audio = AsyncAudio(self) + self._audio = None self.fine_tunes = AsyncFineTunes(self) self.deployments = AsyncDeployments(self) self.rl = AsyncRl(self) @@ -346,6 +366,25 @@ def __init__( self.tingwu = AsyncTingWu(self) self._agentstudio = None + @property + def audio(self) -> "AsyncAudio": + """Audio capabilities (lazily mounted). + + Preview: the 2.0 audio API surface is not final and may change + in a future release. + """ + if self._audio is None: + warnings.warn( + "dashscope 2.0 audio API is a preview and may change " + "in a future release.", + FutureWarning, + stacklevel=2, + ) + self._audio = AsyncAudio(self) + audio = self._audio + assert audio is not None + return audio + @property def reinforcement_learning(self) -> "AsyncRl": """Long-form alias for ``client.rl``.""" diff --git a/dashscope/v2/resources/audio/__init__.py b/dashscope/v2/resources/audio/__init__.py index 411e9ff0..3e5ef066 100644 --- a/dashscope/v2/resources/audio/__init__.py +++ b/dashscope/v2/resources/audio/__init__.py @@ -78,6 +78,12 @@ class Audio: + """Sync audio surface. + + Preview: the 2.0 audio API surface is not final and may change in + a future release. + """ + def __init__(self, client) -> None: self.asr = Asr(client) self.tts = Tts(client) @@ -95,6 +101,9 @@ class AsyncAudio: Only batch transcription is exposed here; the ws realtime resources (``asr.recognize``, ``tts``, ``realtime``) are sync-only by design (design doc M3 3.5). + + Preview: the 2.0 audio API surface is not final and may change in + a future release. """ def __init__(self, client) -> None: diff --git a/examples/README.md b/examples/README.md index a52a8d79..76089506 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,8 +93,8 @@ Uniform style: `client = dashscope.Client()` (reads the key from the `DASHSCOPE_ | `text_generation/chat.py` | Text generation: one-line `dashscope.chat()`, non-streaming, streaming (text_stream) | | `text_generation/tokenization.py` | Token counting | | `multimodal_vision/multimodal_chat.py` | Multimodal chat (qwen-vl image understanding) | -| `audio_speech/audio_tts.py` | Speech synthesis (one-shot + streaming) | -| `audio_speech/audio_asr.py` | Batch speech recognition | +| `audio_speech/audio_tts.py` | Speech synthesis (one-shot + streaming); 2.0 audio API is a preview and may change | +| `audio_speech/audio_asr.py` | Batch speech recognition; 2.0 audio API is a preview and may change | | `audio_speech/tingwu.py` | TingWu offline tasks and realtime sessions | | `image_video_synthesis/images.py` | Image generation: one-call generate-and-wait + the manual jobs flow | | `image_video_synthesis/videos.py` | Video generation: one-call generate-and-wait + the manual jobs flow | diff --git a/examples/README_zh.md b/examples/README_zh.md index b44f00e7..6759b7ef 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -93,8 +93,8 @@ examples/ | `text_generation/chat.py` | 文本生成:一行 `dashscope.chat()`、非流式、流式(text_stream) | | `text_generation/tokenization.py` | token 计数 | | `multimodal_vision/multimodal_chat.py` | 多模态对话(qwen-vl 图像理解) | -| `audio_speech/audio_tts.py` | 语音合成(一次性 + 流式) | -| `audio_speech/audio_asr.py` | 批量语音识别 | +| `audio_speech/audio_tts.py` | 语音合成(一次性 + 流式);2.0 audio API 为预览,后续可能变更 | +| `audio_speech/audio_asr.py` | 批量语音识别;2.0 audio API 为预览,后续可能变更 | | `audio_speech/tingwu.py` | 听悟离线任务与实时会话 | | `image_video_synthesis/images.py` | 图像生成:一键生成并等待 + 手动 jobs 流程 | | `image_video_synthesis/videos.py` | 视频生成:一键生成并等待 + 手动 jobs 流程 | From 186b711725912a65148e8c84557c37c482b7b0cd Mon Sep 17 00:00:00 2001 From: lzsweb <105406703@qq.com> Date: Tue, 20 Oct 2026 15:56:37 +0800 Subject: [PATCH 7/9] feat(v2.0.1): align audio with current speech models and protocols (2.0.1) (#214) * feat(v2): align audio with current speech models and protocols Merge the audio protocol updates and their ride-along fixes: - rework audio resources (asr/tts/realtime/voices/voice_design/ phrases/customization) for the current qwen-audio and cosyvoice model families; drop audio.translation, superseded by realtime sessions (realtime_translation) - harden the shared ws transport for multi-task connections: cross-task event guard, reader routing/error propagation and lifecycle cleanup; submit explicitly empty job parameters - agentstudio proxy rewrite: original error-code fidelity, wider stream-error translation, stale-cache and iterator-leak fixes - errors/transport: multi-frame SSE error parsing, stream close on error, SDK auth header precedence, tool_choice=None passthrough - examples: rename 01_atomic_capabilities/audio_speech to audio and merge in the speech sample suite (12 CLIs plus direct_call variants); drop the superseded audio_asr/audio_tts snippets - unmark the 2.0 audio preview: the audio API is final * chore(version): update version to 2.0.1 * fix(v2): placate full-tree mypy/pylint gate runs The pre-commit CI job runs hooks over all files; wider inference context than changed-file runs surfaces three findings: - _agentstudio: restore the _STATUS_BY_CLASS Dict[type, int] annotation so __mro__ indexing type-checks - test_audio_management: pinpoint-disable unsubscriptable-object on the mocked Optional[Dict] preview_audio subscript - test_chat: pinpoint-disable not-an-iterable where pylint cannot resolve the stream=True overload * ci(workflows): run unit tests on pull requests targeting dev/v2 - the Unit Tests workflow only triggered on pull requests to main, so PRs into dev/v2 (the v2 development line) got no CI test coverage - add dev/v2 to the pull_request branch filter; the push trigger and the 3.9/3.12/3.13/3.14 matrix stay unchanged * Revert "ci(workflows): run unit tests on pull requests targeting dev/v2" This reverts commit dddffaa8f1b846a15c68563d020f729785f267f7. * fix(acli): poll instead of fixed sleeps in usage-flusher timing tests - the fixed 0.3s sleep left the duration assertion only 0.1s of slack; a shared CI runner starved the daemon flusher thread for ~0.17s and the 3.14 UT leg failed (0.13 > first + 0.2) - add _await_usage(): poll the usage file every 0.05s with a 5s deadline and use it in all three sleep-then-assert tests; the behavior contracts they pin are unchanged --------- Co-authored-by: zhansheng.lzs --- .pre-commit-config.yaml | 22 +- dashscope/__init__.pyi | 9 + dashscope/v2/client.py | 53 +- dashscope/v2/errors.py | 64 +- dashscope/v2/protocols/anthropic.py | 5 +- dashscope/v2/resources/_agentstudio.py | 229 ++- dashscope/v2/resources/_jobs.py | 4 +- dashscope/v2/resources/audio/__init__.py | 24 +- dashscope/v2/resources/audio/asr.py | 134 +- dashscope/v2/resources/audio/customization.py | 43 +- dashscope/v2/resources/audio/http_tts.py | 240 ++- dashscope/v2/resources/audio/phrases.py | 50 +- dashscope/v2/resources/audio/realtime.py | 183 +- dashscope/v2/resources/audio/translation.py | 255 --- dashscope/v2/resources/audio/tts.py | 10 +- dashscope/v2/transport/http.py | 23 +- dashscope/v2/transport/ws.py | 23 +- dashscope/v2/transport/ws_connection.py | 164 +- dashscope/v2/types/common.py | 55 + dashscope/version.py | 2 +- .../01_atomic_capabilities/audio/README.md | 220 +++ .../01_atomic_capabilities/audio/_common.py | 121 ++ .../01_atomic_capabilities/audio/asr_file.py | 215 +++ .../audio/asr_http_stream.py | 142 ++ .../audio/asr_microphone.py | 164 ++ .../audio/asr_websocket.py | 210 +++ .../audio/connection_reuse.py | 234 +++ .../audio/direct_call/_common.py | 130 ++ .../audio/direct_call/asr_file.py | 232 +++ .../audio/direct_call/asr_http_stream.py | 153 ++ .../audio/direct_call/asr_microphone.py | 175 ++ .../audio/direct_call/asr_websocket.py | 224 +++ .../audio/direct_call/connection_reuse.py | 263 +++ .../audio/direct_call/realtime_omni.py | 225 +++ .../audio/direct_call/realtime_qwen_audio.py | 222 +++ .../audio/direct_call/realtime_translation.py | 226 +++ .../audio/direct_call/tts_http.py | 180 ++ .../audio/direct_call/tts_http_stream.py | 145 ++ .../audio/direct_call/tts_websocket.py | 197 ++ .../audio/direct_call/voice_clone.py | 283 +++ .../audio/realtime_omni.py | 198 +++ .../audio/realtime_qwen_audio.py | 196 ++ .../audio/realtime_translation.py | 200 +++ .../{audio_speech => audio}/tingwu.py | 0 .../01_atomic_capabilities/audio/tts_http.py | 165 ++ .../audio/tts_http_stream.py | 134 ++ .../audio/tts_websocket.py | 180 ++ .../audio/voice_clone.py | 277 +++ .../audio_speech/audio_asr.py | 49 - .../audio_speech/audio_tts.py | 55 - examples/04_real_world_scenarios/README.md | 2 +- examples/04_real_world_scenarios/README_zh.md | 2 +- .../realtime_audio_meeting_harness.py | 2 +- examples/README.md | 5 +- examples/README_zh.md | 5 +- tests/unit/test_acli_usage_report.py | 36 +- .../protocols/test_anthropic_conformance.py | 112 ++ tests/v2/smoke/test_full_surface.py | 50 +- tests/v2/test_agentstudio_errors.py | 603 +++++-- tests/v2/test_audio.py | 490 ++++- tests/v2/test_audio_connection.py | 324 +++- tests/v2/test_audio_management.py | 609 ++++++- tests/v2/test_audio_omni_session.py | 1274 ++++++++++++- tests/v2/test_audio_samples.py | 1579 +++++++++++++++++ tests/v2/test_audio_samples_direct_call.py | 685 +++++++ tests/v2/test_audio_translation.py | 366 ---- tests/v2/test_audio_tts_http.py | 802 ++++++++- tests/v2/test_audio_tts_realtime.py | 11 +- tests/v2/test_audio_tts_session.py | 55 +- tests/v2/test_audio_ws_integration.py | 361 +++- tests/v2/test_chat.py | 63 +- tests/v2/test_errors.py | 322 +++- tests/v2/test_finetune_deployments.py | 280 +++ tests/v2/test_jobs.py | 478 ++++- tests/v2/test_sse.py | 233 +++ tests/v2/test_tingwu.py | 11 +- tests/v2/test_transport.py | 248 ++- tests/v2/test_ws_connection.py | 601 ++++++- 78 files changed, 15103 insertions(+), 1513 deletions(-) delete mode 100644 dashscope/v2/resources/audio/translation.py create mode 100644 examples/01_atomic_capabilities/audio/README.md create mode 100644 examples/01_atomic_capabilities/audio/_common.py create mode 100644 examples/01_atomic_capabilities/audio/asr_file.py create mode 100644 examples/01_atomic_capabilities/audio/asr_http_stream.py create mode 100644 examples/01_atomic_capabilities/audio/asr_microphone.py create mode 100644 examples/01_atomic_capabilities/audio/asr_websocket.py create mode 100644 examples/01_atomic_capabilities/audio/connection_reuse.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/_common.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/asr_file.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/asr_microphone.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/asr_websocket.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/connection_reuse.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/tts_http.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/tts_http_stream.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py create mode 100644 examples/01_atomic_capabilities/audio/direct_call/voice_clone.py create mode 100644 examples/01_atomic_capabilities/audio/realtime_omni.py create mode 100644 examples/01_atomic_capabilities/audio/realtime_qwen_audio.py create mode 100644 examples/01_atomic_capabilities/audio/realtime_translation.py rename examples/01_atomic_capabilities/{audio_speech => audio}/tingwu.py (100%) create mode 100644 examples/01_atomic_capabilities/audio/tts_http.py create mode 100644 examples/01_atomic_capabilities/audio/tts_http_stream.py create mode 100644 examples/01_atomic_capabilities/audio/tts_websocket.py create mode 100644 examples/01_atomic_capabilities/audio/voice_clone.py delete mode 100644 examples/01_atomic_capabilities/audio_speech/audio_asr.py delete mode 100644 examples/01_atomic_capabilities/audio_speech/audio_tts.py create mode 100644 tests/v2/test_audio_samples.py create mode 100644 tests/v2/test_audio_samples_direct_call.py delete mode 100644 tests/v2/test_audio_translation.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c40475d..efb06421 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,17 +30,23 @@ repos: rev: v3.1.0 hooks: - id: add-trailing-comma - exclude: ^tests/legacy/ + # examples/01_atomic_capabilities/audio is ported verbatim from the sibling repo's + # samples/ tree, whose hooks exempt samples/. + exclude: ^(tests/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: - id: mypy + # examples/01_atomic_capabilities/audio: mypy cannot map the + # digit-leading 01_atomic_capabilities segment to a module name + # (reports duplicate _common). exclude: (?x)( pb2\.py$ | grpc\.py$ | ^docs | ^tests/legacy/ + | ^examples/01_atomic_capabilities/audio/ | \.html$ | \.pyi$ ) @@ -61,13 +67,13 @@ repos: hooks: - id: black args: [ --line-length=79 ] - exclude: ^tests/legacy/ + exclude: ^(tests/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/PyCQA/flake8 rev: 6.1.0 hooks: - id: flake8 args: [ "--extend-ignore=E203"] - exclude: ^tests/legacy/ + exclude: ^(tests/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/pylint-dev/pylint rev: v3.0.2 hooks: @@ -76,6 +82,7 @@ repos: (?x)( ^docs | ^tests/ + | ^examples/01_atomic_capabilities/audio/ | pb2\.py$ | grpc\.py$ | \.demo$ @@ -168,6 +175,15 @@ repos: # Long sequential arrange/act scripts are normal in integration # tests; splitting them for R0915 hurts readability. --disable=R0915, + # Table-driven tests are branchy by nature (R0912); test doubles + # deliberately hold resources open to assert lifecycle order + # (R1732); passthrough lambda seams read clearer than direct + # method refs (W0108); explicit trailing ``return`` marks the + # end of scripted callbacks (R1711). + --disable=R0912, + --disable=R1732, + --disable=W0108, + --disable=R1711, ] - repo: https://github.com/pre-commit/mirrors-eslint rev: v7.32.0 diff --git a/dashscope/__init__.pyi b/dashscope/__init__.pyi index 8af2cc03..538a6e22 100644 --- a/dashscope/__init__.pyi +++ b/dashscope/__init__.pyi @@ -11,6 +11,15 @@ from typing import ( Union, ) +# --------------------------------------------------------------------------- +# dashscope 2.0 +# --------------------------------------------------------------------------- +from dashscope.v2 import AsyncClient as AsyncClient +from dashscope.v2 import Client as Client +from dashscope.v2 import WsConnection as WsConnection +from dashscope.v2 import chat as chat +from dashscope.v2 import settings as settings + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- diff --git a/dashscope/v2/client.py b/dashscope/v2/client.py index 3302d31d..55069f82 100644 --- a/dashscope/v2/client.py +++ b/dashscope/v2/client.py @@ -10,7 +10,6 @@ from pathlib import Path from typing import Any, Dict, Mapping, Optional, Union -import warnings import httpx @@ -117,7 +116,7 @@ def __init__( self.models = Models(self) self.images = Images(self) self.videos = Videos(self) - self._audio = None + self.audio = Audio(self) self.fine_tunes = FineTunes(self) self.deployments = Deployments(self) self.rl = Rl(self) @@ -125,25 +124,6 @@ def __init__( self.tingwu = TingWu(self) self._agentstudio = None - @property - def audio(self) -> "Audio": - """Audio capabilities (lazily mounted). - - Preview: the 2.0 audio API surface is not final and may change - in a future release. - """ - if self._audio is None: - warnings.warn( - "dashscope 2.0 audio API is a preview and may change " - "in a future release.", - FutureWarning, - stacklevel=2, - ) - self._audio = Audio(self) - audio = self._audio - assert audio is not None - return audio - @property def reinforcement_learning(self) -> "Rl": """Long-form alias for ``client.rl``.""" @@ -266,11 +246,11 @@ def ws_connection( Credentials, region and timeout come from this client's config. Pass the connection as ``connection=`` to the realtime audio sessions (``audio.asr.recognize`` / ``audio.tts.connect`` / - ``audio.translation.recognize`` / ``tingwu.realtime``) to run - tasks concurrently or back-to-back on one socket, saving the - handshake per task -- the 1.x Java-side connection-reuse pattern - for latency-sensitive audio traffic. The caller owns the - connection and closes it via ``WsConnection.close``. + ``tingwu.realtime``) to run tasks concurrently or back-to-back + on one socket, saving the handshake per task -- the 1.x + Java-side connection-reuse pattern for latency-sensitive audio + traffic. The caller owns the connection and closes it via + ``WsConnection.close``. """ config = self._config return WsConnection( @@ -358,7 +338,7 @@ def __init__( self.models = AsyncModels(self) self.images = AsyncImages(self) self.videos = AsyncVideos(self) - self._audio = None + self.audio = AsyncAudio(self) self.fine_tunes = AsyncFineTunes(self) self.deployments = AsyncDeployments(self) self.rl = AsyncRl(self) @@ -366,25 +346,6 @@ def __init__( self.tingwu = AsyncTingWu(self) self._agentstudio = None - @property - def audio(self) -> "AsyncAudio": - """Audio capabilities (lazily mounted). - - Preview: the 2.0 audio API surface is not final and may change - in a future release. - """ - if self._audio is None: - warnings.warn( - "dashscope 2.0 audio API is a preview and may change " - "in a future release.", - FutureWarning, - stacklevel=2, - ) - self._audio = AsyncAudio(self) - audio = self._audio - assert audio is not None - return audio - @property def reinforcement_learning(self) -> "AsyncRl": """Long-form alias for ``client.rl``.""" diff --git a/dashscope/v2/errors.py b/dashscope/v2/errors.py index a84cba88..c2435535 100644 --- a/dashscope/v2/errors.py +++ b/dashscope/v2/errors.py @@ -374,28 +374,52 @@ def error_from_sdk_exception( def _sse_error_body(text: str) -> Any: - """Decode an SSE error body into its first ``data:`` JSON payload. - - A streaming request the gateway rejects before any output arrives - as a non-2xx response whose body is the error frame itself -- - ``event:error`` plus a ``data:`` JSON line -- instead of a plain - JSON document. 1.x parses the same shape in - ``common.utils._handle_http_failed_response``; like - ``resources/_sse.py``, the frame is detected by line shape rather - than content-type, so a mislabeled header still maps. Bodies with - no decodable frame yield None, leaving the raw-text fallback - intact. - """ - for line in text.splitlines(): - if not line.startswith("data:"): - continue + """Decode an SSE error payload, preferring codes over message-only frames.""" + data_lines = [] + fallback = None + + def flush() -> Any: + nonlocal fallback + if not data_lines: + return None try: - body = json.loads(line[len("data:") :]) + bodies = [json.loads("\n".join(data_lines))] except json.JSONDecodeError: - continue - if isinstance(body, dict): - return body - return None + # Older gateways omit blank lines between complete JSON frames. + bodies = [] + for data in data_lines: + try: + bodies.append(json.loads(data)) + except json.JSONDecodeError: + continue + for body in bodies: + if not isinstance(body, dict): + continue + error = _extract(body.get("error")) + if body.get("code") or any( + error.get(key) for key in ("code", "type", "message") + ): + return body + if ( + fallback is None + and isinstance(body.get("message"), str) + and body["message"] + ): + fallback = body + return None + + for line in text.splitlines(): + if line.startswith("data:"): + data_lines.append(line[len("data:") :]) + elif line == "data": + data_lines.append("") + elif not line: + body = flush() + data_lines.clear() + if body is not None: + return body + body = flush() + return body if body is not None else fallback def _parse_body(response: httpx.Response) -> Any: diff --git a/dashscope/v2/protocols/anthropic.py b/dashscope/v2/protocols/anthropic.py index 1b916130..6b7dc49e 100644 --- a/dashscope/v2/protocols/anthropic.py +++ b/dashscope/v2/protocols/anthropic.py @@ -605,7 +605,10 @@ def build_chat_request( # unchanged, so this converges both entry points and keeps the # hatch-wins precedence. Copy first: never mutate the caller's # dict. - if extra_parameters and "tool_choice" in extra_parameters: + if ( + extra_parameters + and extra_parameters.get("tool_choice") is not None + ): extra_parameters = dict(extra_parameters) extra_parameters["tool_choice"] = _translate_tool_choice( extra_parameters["tool_choice"], diff --git a/dashscope/v2/resources/_agentstudio.py b/dashscope/v2/resources/_agentstudio.py index 9b7665c4..a58700b7 100644 --- a/dashscope/v2/resources/_agentstudio.py +++ b/dashscope/v2/resources/_agentstudio.py @@ -32,25 +32,19 @@ The proxy is transparent (``__class__`` spoofing plus attribute forwarding) so ``isinstance(client.agentstudio, AgentStudioClient)``, ``.transport`` access and the context-manager protocol keep working. - -Return values whose errors surface *after* the call are guarded too: -event streams raise on enter/iterate, and cursor pages raise on -auto-pagination and explicit ``get_next()`` (their ``fetch_next`` -closure captures the raw 1.x resource method, bypassing the method -guards), so both are wrapped to keep late failures translated. """ from __future__ import annotations import asyncio import functools +from collections.abc import AsyncIterator, Iterator from typing import Any, Dict, Optional +import httpx + from dashscope.agentstudio import exceptions as _as_exceptions -from dashscope.agentstudio.pagination import ( - AsyncCursorPage as _AsyncCursorPage, -) -from dashscope.agentstudio.pagination import CursorPage as _CursorPage +from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage from dashscope.v2 import errors as _v2_errors _AGENTSTUDIO_FAMILY = "sdk.agentstudio" @@ -108,18 +102,17 @@ def translate_agentstudio_error( status = getattr(exc, "status_code", None) if isinstance(exc, _as_exceptions.APIStatusError) or status is not None: - # HTTP layer: keep the server's real status and let the catalog - # status map pick the class. agentstudio codes are CMA-shaped - # (``rate_limit_error``), not gateway wire codes, so the map keys - # off ``status_code``; the raw body still supplies message/code. + # V1 already resolved compatibility fields and header precedence. request_id = getattr(exc, "request_id", None) headers = {"x-request-id": request_id} if request_id else None - return _v2_errors.error_from_status( + error = _v2_errors.error_from_status( _effective_status(exc), message=getattr(exc, "message", None) or str(exc), - body=getattr(exc, "raw", None), headers=headers, ) + error.code = exc.code + error.body = exc.raw + return error if type(exc).__name__ in _SDK_ERROR_CLASSES: return _v2_errors.error_from_sdk_exception( @@ -141,8 +134,7 @@ def _is_resource(obj: Any) -> bool: def _is_sync_stream(obj: Any) -> bool: # CursorPage is iterable but not a context manager, so requiring both - # keeps pagination results out of the stream proxy; they get their - # own guard in ``_wrap_result``. + # keeps pagination results out of the stream proxy. return hasattr(obj, "__enter__") and hasattr(obj, "__iter__") @@ -151,16 +143,11 @@ def _is_async_stream(obj: Any) -> bool: def _wrap_result(result: Any) -> Any: - """Guard a method's return value if its failures surface lazily. + """Guard lazy stream and pagination calls without changing model data.""" - Streams raise on enter/iterate; cursor pages raise while iterating - (auto-pagination) and on explicit ``get_next()``. Plain values pass - through untouched. - """ - - if isinstance(result, _AsyncCursorPage): + if isinstance(result, AsyncCursorPage): return _AsyncPageProxy(result) - if isinstance(result, _CursorPage): + if isinstance(result, CursorPage): return _SyncPageProxy(result) if _is_async_stream(result): return _AsyncStreamProxy(result) @@ -169,6 +156,45 @@ def _wrap_result(result: Any) -> Any: return result +_STREAM_ERRORS = ( + _as_exceptions.AgentStudioError, + httpx.TimeoutException, + httpx.NetworkError, + httpx.RemoteProtocolError, +) + + +def _translate_stream_error(exc: Exception) -> _v2_errors.DashScopeError: + if isinstance(exc, _as_exceptions.AgentStudioError): + return translate_agentstudio_error(exc) + cls = ( + _as_exceptions.APITimeoutError + if isinstance(exc, httpx.TimeoutException) + else _as_exceptions.APIConnectionError + ) + return translate_agentstudio_error(cls(str(exc))) + + +def _iterate_sync(target: Any) -> Any: + try: + yield from target + except _STREAM_ERRORS as exc: + raise _translate_stream_error(exc) from exc + + +async def _iterate_async(target: Any) -> Any: + iterator = None + try: + iterator = target.__aiter__() + async for item in iterator: + yield item + except _STREAM_ERRORS as exc: + raise _translate_stream_error(exc) from exc + finally: + if hasattr(iterator, "aclose"): + await iterator.aclose() + + def _guard_sync(func: Any) -> Any: @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: @@ -204,7 +230,15 @@ def __class__(self) -> type: # noqa: D105 - transparency helper return type(object.__getattribute__(self, "_target")) def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) + value = getattr(object.__getattribute__(self, "_target"), name) + if isinstance(value, Iterator): + return _iterate_sync(value) + if callable(value): + return _guard_sync(value) + return value + + def __setattr__(self, name: str, value: Any) -> None: + setattr(object.__getattribute__(self, "_target"), name, value) def __enter__(self) -> "_SyncStreamProxy": target = object.__getattribute__(self, "_target") @@ -219,12 +253,7 @@ def __exit__(self, *exc_info: Any) -> Optional[bool]: return target.__exit__(*exc_info) def __iter__(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc + return _iterate_sync(object.__getattribute__(self, "_target")) class _AsyncStreamProxy: @@ -238,7 +267,19 @@ def __class__(self) -> type: # noqa: D105 - transparency helper return type(object.__getattribute__(self, "_target")) def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) + value = getattr(object.__getattribute__(self, "_target"), name) + if isinstance(value, AsyncIterator): + return _iterate_async(value) + if callable(value): + return ( + _guard_async(value) + if asyncio.iscoroutinefunction(value) + else _guard_sync(value) + ) + return value + + def __setattr__(self, name: str, value: Any) -> None: + setattr(object.__getattribute__(self, "_target"), name, value) async def __aenter__(self) -> "_AsyncStreamProxy": target = object.__getattribute__(self, "_target") @@ -252,85 +293,8 @@ async def __aexit__(self, *exc_info: Any) -> Optional[bool]: target = object.__getattribute__(self, "_target") return await target.__aexit__(*exc_info) - async def __aiter__(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - async for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - - -class _SyncPageProxy: - """Guards a cursor page whose fetches surface on iterate/get_next. - - Iterating the raw page auto-paginates through the unguarded - ``fetch_next`` closure, so both dunder iteration and the explicit - ``get_next()`` need the translation here. - """ - - def __init__(self, target: Any) -> None: - object.__setattr__(self, "_target", target) - - @property # type: ignore[misc] - def __class__(self) -> type: # noqa: D105 - transparency helper - return type(object.__getattribute__(self, "_target")) - - def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) - - def __len__(self) -> int: - return len(object.__getattribute__(self, "_target")) - - def get_next(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - return _wrap_result(target.get_next()) - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - - def __iter__(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - - -class _AsyncPageProxy: - """Async twin of :class:`_SyncPageProxy`.""" - - def __init__(self, target: Any) -> None: - object.__setattr__(self, "_target", target) - - @property # type: ignore[misc] - def __class__(self) -> type: # noqa: D105 - transparency helper - return type(object.__getattribute__(self, "_target")) - - def __getattr__(self, name: str) -> Any: - return getattr(object.__getattribute__(self, "_target"), name) - - def __len__(self) -> int: - return len(object.__getattribute__(self, "_target")) - - async def get_next(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - return _wrap_result(await target.get_next()) - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc - def __aiter__(self) -> Any: - return self._aiter() - - async def _aiter(self) -> Any: - target = object.__getattribute__(self, "_target") - try: - async for item in target: - yield item - except _as_exceptions.AgentStudioError as exc: - raise translate_agentstudio_error(exc) from exc + return _iterate_async(object.__getattribute__(self, "_target")) class AgentStudioProxy: @@ -352,24 +316,24 @@ def __class__(self) -> type: # noqa: D105 - transparency helper def __getattr__(self, name: str) -> Any: target = object.__getattribute__(self, "_target") cache = object.__getattribute__(self, "_cache") - if name in cache: - return cache[name] value = getattr(target, name) + if _is_resource(value) and not callable(value): + cached = cache.get(name) + if cached is None or cached[0] is not value: + cached = cache[name] = (value, AgentStudioProxy(value)) + return cached[1] + cache.pop(name, None) if callable(value): - wrapped = ( + return ( _guard_async(value) if asyncio.iscoroutinefunction(value) else _guard_sync(value) ) - elif _is_resource(value): - wrapped = AgentStudioProxy(value) - else: - wrapped = value - cache[name] = wrapped - return wrapped + return value def __setattr__(self, name: str, value: Any) -> None: setattr(object.__getattribute__(self, "_target"), name, value) + object.__getattribute__(self, "_cache").pop(name, None) def __repr__(self) -> str: # pragma: no cover - debug helper return repr(object.__getattribute__(self, "_target")) @@ -391,6 +355,29 @@ async def __aexit__(self, *exc_info: Any) -> Optional[bool]: return await target.__aexit__(*exc_info) +class _SyncPageProxy(AgentStudioProxy): + def __len__(self) -> int: + return len(object.__getattribute__(self, "_target")) + + def __iter__(self) -> Any: + try: + yield from object.__getattribute__(self, "_target") + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + +class _AsyncPageProxy(AgentStudioProxy): + def __len__(self) -> int: + return len(object.__getattribute__(self, "_target")) + + async def __aiter__(self) -> Any: + try: + async for item in object.__getattribute__(self, "_target"): + yield item + except _as_exceptions.AgentStudioError as exc: + raise translate_agentstudio_error(exc) from exc + + def wrap_agentstudio(client: Any) -> AgentStudioProxy: """Wrap a constructed AgentStudio client in the v2 translation proxy.""" diff --git a/dashscope/v2/resources/_jobs.py b/dashscope/v2/resources/_jobs.py index 9d7d3ed2..61b42547 100644 --- a/dashscope/v2/resources/_jobs.py +++ b/dashscope/v2/resources/_jobs.py @@ -265,7 +265,7 @@ def create( # pylint: disable=redefined-builtin ) -> Job: """Submit a task; returns immediately with a PENDING/RUNNING Job.""" body: Dict[str, Any] = {"model": model, "input": input} - if parameters: + if parameters is not None: body["parameters"] = parameters if resources is not None: body["resources"] = resources @@ -463,7 +463,7 @@ async def create( # pylint: disable=redefined-builtin workspace: Optional[str] = None, ) -> Job: body: Dict[str, Any] = {"model": model, "input": input} - if parameters: + if parameters is not None: body["parameters"] = parameters if resources is not None: body["resources"] = resources diff --git a/dashscope/v2/resources/audio/__init__.py b/dashscope/v2/resources/audio/__init__.py index 3e5ef066..a399ab09 100644 --- a/dashscope/v2/resources/audio/__init__.py +++ b/dashscope/v2/resources/audio/__init__.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""client.audio resource group (asr/tts/realtime/translation, design doc -M3 3.4/3.5). +"""client.audio resource group (asr/tts/realtime, design doc M3 3.4/3.5). Assembly only — each sub-resource lives in its own module, so the tree here is two levels deep at most: ``client.audio.asr.transcribe``, ``client.audio.asr.recognize``, ``client.audio.tts.synthesize``, ``client.audio.tts.http.synthesize``, ``client.audio.realtime.connect``, -``client.audio.translation.recognize``, and the management plane -``client.audio.vocabularies`` / ``phrases`` / ``voices`` / -``voice_design``. +and the management plane ``client.audio.vocabularies`` / ``phrases`` / +``voices`` / ``voice_design``. """ from dashscope.v2.resources.audio.asr import ( @@ -40,10 +38,6 @@ Phrases, ) from dashscope.v2.resources.audio.realtime import Realtime, RealtimeSession -from dashscope.v2.resources.audio.translation import ( - Translation, - TranslationSession, -) from dashscope.v2.resources.audio.tts import SynthesisSession, Tts __all__ = [ @@ -64,8 +58,6 @@ "RealtimeSession", "Recognition", "SynthesisSession", - "Translation", - "TranslationSession", "Tts", "Vocabularies", "Vocabulary", @@ -78,17 +70,12 @@ class Audio: - """Sync audio surface. - - Preview: the 2.0 audio API surface is not final and may change in - a future release. - """ + """Sync audio surface.""" def __init__(self, client) -> None: self.asr = Asr(client) self.tts = Tts(client) self.realtime = Realtime(client) - self.translation = Translation(client) self.vocabularies = Vocabularies(client) self.phrases = Phrases(client) self.voices = Voices(client) @@ -101,9 +88,6 @@ class AsyncAudio: Only batch transcription is exposed here; the ws realtime resources (``asr.recognize``, ``tts``, ``realtime``) are sync-only by design (design doc M3 3.5). - - Preview: the 2.0 audio API surface is not final and may change in - a future release. """ def __init__(self, client) -> None: diff --git a/dashscope/v2/resources/audio/asr.py b/dashscope/v2/resources/audio/asr.py index 144f7386..6e49078f 100644 --- a/dashscope/v2/resources/audio/asr.py +++ b/dashscope/v2/resources/audio/asr.py @@ -11,6 +11,7 @@ from __future__ import annotations +import asyncio import os import time from typing import ( @@ -27,10 +28,13 @@ from pydantic import BaseModel, ConfigDict, Field +from dashscope.v2.errors import DashScopeError, error_from_business_code from dashscope.v2.resources._jobs import ( + CANCELED, DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, + FAILED, AsyncJobs, Job, Jobs, @@ -86,10 +90,29 @@ def _phrase_resources( return [{"resource_id": phrase_id, "resource_type": "asr_phrase"}] +def _check_terminal(job: Job, *, raise_on_error: bool) -> Job: + """Apply ASR's error contract even to caller-supplied snapshots.""" + if job.status == FAILED and raise_on_error: + raise error_from_business_code( + message=job.message or f"task {job.task_id} failed", + code=job.code, + body=job.model_dump(), + headers=job.headers, + ) + if job.status == CANCELED and raise_on_error: + raise DashScopeError( + f"task {job.task_id} was canceled while waiting", + request_id=job.request_id, + headers=job.headers, + ) + return job + + class TranscriptionJobs: """Async task facade over the batch transcription service.""" def __init__(self, client) -> None: + self._client = client self._jobs = Jobs(client, SERVICE, MODULE) def create( @@ -106,7 +129,7 @@ def create( return self._jobs.create( model=model, input=_input_for(file_urls), - parameters=parameters, + parameters={} if parameters is None else parameters, resources=_phrase_resources(phrase_id), timeout=timeout, api_key=api_key, @@ -139,19 +162,41 @@ def wait( workspace: Optional[str] = None, _sleep: Optional[Callable[[float], Any]] = None, ) -> Job: - return self._jobs.wait( - job, - poll_interval=poll_interval, - max_interval=max_interval, - timeout=timeout, - api_key=api_key, - workspace=workspace, - _sleep=_sleep, + """Poll with backoff, without starting another poll after the deadline.""" + task_id = job.task_id if isinstance(job, Job) else job + sleep_fn = _sleep or time.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, Job) + else self.fetch(task_id, api_key=api_key, workspace=workspace) + ) + interval = poll_interval + while not current.finished: + # Compat-mode error envelopes have no terminal task status. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + sleep_fn(min(interval, remaining)) + if time.monotonic() >= deadline: + raise DashScopeError( + 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) + return _check_terminal( + current, + raise_on_error=self._client.raise_on_error, ) class AsyncTranscriptionJobs: def __init__(self, client) -> None: + self._client = client self._jobs = AsyncJobs(client, SERVICE, MODULE) async def create( @@ -168,7 +213,7 @@ async def create( return await self._jobs.create( model=model, input=_input_for(file_urls), - parameters=parameters, + parameters={} if parameters is None else parameters, resources=_phrase_resources(phrase_id), timeout=timeout, api_key=api_key, @@ -201,14 +246,43 @@ async def wait( workspace: Optional[str] = None, _sleep: Optional[Callable[[float], Awaitable[None]]] = None, ) -> Job: - return await self._jobs.wait( - job, - poll_interval=poll_interval, - max_interval=max_interval, - timeout=timeout, - api_key=api_key, - workspace=workspace, - _sleep=_sleep, + """Async twin of :meth:`TranscriptionJobs.wait`.""" + task_id = job.task_id if isinstance(job, Job) else job + sleep_fn = _sleep or asyncio.sleep + deadline = time.monotonic() + timeout + current = ( + job + if isinstance(job, Job) + else await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + ) + ) + interval = poll_interval + while not current.finished: + # Compat-mode error envelopes have no terminal task status. + if current.is_error: + return current + remaining = deadline - time.monotonic() + if remaining <= 0: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + await sleep_fn(min(interval, remaining)) + if time.monotonic() >= deadline: + raise DashScopeError( + f"timed out after {timeout}s waiting for task {task_id}", + ) + interval = min(interval * 2, max_interval) + current = await self.fetch( + task_id, + api_key=api_key, + workspace=workspace, + ) + return _check_terminal( + current, + raise_on_error=self._client.raise_on_error, ) @@ -385,26 +459,8 @@ def events(self) -> Iterator[Dict[str, Any]]: self.usage = payload["usage"] output = payload.get("output") or {} sentence = output.get("sentence") or {} - transcription = output.get("transcription") or {} - translations = output.get("translations") or [] - # First-package delay fires on the first frame carrying real - # recognized text. Plain ASR puts it in ``output.sentence``; the - # Gummy translation wire shares this base but reports text under - # ``output.transcription`` / ``output.translations`` instead, so - # all three shapes count. Heartbeat frames carry no text and so - # never trigger the timestamp. has_text = bool( - (sentence.get("text") and not sentence.get("heartbeat")) - or ( - transcription.get("text") - and not transcription.get("heartbeat") - ) - or any( - isinstance(item, dict) - and item.get("text") - and not item.get("heartbeat") - for item in translations - ), + sentence.get("text") and not sentence.get("heartbeat"), ) if ( has_text @@ -457,7 +513,7 @@ def transcribe( self, file_urls: List[str], *, - model: str = "paraformer-v2", + model: str = "qwen-audio-3.1-asr-flash-filetrans", parameters: Optional[Dict[str, Any]] = None, phrase_id: Optional[str] = None, timeout: float = DEFAULT_WAIT_TIMEOUT, @@ -594,7 +650,7 @@ async def transcribe( self, file_urls: List[str], *, - model: str = "paraformer-v2", + model: str = "qwen-audio-3.1-asr-flash-filetrans", parameters: Optional[Dict[str, Any]] = None, phrase_id: Optional[str] = None, timeout: float = DEFAULT_WAIT_TIMEOUT, diff --git a/dashscope/v2/resources/audio/customization.py b/dashscope/v2/resources/audio/customization.py index bde917fb..89675473 100644 --- a/dashscope/v2/resources/audio/customization.py +++ b/dashscope/v2/resources/audio/customization.py @@ -19,11 +19,11 @@ from pydantic import AliasChoices, Field -from dashscope.v2.errors import check_response from dashscope.v2.types.common import ( JobStatus, ResponseBase, flatten_envelope, + response_body, with_headers, ) @@ -113,11 +113,9 @@ def _call( api_key: Optional[str] = None, workspace: Optional[str] = None, ) -> Any: - input_ = {"action": action} - input_.update(fields) body: Dict[str, Any] = { "model": model or self.DEFAULT_MODEL, - "input": input_, + "input": {**fields, "action": action}, } if parameters is not None: body["parameters"] = parameters @@ -131,9 +129,16 @@ def _call( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) return response + def _parse_response(self, response: Any) -> Dict[str, Any]: + return flatten_envelope( + response_body( + response, + raise_on_error=self._client.raise_on_error, + ), + ) + class Vocabularies(_CustomizationBase): """``client.audio.vocabularies`` -- asr hot-word tables.""" @@ -170,7 +175,7 @@ def create( workspace=workspace, ) return with_headers( - Vocabulary(**flatten_envelope(response.json())), + Vocabulary(**self._parse_response(response)), response, ) @@ -203,7 +208,7 @@ def list( workspace=workspace, ) return with_headers( - VocabularyList(**flatten_envelope(response.json())), + VocabularyList(**self._parse_response(response)), response, ) @@ -229,7 +234,7 @@ def get( workspace=workspace, ) return with_headers( - Vocabulary(**flatten_envelope(response.json())), + Vocabulary(**self._parse_response(response)), response, ) @@ -256,7 +261,7 @@ def update( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -282,7 +287,7 @@ def delete( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -330,7 +335,7 @@ def create( workspace=workspace, ) return with_headers( - Voice(**flatten_envelope(response.json())), + Voice(**self._parse_response(response)), response, ) @@ -363,7 +368,7 @@ def list( workspace=workspace, ) return with_headers( - VoiceList(**flatten_envelope(response.json())), + VoiceList(**self._parse_response(response)), response, ) @@ -389,7 +394,7 @@ def get( workspace=workspace, ) return with_headers( - Voice(**flatten_envelope(response.json())), + Voice(**self._parse_response(response)), response, ) @@ -416,7 +421,7 @@ def update( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -442,7 +447,7 @@ def delete( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) @@ -498,7 +503,7 @@ def create( workspace=workspace, ) return with_headers( - DesignedVoice(**flatten_envelope(response.json())), + DesignedVoice(**self._parse_response(response)), response, ) @@ -524,7 +529,7 @@ def list( workspace=workspace, ) return with_headers( - DesignedVoiceList(**flatten_envelope(response.json())), + DesignedVoiceList(**self._parse_response(response)), response, ) @@ -550,7 +555,7 @@ def get( workspace=workspace, ) return with_headers( - DesignedVoice(**flatten_envelope(response.json())), + DesignedVoice(**self._parse_response(response)), response, ) @@ -576,6 +581,6 @@ def delete( workspace=workspace, ) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**self._parse_response(response)), response, ) diff --git a/dashscope/v2/resources/audio/http_tts.py b/dashscope/v2/resources/audio/http_tts.py index c3a92087..cc0edadc 100644 --- a/dashscope/v2/resources/audio/http_tts.py +++ b/dashscope/v2/resources/audio/http_tts.py @@ -4,28 +4,39 @@ V2 form of the two 1.x HTTP TTS families, sharing one entry: -- sambert/cosyvoice (1.x ``HttpSpeechSynthesizer``) -> - ``services/audio/tts/SpeechSynthesizer`` +- qwen-audio / cosyvoice -> ``services/audio/tts/SpeechSynthesizer`` - qwen-tts family (1.x qwen ``SpeechSynthesizer``: qwen-tts, qwen3-tts-flash) -> ``services/aigc/multimodal-generation/generation`` Both POST the same ``{model, input}`` envelope and answer with ``output.audio``; streaming rides SSE via the ``X-DashScope-SSE: enable`` -header, framed exactly like the 1.x ``StreamEventMixin`` wire (see -``resources/_sse.py``). +header. Audio uses standard SSE framing and strict error handling locally, +without changing the legacy job-event stream contract. """ from __future__ import annotations import base64 import json -from typing import Any, Dict, Iterator, Mapping, Optional - -from pydantic import BaseModel, ConfigDict, Field - -from dashscope.v2.errors import DashScopeError, check_response -from dashscope.v2.resources._sse import _iter_sse, _stream_error -from dashscope.v2.types.common import with_headers +from typing import ( + Any, + Dict, + Generator, + Iterator, + List, + Mapping, + Optional, + Tuple, +) + +from dashscope.v2.errors import ( + APIStatusError, + DashScopeError, + error_from_business_code, + error_from_status, +) +from dashscope.v2.resources._sse import _try_json +from dashscope.v2.types.common import ResponseBase, response_body, with_headers __all__ = [ "HttpSynthesis", @@ -41,9 +52,108 @@ _QWEN_PATH = "/services/aigc/multimodal-generation/generation" +_Frame = Tuple[bool, Optional[int], str] + + +class _SSEDecoder: + """Standard SSE framing for synchronous HTTP audio only.""" + + def __init__(self) -> None: + self.is_error = False + self.status_code: Optional[int] = None + self.data: List[str] = [] + + def feed(self, line: str) -> Optional[_Frame]: + if not line: + frame = None + if self.data: + data = "\n".join(self.data) + body = _try_json(data) + is_error = self.is_error or ( + isinstance(body, dict) and bool(body.get("code")) + ) + frame = (is_error, self.status_code, data) + self.is_error = False + self.status_code = None + self.data = [] + return frame + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + if field == "event": + self.is_error = value == "error" + elif field == "status": + self.status_code = int(value.strip()) + elif field == "data": + self.data.append(value) + return None + + +def _iter_audio_sse(response) -> Iterator[_Frame]: + # Non-2xx audio bodies can look like valid SSE but are still failures. + if not 200 <= response.status_code < 300: + response.read() + response_body(response) + return + media_type = response.headers.get("content-type", "").split(";", 1)[0] + media_type = media_type.strip().lower() + if media_type == "application/json" or media_type.endswith("+json"): + response.read() + response_body(response) + return + decoder = _SSEDecoder() + for line in response.iter_lines(): + frame = decoder.feed(line) + if frame is not None: + yield frame + if frame[0]: + return + # Some services omit the final blank line; retain the final audio/error part. + frame = decoder.feed("") + if frame is not None: + yield frame + + +def _with_audio_request_id(error: DashScopeError) -> DashScopeError: + """Keep audio's outer/output/header precedence out of shared errors.""" + body = error.body if isinstance(error.body, dict) else {} + output = body.get("output") + output = output if isinstance(output, dict) else {} + error.request_id = ( + body.get("request_id") + or output.get("request_id") + or error.headers.get("x-request-id") + or error.request_id + ) + return error + + +def _audio_stream_error( + status_code: Optional[int], + data: str, + headers: Mapping[str, str], +) -> DashScopeError: + body = _try_json(data) + message = (body.get("message") or data) if isinstance(body, dict) else data + if status_code is None: + error = error_from_business_code( + message=message, + body=body, + headers=headers, + ) + else: + error = error_from_status( + status_code, + message=message, + body=body, + headers=headers, + ) + return _with_audio_request_id(error) + + def _path_for(model: str) -> str: - # qwen-tts / qwen3-tts-flash ride the multimodal-generation service; - # sambert/cosyvoice ride the audio tts service. + if model.startswith("qwen-audio-"): + return _DEFAULT_PATH if model.startswith("qwen"): return _QWEN_PATH return _DEFAULT_PATH @@ -87,17 +197,12 @@ def _usage_dict(body: Dict[str, Any]) -> Optional[Dict[str, Any]]: return usage if isinstance(usage, dict) else None -class HttpSynthesis(BaseModel): +class HttpSynthesis(ResponseBase): """Non-streaming result: a URL reference to the rendered audio.""" - model_config = ConfigDict(extra="allow") - output: Dict[str, Any] = {} usage: Optional[Dict[str, Any]] = None request_id: str = "" - # Raw HTTP response headers, 1.x parity (``GenerationResponse.headers``); - # filled via ``with_headers`` like every other non-streaming success. - headers: Dict[str, str] = Field(default_factory=dict) @property def audio_url(self) -> Optional[str]: @@ -114,11 +219,9 @@ def expires_at(self) -> Optional[int]: return _audio_dict(self.output).get("expires_at") -class HttpSynthesisChunk(BaseModel): +class HttpSynthesisChunk(ResponseBase): """One SSE part of a streaming synthesis.""" - model_config = ConfigDict(extra="allow") - output: Dict[str, Any] = {} audio: Optional[bytes] = None usage: Optional[Dict[str, Any]] = None @@ -159,9 +262,12 @@ def _synthesis_from_body( ) -> HttpSynthesis: output = body.get("output") return HttpSynthesis( - output=output if isinstance(output, dict) else {}, - usage=_usage_dict(body), - request_id=body.get("request_id") or header_request_id, + **{ + **body, + "output": output if isinstance(output, dict) else {}, + "usage": _usage_dict(body), + "request_id": body.get("request_id") or header_request_id, + }, ) @@ -180,12 +286,20 @@ def _chunk_from_data( output = body.get("output") output = output if isinstance(output, dict) else {} payload = _audio_dict(output).get("data") - return HttpSynthesisChunk( - output=output, - audio=base64.b64decode(payload) if payload else None, - usage=_usage_dict(body), - request_id=body.get("request_id") or header_request_id, + chunk = HttpSynthesisChunk( + **{ + **body, + "output": output, + "audio": base64.b64decode(payload, validate=True) + if payload + else None, + "usage": _usage_dict(body), + "request_id": body.get("request_id") or header_request_id, + }, ) + if chunk.status_code is not None and not 200 <= chunk.status_code < 300: + chunk.is_error = True + return chunk class HttpSynthesisStream: @@ -193,14 +307,14 @@ class HttpSynthesisStream: def __init__( self, - inner: Iterator[HttpSynthesisChunk], + inner: Generator[HttpSynthesisChunk, None, None], response: Any, ) -> None: self._inner = inner self._response = response - def __iter__(self) -> Iterator[HttpSynthesisChunk]: - return iter(self._inner) + def __iter__(self) -> "HttpSynthesisStream": + return self def __next__(self) -> HttpSynthesisChunk: return next(self._inner) @@ -211,7 +325,10 @@ def headers(self) -> Dict[str, str]: return dict(self._response.headers) def close(self) -> None: - self._inner.close() + try: + self._inner.close() + finally: + self._response.close() class HttpTts: @@ -256,10 +373,12 @@ def synthesize( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( _synthesis_from_body( - response.json(), + response_body( + response, + raise_on_error=self._client.raise_on_error, + ), response.headers.get("x-request-id", ""), ), response, @@ -291,33 +410,38 @@ def stream( sse_headers = dict(_SSE_HEADER) if headers: sse_headers.update(headers) - response = self._client.transport.open_stream( - "POST", - _path_for(model), - json=_build_body( - text, - model=model, - voice=voice, - format=format, - sample_rate=sample_rate, - language_type=language_type, - parameters=parameters, - extra_input=extra_input, - ), - headers=sse_headers, - timeout=timeout, - sdk_module=MODULE, - api_key=api_key, - workspace=workspace, - ) + try: + response = self._client.transport.open_stream( + "POST", + _path_for(model), + json=_build_body( + text, + model=model, + voice=voice, + format=format, + sample_rate=sample_rate, + language_type=language_type, + parameters=parameters, + extra_input=extra_input, + ), + headers=sse_headers, + timeout=timeout, + sdk_module=MODULE, + api_key=api_key, + workspace=workspace, + ) + except APIStatusError as error: + # >=400 is raised (and closed) before open_stream returns a response. + _with_audio_request_id(error) + raise return HttpSynthesisStream(self._events(response), response) - def _events(self, response) -> Iterator[HttpSynthesisChunk]: + def _events(self, response) -> Generator[HttpSynthesisChunk, None, None]: header_request_id = response.headers.get("x-request-id", "") try: - for is_error, status, data in _iter_sse(response): + for is_error, status, data in _iter_audio_sse(response): if is_error: - raise _stream_error(status, data) + raise _audio_stream_error(status, data, response.headers) yield _chunk_from_data(data, header_request_id) finally: response.close() diff --git a/dashscope/v2/resources/audio/phrases.py b/dashscope/v2/resources/audio/phrases.py index 084f24ba..6af26877 100644 --- a/dashscope/v2/resources/audio/phrases.py +++ b/dashscope/v2/resources/audio/phrases.py @@ -3,9 +3,10 @@ """Paraformer hot-phrase management (1.x ``AsrPhraseManager``). A compile job in disguise: ``create``/``update`` POST ``/fine-tunes`` -with ``mode=compile_asr_phrase`` and an empty file set; ``get``/ -``list``/``delete`` hit ``/fine-tunes/outputs``. The resulting job id -doubles as the ``phrase_id`` that ``asr.recognize`` consumes. 1.x +with ``training_type=compile_asr_phrase`` and an empty file set; ``get``/ +``list``/``delete`` hit ``/fine-tunes/outputs``. ``finetuned_output`` is +the compiled resource ID that ``asr.recognize`` consumes as ``phrase_id``; +``job_id`` identifies the compile job and is only a legacy fallback. 1.x ``list_phrases`` accepted ``page=`` but dropped it in ``**kwargs`` before it reached the wire; v2 sends the documented ``page_no``. """ @@ -16,11 +17,11 @@ from pydantic import AliasChoices, Field -from dashscope.v2.errors import check_response from dashscope.v2.types.common import ( JobStatus, ResponseBase, flatten_envelope, + response_body, with_headers, ) @@ -66,14 +67,14 @@ def _compile_body( class Phrase(ResponseBase): - """One compiled hot-phrase set; the job id doubles as ``phrase_id``.""" + """One compiled phrase set; prefer its resource ID over the compile job ID.""" phrase_id: Optional[str] = Field( None, validation_alias=AliasChoices( "phrase_id", - "job_id", "finetuned_output", + "job_id", ), ) status: Optional[str] = None @@ -88,7 +89,12 @@ class PhraseList(ResponseBase): ) phrases: List[Phrase] = Field( default_factory=list, - validation_alias=AliasChoices("phrases", "jobs", "outputs"), + validation_alias=AliasChoices( + "phrases", + "jobs", + "outputs", + "finetuned_outputs", + ), ) @@ -126,11 +132,11 @@ def create( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - Phrase(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(Phrase(**flatten_envelope(body)), response) def update( self, @@ -162,11 +168,11 @@ def update( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - Phrase(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(Phrase(**flatten_envelope(body)), response) def get( self, @@ -188,11 +194,11 @@ def get( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - Phrase(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(Phrase(**flatten_envelope(body)), response) def list( self, @@ -215,11 +221,11 @@ def list( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - PhraseList(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(PhraseList(**flatten_envelope(body)), response) def delete( self, @@ -241,8 +247,8 @@ def delete( api_key=api_key, workspace=workspace, ) - check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers( - JobStatus(**flatten_envelope(response.json())), + body = response_body( response, + raise_on_error=self._client.raise_on_error, ) + return with_headers(JobStatus(**flatten_envelope(body)), response) diff --git a/dashscope/v2/resources/audio/realtime.py b/dashscope/v2/resources/audio/realtime.py index e2decb99..a45fca85 100644 --- a/dashscope/v2/resources/audio/realtime.py +++ b/dashscope/v2/resources/audio/realtime.py @@ -52,25 +52,19 @@ class RealtimeSession: Usage:: with client.audio.realtime.connect( - model="qwen-omni-turbo-realtime", + model="qwen3.8-omni-flash-realtime", ) as rt: rt.send_event({"type": "session.update", "session": {...}}) for event in rt.events(): ... - Unlike the run-task family (asr/tts/translation/tingwu), the + Unlike the run-task family (asr/tts/tingwu), the realtime protocol has no task id: one socket IS one session and the server closes it when the session ends, so connection reuse does not apply here -- every session opens its own socket (see ``WsConnection`` for the multiplexable family). Latency-sensitive callers can shave the handshake by connecting ahead of use rather than by pooling. - - This one-socket-per-session rule is a published contract: it is - pinned by tests/v2/test_audio_omni_session.py and relied on by - examples/04_real_world_scenarios/ - realtime_audio_meeting_harness.py, which sizes its meeting - concurrency on it. """ def __init__( @@ -126,7 +120,9 @@ def __init__( all_headers.update(user_headers) self._model = model wire_model = next(value for key, value in query if key == "model") - if wire_model.startswith("qwen-audio-"): + if wire_model.startswith("qwen3.8-omni-"): + self._protocol = "omni_v38" + elif wire_model.startswith("qwen-audio-"): self._protocol = "qwen_audio" elif wire_model.startswith("qwen3.8-livetranslate-"): self._protocol = "livetranslate_v38" @@ -141,6 +137,8 @@ def __init__( self._connector = connector self._timeout = config.timeout if timeout is None else timeout self._lock = threading.RLock() + self._recv_lock = threading.Lock() + self._generation = 0 self._ws = None self._reset_state() @@ -149,6 +147,7 @@ def _reset_state(self) -> None: self._finish_requested = False self._finished = False self._response_active = False + self._pending_response_ids: List[Any] = [] self.session_id: Optional[str] = None self.last_message: Optional[Dict[str, Any]] = None self.last_response_id: Optional[str] = None @@ -169,6 +168,7 @@ def connect(self) -> "RealtimeSession": with self._lock: if self._ws is not None: raise RuntimeError("realtime session is already open") + self._generation += 1 try: if self._connector is None: self._ws = _default_connector( @@ -209,6 +209,8 @@ def send_event(self, event: Dict[str, Any]) -> None: self._finish_requested = True try: self._ws.send(raw) + if payload.get("type") == "response.create": + self._pending_response_ids.append(payload["event_id"]) except BaseException: self.close() raise @@ -256,7 +258,7 @@ def update_session( ), **kwargs: Any, ) -> None: - """Patch Qwen-Audio/Live settings; Omni retains its legacy defaults. + """Patch modern sessions; pre-3.8 Omni retains its legacy defaults. ``**kwargs`` is the protocol-evolution escape hatch: extra keys merge into the wire ``session`` object after the named helpers, @@ -390,7 +392,9 @@ def _omni_session( session.update(kwargs) return session - def _incremental_session( # pylint: disable=too-many-branches,too-many-statements + # Protocol event routing is a deliberate branch table. + # pylint: disable-next=too-many-branches,too-many-statements + def _incremental_session( self, options: Dict[str, Any], kwargs: Dict[str, Any], @@ -434,7 +438,7 @@ def _incremental_session( # pylint: disable=too-many-branches,too-many-statemen if enabled is True and self._protocol != "livetranslate_v38": transcription = ( {"model": "qwen3-asr-flash-realtime"} - if self._protocol == "livetranslate" + if self._protocol in {"livetranslate", "omni_v38"} else {} ) if "input_audio_transcription_model" in options: @@ -486,7 +490,7 @@ def _incremental_session( # pylint: disable=too-many-branches,too-many-statemen audio[direction] = {"format": options[key]} if audio: session["audio"] = audio - if self._protocol != "livetranslate_v38": + if self._protocol not in {"livetranslate_v38", "omni_v38"}: for key in ( "input_audio_format", "output_audio_format", @@ -514,12 +518,12 @@ def _incremental_session( # pylint: disable=too-many-branches,too-many-statemen "Livetranslate 3.8 does not support " "same_language_skip_options", ) - session = self._nested_translation_session(session) + if self._protocol in {"livetranslate_v38", "omni_v38"}: + session = self._nested_audio_session(session) return session - @staticmethod - def _nested_translation_session(session: Dict[str, Any]) -> Dict[str, Any]: - if "modalities" in session: + def _nested_audio_session(self, session: Dict[str, Any]) -> Dict[str, Any]: + if self._protocol == "livetranslate_v38" and "modalities" in session: session["output_modalities"] = session.pop("modalities") audio: Dict[str, Any] = {} for field, direction, nested_key in ( @@ -531,12 +535,15 @@ def _nested_translation_session(session: Dict[str, Any]) -> Dict[str, Any]: audio.setdefault(direction, {}).setdefault("format", {})[ nested_key ] = session.pop(field) - for field, direction in ( - ("turn_detection", "input"), - ("voice", "output"), + if "voice" in session: + audio.setdefault("output", {})["voice"] = session.pop("voice") + if ( + self._protocol == "livetranslate_v38" + and "turn_detection" in session ): - if field in session: - audio.setdefault(direction, {})[field] = session.pop(field) + audio.setdefault("input", {})["turn_detection"] = session.pop( + "turn_detection", + ) raw_audio = session.pop("audio", _UNSET) if isinstance(raw_audio, dict): for direction, config in raw_audio.items(): @@ -630,7 +637,10 @@ def create_response( ): response["modalities"] = modalities response.update(kwargs) - self.send_event({"type": "response.create", "response": response}) + event: Dict[str, Any] = {"type": "response.create"} + if response or self._protocol != "omni_v38": + event["response"] = response + self.send_event(event) def cancel_response(self) -> None: self.send_event({"type": "response.cancel"}) @@ -644,60 +654,100 @@ def end_session(self) -> None: ) self.send_event({"type": "session.finish"}) + # pylint: disable-next=too-many-branches,too-many-statements def recv_event(self) -> Optional[Dict[str, Any]]: """Receive a raw event; unfinished responses cannot end silently.""" - ws = self._ws - if ws is None: - self._check_unfinished() - if self._closed: - return None - raise RuntimeError("realtime session is not open") from websocket import ( # pylint: disable=import-outside-toplevel WebSocketConnectionClosedException, ) - try: + # Network reads must not hold the lock needed by send_event() or close(). + with self._recv_lock: + with self._lock: + ws, generation = self._ws, self._generation + if ws is None: + self._check_unfinished() + if self._closed: + return None + raise RuntimeError("realtime session is not open") + failure = None try: - raw = ws.recv() - except WebSocketConnectionClosedException: - raw = None - if raw is None or raw == "" or raw == b"": - self.close() - self._check_unfinished() - return None - if isinstance(raw, (bytes, bytearray)): - raw = raw.decode("utf-8") - event = json.loads(raw) - if not isinstance(event, dict): - raise ValueError("expected a JSON event object") - self.last_message = event - self._track_event(event) - if event.get("type") == "session.finished": - self._finished = True - self.close() - except BaseException: - self.close() - raise - if event.get("type") == "error": - error = event.get("error") or {} - if ( - error.get("type") != "invalid_request_error" - or self._finish_requested - ): - self.close() - raise error_from_business_code( - message=error.get("message") or "realtime error", - code=error.get("code"), - body=event, - ) - return event + try: + raw = ws.recv() + except WebSocketConnectionClosedException: + raw = None + with self._lock: + if generation != self._generation: + return None + if ws is not self._ws: + self._check_unfinished() + return None + if raw is None or raw == "" or raw == b"": + self.close() + self._check_unfinished() + return None + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8") + event = json.loads(raw) + if not isinstance(event, dict): + raise ValueError("expected a JSON event object") + self.last_message = event + self._track_event(event) + if event.get("type") == "session.finished": + self._finished = True + self.close() + self._check_unfinished() + elif event.get("type") == "error": + error = event.get("error") + if error is None: + error = {} + if not isinstance(error, dict): + raise ValueError("expected a JSON error object") + code = error.get("code") + if code is not None and not isinstance(code, str): + raise ValueError("expected a string error code") + failure = error_from_business_code( + message=error.get("message") or "realtime error", + code=code, + body=event, + ) + recoverable = ( + error.get("type") == "invalid_request_error" + ) + if recoverable: + # The nested event_id identifies the rejected request. + request_id = error.get("event_id") + if ( + isinstance(request_id, str) + and request_id in self._pending_response_ids + ): + self._pending_response_ids.remove(request_id) + if not recoverable or self._finish_requested: + self.close() + except BaseException: + with self._lock: + if ws is self._ws and generation == self._generation: + self.close() + raise + # Malformed errors require cleanup; parsed request errors may recover. + if failure is not None: + raise failure + return event def _check_unfinished(self) -> None: - if self._finish_requested and not self._finished: + # Omni 3.8 documents session.finish without a session.finished ACK. + if ( + self._finish_requested + and not self._finished + and self._protocol != "omni_v38" + ): raise APIConnectionError( "realtime connection closed before session.finished", ) - if self._response_active and not self._finished: + # Unlike legacy protocols, Omni 3.8's optional ACK cannot finish a response. + if (self._response_active or self._pending_response_ids) and ( + not self._finished or self._protocol == "omni_v38" + ): raise APIConnectionError( "realtime connection closed before response.done", ) @@ -716,6 +766,9 @@ def _track_event(self, event: Dict[str, Any]) -> None: self.last_first_audio_delay = None self.last_first_text_delay = None self._response_active = True + # response.created has no client ID; acknowledge in wire order. + if self._pending_response_ids: + self._pending_response_ids.pop(0) elif kind == "response.done": response_id = (event.get("response") or {}).get( "id", diff --git a/dashscope/v2/resources/audio/translation.py b/dashscope/v2/resources/audio/translation.py deleted file mode 100644 index 3d097a3b..00000000 --- a/dashscope/v2/resources/audio/translation.py +++ /dev/null @@ -1,255 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""client.audio.translation resource (Gummy realtime speech translation). - -Same duplex wire as ``asr.recognize`` (task_group=audio, task=asr, -function=recognition) — the 1.x ``TranslationRecognizerRealtime`` and -``TranslationRecognizerChat`` pair rides it too -(translation_recognizer.py:451/987). This resource names the translation -parameters and adds the bilingual typed view; the chat models -(gummy-chat-v1) share the wire and use the same entry — 1.x Chat only -differs by dropping audio after the first sentence end, which callers -express through ``TranslationResult.is_sentence_end()``. - -The newer qwen3-livetranslate realtime models are a different protocol -and live under ``client.audio.realtime.connect`` instead. -""" - -from __future__ import annotations - -import os -from typing import Any, Dict, Iterator, List, Mapping, Optional - -from dashscope.v2.resources.audio.asr import ( - Recognition, - RecognitionResult, - _stream_file_frames, -) -from dashscope.v2.transport.ws import EVENT_GENERATED, Connector -from dashscope.v2.transport.ws_connection import WsConnection - -__all__ = [ - "Translation", - "TranslationResult", - "TranslationSession", -] - - -class TranslationResult(RecognitionResult): - """Typed view over one bilingual ``result-generated`` payload. - - Gummy frames carry ``output.transcription`` (source-language - sentence) and/or ``output.translations`` (one dict per target - language, ``lang``/``text``/``words``/...), unlike the plain ASR - ``output.sentence`` shape ``RecognitionResult.get_sentence`` reads. - """ - - def get_transcription(self) -> Optional[Dict[str, Any]]: - """The source-language sentence dict, or None on this frame.""" - transcription = self.output.get("transcription") - return transcription if isinstance(transcription, dict) else None - - def get_translations(self) -> List[Dict[str, Any]]: - """Per-target-language sentence dicts (possibly empty).""" - translations = self.output.get("translations") - return translations if isinstance(translations, list) else [] - - def is_sentence_end(self) -> bool: - """True when transcription or any translation ends its sentence.""" - transcription = self.get_transcription() - if transcription and transcription.get("sentence_end"): - return True - return any( - item.get("sentence_end") for item in self.get_translations() - ) - - -def _results_from_events( - events: Iterator[Dict[str, Any]], -) -> Iterator[TranslationResult]: - for event in events: - if event["header"].get("event") != EVENT_GENERATED: - continue - data = dict(event.get("payload") or {}) - data.setdefault("request_id", event["header"].get("request_id")) - result = TranslationResult.model_validate(data) - transcription = result.get_transcription() - if transcription and transcription.get("heartbeat"): - continue - yield result - - -class TranslationSession(Recognition): - """Duplex translation session; lifecycle identical to ``Recognition``.""" - - def __init__( - self, - client, - *, - model: str, - format: str = "pcm", # pylint: disable=redefined-builtin - sample_rate: int = 16000, - source_language: Optional[str] = None, - transcription_enabled: Optional[bool] = None, - translation_enabled: Optional[bool] = None, - translation_target_languages: Optional[List[str]] = None, - max_end_silence: Optional[int] = None, - parameters: Optional[Dict[str, Any]] = None, - # pylint: disable-next=redefined-builtin - input: Optional[Dict[str, Any]] = None, - phrase_id: Optional[str] = None, - ws_url: Optional[str] = None, - connector: Optional[Connector] = None, - connection: Optional[WsConnection] = None, - timeout: Optional[float] = None, - headers: Optional[Mapping[str, str]] = None, - api_key: Optional[str] = None, - workspace: Optional[str] = None, - ) -> None: - merged = dict(parameters or {}) - # Named kwargs win over the ``parameters=`` escape hatch; the keys - # match the 1.x TranslationRecognizerRealtime wire names. Omitted - # parameters fall to the service defaults (transcription on, - # translation off, source language auto). - for key, value in ( - ("source_language", source_language), - ("transcription_enabled", transcription_enabled), - ("translation_enabled", translation_enabled), - ("translation_target_languages", translation_target_languages), - ("max_end_silence", max_end_silence), - ): - if value is not None: - merged[key] = value - super().__init__( - client, - model=model, - format=format, - sample_rate=sample_rate, - parameters=merged, - input=input, - phrase_id=phrase_id, - ws_url=ws_url, - connector=connector, - connection=connection, - timeout=timeout, - headers=headers, - api_key=api_key, - workspace=workspace, - ) - - def results(self) -> Iterator[TranslationResult]: - """Yield typed bilingual results until the task finishes. - - Non-result events (task-finished, ...) are skipped. Frames whose - ``transcription`` is only a heartbeat are dropped too — a v2 - convenience so callers see real sentences; unlike the plain-ASR - path, the 1.x translation recognizer forwards such frames to - ``on_event`` rather than filtering them. - """ - return _results_from_events(self.events()) - - -class Translation: - """``client.audio.translation``: realtime speech translation (Gummy).""" - - def __init__(self, client) -> None: - self._client = client - - def recognize( - self, - *, - model: str, - format: str = "pcm", # pylint: disable=redefined-builtin - sample_rate: int = 16000, - source_language: Optional[str] = None, - transcription_enabled: Optional[bool] = None, - translation_enabled: Optional[bool] = None, - translation_target_languages: Optional[List[str]] = None, - max_end_silence: Optional[int] = None, - parameters: Optional[Dict[str, Any]] = None, - # pylint: disable-next=redefined-builtin - input: Optional[Dict[str, Any]] = None, - phrase_id: Optional[str] = None, - ws_url: Optional[str] = None, - connector: Optional[Connector] = None, - connection: Optional[WsConnection] = None, - timeout: Optional[float] = None, - headers: Optional[Mapping[str, str]] = None, - api_key: Optional[str] = None, - workspace: Optional[str] = None, - ) -> TranslationSession: - """Open translation; send audio and consume events concurrently.""" - return TranslationSession( - self._client, - model=model, - format=format, - sample_rate=sample_rate, - source_language=source_language, - transcription_enabled=transcription_enabled, - translation_enabled=translation_enabled, - translation_target_languages=translation_target_languages, - max_end_silence=max_end_silence, - parameters=parameters, - input=input, - phrase_id=phrase_id, - ws_url=ws_url, - connector=connector, - connection=connection, - timeout=timeout, - headers=headers, - api_key=api_key, - workspace=workspace, - ) - - def recognize_file( - self, - path: str, - *, - model: str, - format: str = "pcm", # pylint: disable=redefined-builtin - sample_rate: int = 16000, - source_language: Optional[str] = None, - transcription_enabled: Optional[bool] = None, - translation_enabled: Optional[bool] = None, - translation_target_languages: Optional[List[str]] = None, - max_end_silence: Optional[int] = None, - parameters: Optional[Dict[str, Any]] = None, - phrase_id: Optional[str] = None, - ws_url: Optional[str] = None, - connector: Optional[Connector] = None, - timeout: Optional[float] = None, - api_key: Optional[str] = None, - workspace: Optional[str] = None, - ) -> Iterator[TranslationResult]: - """Translate a local audio file, yielding typed bilingual results. - - The 1.x ``TranslationRecognizerRealtime.call(file)`` equivalent - (its ``TranslationRecognizerResultPack`` items arrive one - ``TranslationResult`` at a time): the file is auto-chunked and - driven to completion in one session. Unlike - ``asr.recognize_file`` this yields typed results — the bilingual - nesting is the point of this facade. Validation happens eagerly; - the session itself opens on first iteration. - """ - if not os.path.isfile(path): - raise ValueError(f"audio file not found: {path}") - if os.path.getsize(path) == 0: - raise ValueError(f"audio file is empty: {path}") - session = self.recognize( - model=model, - format=format, - sample_rate=sample_rate, - source_language=source_language, - transcription_enabled=transcription_enabled, - translation_enabled=translation_enabled, - translation_target_languages=translation_target_languages, - max_end_silence=max_end_silence, - parameters=parameters, - phrase_id=phrase_id, - ws_url=ws_url, - connector=connector, - timeout=timeout, - api_key=api_key, - workspace=workspace, - ) - return _results_from_events(_stream_file_frames(session, path)) diff --git a/dashscope/v2/resources/audio/tts.py b/dashscope/v2/resources/audio/tts.py index c2f03bb1..cf8c5321 100644 --- a/dashscope/v2/resources/audio/tts.py +++ b/dashscope/v2/resources/audio/tts.py @@ -170,7 +170,7 @@ def start(self) -> "SynthesisSession": input={}, ) self._record_response(response) - except Exception as error: + except BaseException as error: self._record_response(getattr(error, "body", None)) self.close() raise @@ -229,11 +229,9 @@ def _record_response(self, response: Any) -> None: def events(self) -> Generator[Union[Dict[str, Any], bytes], None, None]: """Yield JSON and audio through task-finished, raising on failure.""" session = self.session - # The lock guards the iterator's whole lifetime and is released in - # its finally block, so a ``with`` scope cannot express it. - if not self._reader_lock.acquire( # pylint: disable=consider-using-with - blocking=False, - ): + # Non-blocking acquire, released in the finally below — no ``with``. + # pylint: disable=consider-using-with + if not self._reader_lock.acquire(blocking=False): raise RuntimeError( "only one events() or audio_chunks() iterator may read " "the synthesis session", diff --git a/dashscope/v2/transport/http.py b/dashscope/v2/transport/http.py index 8dbcb6d1..ffc58c46 100644 --- a/dashscope/v2/transport/http.py +++ b/dashscope/v2/transport/http.py @@ -102,9 +102,10 @@ def _merge_auth_headers( """ if api_key is None and workspace is None: return headers - merged = dict(headers or {}) - merged.update(_build_headers(api_key, workspace, None)) - return merged + return _with_sdk_headers( + headers or {}, + _build_headers(api_key, workspace, None), + ) def _abs_url(base_url: Optional[str], path: str) -> str: @@ -276,9 +277,11 @@ def open_stream( except httpx.TransportError as exc: raise _map_transport_error(exc) from exc if response.status_code >= 400: - response.read() - response.close() - check_response(response) + try: + response.read() + check_response(response) + finally: + response.close() logger.debug( "stream %s %s -> %d (request_id=%s)", method, @@ -436,9 +439,11 @@ async def open_stream( except httpx.TransportError as exc: raise _map_transport_error(exc) from exc if response.status_code >= 400: - await response.aread() - await response.aclose() - check_response(response) + try: + await response.aread() + check_response(response) + finally: + await response.aclose() logger.debug( "stream %s %s -> %d (request_id=%s)", method, diff --git a/dashscope/v2/transport/ws.py b/dashscope/v2/transport/ws.py index 4b311d51..71b2c9fa 100644 --- a/dashscope/v2/transport/ws.py +++ b/dashscope/v2/transport/ws.py @@ -76,14 +76,21 @@ def _assemble_headers( headers: Optional[Mapping[str, str]], module: Optional[str], ) -> Dict[str, str]: - all_headers = dict(headers or {}) + auth = {} if api_key: - all_headers["Authorization"] = f"Bearer {api_key}" + auth["Authorization"] = f"Bearer {api_key}" if workspace: - all_headers["X-DashScope-WorkSpace"] = workspace - all_headers.setdefault("user-agent", default_user_agent()) + auth["X-DashScope-WorkSpace"] = workspace + auth_keys = {key.lower() for key in auth} + all_headers = { + key: value + for key, value in (headers or {}).items() + if key.lower() not in auth_keys + } + all_headers.update(auth) provided = {key.lower() for key in all_headers} - for key, value in sdk_headers(module).items(): + defaults = {"user-agent": default_user_agent(), **sdk_headers(module)} + for key, value in defaults.items(): if key.lower() not in provided: all_headers[key] = value return all_headers @@ -140,6 +147,12 @@ def _process_event(self, event: Dict[str, Any]) -> Dict[str, Any]: ): raise WsEventError("expected a websocket event header") header = event["header"] + if header.get("task_id") not in (None, self.task_id): + raise WsEventError( + "websocket event belongs to a different task", + request_id=self._request_id, + body=event, + ) self._request_id = ( header.get("request_id") or header.get("task_id") diff --git a/dashscope/v2/transport/ws_connection.py b/dashscope/v2/transport/ws_connection.py index 6204059c..450873dc 100644 --- a/dashscope/v2/transport/ws_connection.py +++ b/dashscope/v2/transport/ws_connection.py @@ -23,10 +23,9 @@ opening a dedicated connection per session. """ -# pylint: disable=redefined-builtin -# WsTask and WsConnection are one protocol split across two classes; the -# cross-class private access is intra-module by design. -# pylint: disable=protected-access +# WsConnection and WsTask deliberately share the owning client's +# internals (send/claim/detach) — they are one transport layer. +# pylint: disable=redefined-builtin,protected-access from __future__ import annotations @@ -43,6 +42,8 @@ ) from dashscope.v2.errors import APIConnectionError from dashscope.v2.transport.ws import ( + EVENT_FAILED, + EVENT_FINISHED, EVENT_STARTED, Connector, WsEventError, @@ -86,6 +87,10 @@ def __init__( self._cancel_sent = False self._finished = False self._closed = False + # Reader-side routing uses the connection lock, not the send lock. + self._routing_active = False + self._routing_finished = False + self._error: Optional[BaseException] = None @property def task_id(self) -> str: @@ -100,7 +105,10 @@ def finished(self) -> bool: return self._finished def _enqueue(self, item: _QueueItem) -> None: - if not self._closed: + """Called with the connection lock held; the first error ends input.""" + if not self._closed and self._error is None: + if item[0] == _ERROR: + self._error = item[1] self._queue.put(item) def _frame(self, action: str, payload: Dict[str, Any]) -> Dict[str, Any]: @@ -212,11 +220,9 @@ def recv_event(self) -> Dict[str, Any]: return frame def recv(self) -> Union[Dict[str, Any], bytes, None]: - """Read the next demuxed frame for this task.""" + """Read queued frames before surfacing a persistent close/error.""" if self._finished: return None - if self._closed: - raise RuntimeError("websocket task is closed") try: kind, payload = self._queue.get(timeout=self._timeout) except queue.Empty: @@ -224,20 +230,16 @@ def recv(self) -> Union[Dict[str, Any], bytes, None]: "timed out waiting for a websocket event", ) from None if kind == _ERROR: + # Requeue the terminal marker so subsequent readers also fail. + self._queue.put((kind, payload)) raise payload if isinstance(payload, (bytes, bytearray)): return bytes(payload) - try: - return self._process_event(payload) - finally: - if self._finished: - self._conn._release_binary(self) + return self._process_event(payload) def close(self) -> None: - """Detach from the connection; the shared socket stays open.""" - if not self._closed: - self._closed = True - self._conn._detach(self) + """Detach and wake receivers; the shared socket stays open.""" + self._conn._detach(self) class WsConnection: @@ -284,15 +286,19 @@ def __init__( # are enforced on the demux queues, so the socket itself must # block forever -- a socket read timeout would kill the reader # during any quiet period longer than the timeout. - settimeout = getattr(self._ws, "settimeout", None) - if callable(settimeout): - settimeout(None) - self._reader = threading.Thread( - target=self._reader_main, - name="dashscope-ws-reader", - daemon=True, - ) - self._reader.start() + try: + settimeout = getattr(self._ws, "settimeout", None) + if callable(settimeout): + settimeout(None) + self._reader = threading.Thread( + target=self._reader_main, + name="dashscope-ws-reader", + daemon=True, + ) + self._reader.start() + except BaseException: + self.close() + raise @property def closed(self) -> bool: @@ -325,8 +331,21 @@ def _send(self, data: Any, *, opcode: int = 1) -> None: self.close() raise + def _check_binary_owner(self, task: WsTask) -> None: + """Validate under the connection lock; pre-start claims are allowed.""" + if self._closed: + raise RuntimeError("websocket connection is closed") + if ( + self._tasks.get(task.task_id) is not task + or task._closed + or task._finished + or task._routing_finished + ): + raise RuntimeError("websocket task is not active") + def _claim_binary_uplink(self, task: WsTask) -> None: with self._lock: + self._check_binary_owner(task) owner = self._binary_uplink_owner if owner is not None and owner is not task: raise RuntimeError( @@ -336,6 +355,7 @@ def _claim_binary_uplink(self, task: WsTask) -> None: def _claim_binary_downlink(self, task: WsTask) -> None: with self._lock: + self._check_binary_owner(task) owner = self._binary_downlink_owner if owner is not None and owner is not task: raise RuntimeError( @@ -344,16 +364,20 @@ def _claim_binary_downlink(self, task: WsTask) -> None: self._binary_downlink_owner = task def _release_binary(self, task: WsTask) -> None: - with self._lock: - if self._binary_uplink_owner is task: - self._binary_uplink_owner = None - if self._binary_downlink_owner is task: - self._binary_downlink_owner = None + """Release both directions with the connection lock already held.""" + if self._binary_uplink_owner is task: + self._binary_uplink_owner = None + if self._binary_downlink_owner is task: + self._binary_downlink_owner = None def _detach(self, task: WsTask) -> None: with self._lock: - self._tasks.pop(task.task_id, None) - self._release_binary(task) + task._enqueue((_ERROR, RuntimeError("websocket task is closed"))) + task._closed = True + task._routing_active = False + if self._tasks.get(task.task_id) is task: + self._tasks.pop(task.task_id) + self._release_binary(task) def _reader_main(self) -> None: try: @@ -372,37 +396,47 @@ def _reader_main(self) -> None: def _route_event(self, event: Any) -> None: header = event.get("header") if isinstance(event, dict) else None - task_id = header.get("task_id") if isinstance(header, dict) else None + if not isinstance(header, dict): + raise WsEventError("expected a websocket event header") + task_id = header.get("task_id") + if not isinstance(task_id, str) or not task_id: + raise WsEventError("expected a non-empty websocket task_id") with self._lock: task = self._tasks.get(task_id) - if task is None: - # Late event for a detached task; the task is gone, so drop it. - return - task._enqueue((_FRAME, event)) + if self._closed or task is None or task._routing_finished: + # Late events for detached or terminal tasks are harmless. + return + name = header.get("event") + if name == EVENT_STARTED: + task._routing_active = True + elif name in (EVENT_FINISHED, EVENT_FAILED): + task._routing_active = False + task._routing_finished = True + self._release_binary(task) + task._enqueue((_FRAME, event)) def _route_binary(self, data: bytes) -> None: with self._lock: + if self._closed: + return owner = self._binary_downlink_owner if owner is None: active = [ task for task in self._tasks.values() - if task._started - and not task._finished - and not task._closed + if task._routing_active ] - if len(active) == 1: - owner = active[0] - self._binary_downlink_owner = owner - if owner is None: - if not active: - # Late or garbage binary with no task to receive it. - return - raise WsEventError( - "binary frame with multiple active tasks; claim the " - "downlink with claim_binary_downlink()", - ) - owner._enqueue((_FRAME, data)) + if not active: + # Late or garbage binary with no task to receive it. + return + if len(active) != 1: + raise WsEventError( + "binary frame with multiple active tasks; claim the " + "downlink with claim_binary_downlink()", + ) + owner = active[0] + self._binary_downlink_owner = owner + owner._enqueue((_FRAME, data)) def _fail(self, exc: BaseException) -> None: with self._lock: @@ -411,24 +445,24 @@ def _fail(self, exc: BaseException) -> None: # just the shutdown echo. return self._closed = True + self._binary_uplink_owner = None + self._binary_downlink_owner = None _close_websocket(self._ws) self._broadcast(exc) def _broadcast(self, exc: BaseException) -> None: with self._lock: - tasks = list(self._tasks.values()) - for task in tasks: - task._enqueue((_ERROR, exc)) + for task in self._tasks.values(): + task._routing_active = False + task._enqueue((_ERROR, exc)) def close(self) -> None: - with self._lock: - if self._closed: - return - self._closed = True - _close_websocket(self._ws) - self._broadcast(APIConnectionError("websocket connection closed")) + self._fail(APIConnectionError("websocket connection closed")) + # Initialization may fail before the reader is created or started. + reader = getattr(self, "_reader", None) if ( - self._reader.is_alive() - and threading.current_thread() is not self._reader + reader is not None + and reader.is_alive() + and threading.current_thread() is not reader ): - self._reader.join(timeout=2) + reader.join(timeout=2) diff --git a/dashscope/v2/types/common.py b/dashscope/v2/types/common.py index 2707125e..5f159042 100644 --- a/dashscope/v2/types/common.py +++ b/dashscope/v2/types/common.py @@ -6,13 +6,17 @@ from typing import Any, Dict, Optional, Protocol, TypeVar +import httpx from pydantic import BaseModel, ConfigDict, Field, model_validator +from dashscope.v2.errors import error_from_business_code, error_from_status + __all__ = [ "JobStatus", "ResponseBase", "Usage", "flatten_envelope", + "response_body", "response_envelope", "with_headers", ] @@ -133,6 +137,57 @@ def flatten_envelope( return flat +def response_body( + response: httpx.Response, + *, + raise_on_error: bool = True, +) -> Dict[str, Any]: + """Opt in to strict HTTP envelopes, retaining non-JSON service failures.""" + success = 200 <= response.status_code < 300 + try: + raw = response.json() + except ValueError: + if success: + raise + raw = None + if isinstance(raw, dict): + body = raw + else: + if success: + raise ValueError("expected a JSON object response") + body = {"message": response.text} + output = body.get("output") + output = output if isinstance(output, dict) else {} + request_id = ( + body.get("request_id") + or output.get("request_id") + or response.headers.get("x-request-id") + ) + error = None + if not success: + error = error_from_status( + response.status_code, + message=response.text, + body=raw, + headers=response.headers, + ) + elif body.get("code"): + error = error_from_business_code( + message=body.get("message") or response.text, + body=raw, + headers=response.headers, + ) + if error is not None and raise_on_error: + error.request_id = request_id + raise error + body = {**body, "status_code": response.status_code} + if request_id is not None: + body["request_id"] = request_id + if error is not None: + body["is_error"] = True + return body + + def response_envelope(body: Dict[str, Any]) -> Dict[str, Any]: """Response-level fields, for payloads ``flatten_envelope`` cannot merge. diff --git a/dashscope/version.py b/dashscope/version.py index bc1bd910..31f70e79 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.0" +__version__ = "2.0.1" diff --git a/examples/01_atomic_capabilities/audio/README.md b/examples/01_atomic_capabilities/audio/README.md new file mode 100644 index 00000000..9066e219 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/README.md @@ -0,0 +1,220 @@ +# 语音接口调用示例 + +所有命令在仓库根目录执行,使用 V2 SDK:`from dashscope import Client`。 + +## 准备 + +```bash +python -m pip install -e . +# 使用麦克风时安装,并允许终端或 IDE 访问系统麦克风 +python -m pip install sounddevice + +OUT="$(mktemp -d)" +``` + +- 配置环境变量 `DASHSCOPE_API_KEY`,按需设置 `DASHSCOPE_REGION`、`DASHSCOPE_WORKSPACE`;示例不自动加载 `.env`。真实服务调用可能计费。 +- 输出不覆盖已有文件;`--output-dir` 必须是尚不存在的目录,父目录需已存在。 +- 本地 ASR WAV 使用 **16 kHz、16-bit PCM、单声道**,不自动重采样。 +- TTS 默认模型为 `qwen-audio-3.1-tts-flash`,音色为 `longanhuan_v3.1`;默认保存 WAV 后播放,添加 `--no-play` 只保存。 +- 各命令可加 `--help` 查看完整参数;均支持 `--region`、`--workspace`、`--timeout`。 + +## 直接调用版(不使用 `with`) + +`direct_call/` 提供全部 12 个调用示例的同名副本,参数和业务流程不变。将原版命令中的 `examples.01_atomic_capabilities.audio.` 替换为 `examples.01_atomic_capabilities.audio.direct_call.` 即可;原版仍保留。 + +```bash +python -m examples.01_atomic_capabilities.audio.direct_call.asr_websocket tests/data/asr_example.wav \ + --transcript "$OUT/asr-direct.txt" +python -m examples.01_atomic_capabilities.audio.direct_call.realtime_omni \ + --wav tests/data/asr_example.wav --output "$OUT/omni-direct.wav" --no-play +``` + +副本显式调用 ASR/TTS 会话的 `start()`、Realtime 会话的 `connect()`,用 `try/finally` 关闭文件、客户端和会话;先关闭会话,再 `shutdown()` 等待接收线程退出。HTTP 结果下载仍使用不携带 SDK 凭据的独立客户端。 + +## asr_file.py — HTTP 录音文件识别 + +接口:`client.audio.asr.jobs.create()` / `jobs.wait()`。默认模型:`qwen-audio-3.1-asr-flash-filetrans`。 + +将服务可下载的、你拥有或获授权使用的 HTTPS 音频地址设置为 `AUDIO_URL`,每个任务传一个 URL;不接受本地路径,也不上传文件。输出任务记录 `task.json` 和逐文件转写 JSON/TXT。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_file \ + --url "$AUDIO_URL" --output-dir "$OUT/batch" +``` + +超时或中断后,从 `task.json` 取出 `task_id` 设置为 `TASK_ID`,在同一地域和工作空间恢复,不重复提交: + +```bash +python -m examples.01_atomic_capabilities.audio.asr_file \ + --task-id "$TASK_ID" --output-dir "$OUT/batch-resumed" +``` + +## asr_http_stream.py — RESTful 流式返回识别 + +接口:`client.transport.open_stream()`,POST `/services/aigc/multimodal-generation/generation`,通过 SSE 接收文本。默认模型:`qwen-audio-3.1-asr-flash`。 + +一次提交完整音频,不是双工上传;返回累计文本,TXT 始终保存最新结果。短音频可能只返回一次文本。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_http_stream \ + --file tests/data/asr_example.wav --transcript "$OUT/asr-rest.txt" +``` + +可将 `--file` 替换为 `--url "$AUDIO_URL"`;加 `--events "$OUT/asr-rest.jsonl"` 保存原始事件。 + +## asr_websocket.py — WebSocket 双工识别 + +接口:`client.audio.asr.recognize()`。默认模型:`qwen-audio-3.1-asr-flash-streaming`。 + +读取本地 WAV,分片发送 PCM,同时接收识别结果;最终文本保存到 TXT。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_websocket tests/data/asr_example.wav \ + --transcript "$OUT/asr-wav.txt" --partials +``` + +`--partials` 显示临时结果,不写入最终 TXT。 + +## asr_microphone.py — 麦克风实时识别 + +接口:`client.audio.asr.recognize()`,模型同上。通过 `sounddevice` 采集系统麦克风,实时识别并保存录音与转写。 + +```bash +python -m examples.01_atomic_capabilities.audio.asr_microphone --list-devices +python -m examples.01_atomic_capabilities.audio.asr_microphone --seconds 5 \ + --recording "$OUT/microphone.wav" --transcript "$OUT/microphone.txt" +``` + +`--device` 指定设备;用 `--no-recording` 替换 `--recording` 可不保存录音。采集中 Ctrl+C 停止输入并等待最终结果。 + +## tts_http.py — HTTP 一次性合成 + +接口:`client.audio.tts.http.synthesize()`。一次提交文本,下载返回的音频 URL,保存 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.tts_http \ + --text "你好,这是 HTTP 一次性合成。" --output "$OUT/tts-http.wav" +``` + +## tts_http_stream.py — HTTP/SSE 流式合成 + +接口:`client.audio.tts.http.stream()`。一次提交完整文本,流式接收音频分片,保存为一个 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.tts_http_stream \ + --text "你好,这是 HTTP 流式合成。" --output "$OUT/tts-http-stream.wav" +``` + +两个 HTTP TTS 示例均支持用 `--text-file article.txt` 替换 `--text`。 + +## tts_websocket.py — WebSocket 双工流式合成 + +接口:`client.audio.tts.connect()`,通过 `send_text()` 追加文本,`stop()` 结束输入;同时接收音频并保存 WAV,完成后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.tts_websocket \ + --text "你好,这是第一段。" --text "这是第二段。" \ + --output "$OUT/tts-websocket.wav" +``` + +`--text-file article.txt` 可按行发送;`--events` 可保存 JSONL 事件。也可朗读 LLM 流式回答,此时额外调用 `client.chat.completions.create(stream=True)`: + +```bash +python -m examples.01_atomic_capabilities.audio.tts_websocket \ + --prompt "请用三句话介绍春天。" --llm-model qwen-plus \ + --output "$OUT/tts-llm.wav" +``` + +`--text`、`--text-file`、`--prompt` 三选一。 + +## voice_clone.py — 声音复刻管理 + +接口:`client.audio.voices.create()` / `get()` / `list()` / `delete()`。默认目标模型:`qwen-audio-3.0-tts-flash`。 + +将自己或获授权的参考语音 HTTPS 地址设置为 `CLONE_AUDIO_URL`;推荐 10~20 秒清晰语音。本脚本不上传音频、不播放、不自动删除音色。 + +```bash +python -m examples.01_atomic_capabilities.audio.voice_clone create \ + --url "$CLONE_AUDIO_URL" --prefix demo --output "$OUT/voice.json" +``` + +将输出的 `voice_id` 设置为 `VOICE_ID`,查询状态或列出音色: + +```bash +python -m examples.01_atomic_capabilities.audio.voice_clone get --voice-id "$VOICE_ID" +python -m examples.01_atomic_capabilities.audio.voice_clone list --prefix demo +``` + +状态为 `OK` 后,用创建时相同的模型合成: + +```bash +python -m examples.01_atomic_capabilities.audio.tts_websocket \ + --model qwen-audio-3.0-tts-flash --voice "$VOICE_ID" \ + --text "你好,这是复刻音色。" --output "$OUT/cloned.wav" +``` + +仅在确定不再使用时手动删除: + +```bash +python -m examples.01_atomic_capabilities.audio.voice_clone delete --voice-id "$VOICE_ID" --yes +``` + +## realtime_omni.py — Omni 单轮语音对话 + +接口:`client.audio.realtime.connect()`。固定模型:`qwen3.8-omni-flash-realtime`,默认音色:`longanlingxin`。 + +输入语音,通过 `commit()` / `create_response()` 请求回答,接收至 `response.done`,保存 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.realtime_omni \ + --wav tests/data/asr_example.wav --output "$OUT/omni-reply.wav" +``` + +## realtime_qwen_audio.py — Qwen-Audio 单轮语音对话 + +接口:`client.audio.realtime.connect()`。固定模型:`qwen-audio-3.0-realtime-plus`,默认音色:`longanqian`。 + +按 Qwen-Audio 的音频格式配置会话,输入结束后 `commit()` / `create_response()`,接收至 `response.done`,保存 WAV 后播放。 + +```bash +python -m examples.01_atomic_capabilities.audio.realtime_qwen_audio \ + --wav tests/data/asr_example.wav --output "$OUT/qwen-audio-reply.wav" +``` + +## realtime_translation.py — 实时语音翻译 + +接口:`client.audio.realtime.connect()`。固定模型:`qwen3.8-livetranslate-flash-realtime`;`--language` 指定目标语种,默认 `en`(英语)。 + +边输入边翻译,打印原文与译文,保存翻译音频后播放。使用服务端默认说话人检测,不调用 `commit()` / `create_response()`;输入结束调用 `end_session()`,继续接收至 `session.finished`,不在第一条 `response.done` 提前退出。 + +```bash +python -m examples.01_atomic_capabilities.audio.realtime_translation \ + --wav tests/data/asr_example.wav --language en --output "$OUT/translation.wav" +``` + +以上三个示例均可将 `--wav` 替换为 `--microphone --seconds 5`,加 `--no-play` 只保存。输入为 0.1~20 秒、16 kHz、16-bit PCM、单声道 WAV,输出为 24 kHz WAV;麦克风输入不会另存录音。各文件固定对应模型,不再使用原 `realtime.py` 的 `--model` 切换;新拆分示例尚未做云端验证。 + +## connection_reuse.py — 同一连接运行五个任务 + +接口:`client.ws_connection()`,通过 `connection=` 传给 `client.audio.asr.recognize()` 或 `client.audio.tts.connect()`。 + +ASR 使用 `qwen-audio-3.1-asr-flash-streaming`,TTS 使用 `qwen-audio-3.1-tts-flash` / `longanhuan_v3.1`。每次运行创建一条 WebSocket,顺序完成五个独立任务,输出逐轮文件与 `summary.json`;不录音、不播放,失败后不自动重连。 + +```bash +python -m examples.01_atomic_capabilities.audio.connection_reuse asr tests/data/asr_example.wav \ + --output-dir "$OUT/reuse-asr" +python -m examples.01_atomic_capabilities.audio.connection_reuse tts \ + --text "你好,这是连接复用。" --output-dir "$OUT/reuse-tts" +``` + +## tingwu.py — 听悟任务 + +接口:`client.tingwu`。听悟离线任务与实时会话示例。 + +## _common.py — 本地音频工具 + +提供 WAV 读写、麦克风采集、系统播放和参数校验,供上述示例使用;不调用云端接口,无独立命令行入口。 + +## 生产化模式 + +以上示例保持最小可运行;生产化请参考 `02_advanced_engineering/local_mock_debugging.py`(离线 mock 调试)、`02_advanced_engineering/circuit_breaker_retry.py`(退避与熔断容灾)、`04_real_world_scenarios/realtime_audio_meeting_harness.py`(实时音频会议 harness)。 diff --git a/examples/01_atomic_capabilities/audio/_common.py b/examples/01_atomic_capabilities/audio/_common.py new file mode 100644 index 00000000..4cfe41b8 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/_common.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""Local PCM capture, WAV containers, and operating-system playback.""" + +import argparse +import math +import shutil +import subprocess +import sys +import wave +from contextlib import contextmanager +from pathlib import Path + + +def positive_seconds(value): + seconds = float(value) + if not math.isfinite(seconds) or seconds <= 0: + raise argparse.ArgumentTypeError("秒数必须是大于 0 的有限数值") + return seconds + + +@contextmanager +def open_wav(path, sample_rates=(8000, 16000)): + with wave.open(str(path), "rb") as audio: + if ( + audio.getnchannels() != 1 + or audio.getsampwidth() != 2 + or audio.getcomptype() != "NONE" + or audio.getframerate() not in sample_rates + ): + raise ValueError( + f"需要单声道、16-bit PCM WAV,采样率为 {sample_rates};" + f"实际格式:{audio.getparams()}。可先使用 ffmpeg 转换。" + ) + if not audio.getnframes(): + raise ValueError("WAV 文件没有音频帧") + yield audio + + +@contextmanager +def wav_output(path, sample_rate): + path = Path(path) + if path.suffix.lower() != ".wav": + raise ValueError("PCM 输出必须使用 .wav 扩展名") + with path.open("xb") as raw: + with wave.open(raw, "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(sample_rate) + yield audio + + +def input_device(value): + return int(value) if value.isdecimal() else value + + +def load_sounddevice(): + try: + import sounddevice + except (ImportError, OSError) as error: + raise RuntimeError( + "录音需要 sounddevice 和 PortAudio;请在仓库根目录运行 " + "python -m pip install -e '.[acli-voice]'。" + "Linux 还需安装系统 PortAudio 库。" + ) from error + return sounddevice + + +@contextmanager +def microphone(sample_rate, device=None): + sd = load_sounddevice() + sd.check_input_settings( + device=device, channels=1, dtype="int16", samplerate=sample_rate, + ) + with sd.RawInputStream( + samplerate=sample_rate, + blocksize=sample_rate // 10, + device=device, + channels=1, + dtype="int16", + ) as stream: + yield stream + + +def player_command(path): + path = str(Path(path).resolve()) + if sys.platform == "darwin" and shutil.which("afplay"): + return ["afplay", path] + if sys.platform == "win32" and shutil.which("powershell"): + return [ + "powershell", "-NoProfile", "-NonInteractive", "-Command", + "$p = New-Object System.Media.SoundPlayer; " + "$p.SoundLocation = $env:DASHSCOPE_SAMPLE_WAV; $p.PlaySync()", + ] + if shutil.which("aplay"): + return ["aplay", path] + if shutil.which("ffplay"): + return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", path] + raise RuntimeError( + "找不到 WAV 播放器(afplay / PowerShell / aplay / ffplay);" + "可使用 --no-play 仅保存文件。" + ) + + +def play_wav(path): + import os + + command = player_command(path) + environment = dict(os.environ, DASHSCOPE_SAMPLE_WAV=str(Path(path).resolve())) + process = subprocess.Popen(command, env=environment) + try: + returncode = process.wait() + if returncode: + raise subprocess.CalledProcessError(returncode, command) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/examples/01_atomic_capabilities/audio/asr_file.py b/examples/01_atomic_capabilities/audio/asr_file.py new file mode 100644 index 00000000..ac66cc89 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_file.py @@ -0,0 +1,215 @@ +# -*- coding: utf-8 -*- +"""Submit or resume URL-based batch transcription using the V2 speech API.""" + +import argparse +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use an HTTPS audio URL without userinfo or fragments, not a local " + "path. Upload your own WAV to a service-reachable URL yourself; " + "this sample never uploads files." + ) + return value + + +def task_id(value): + if not value or any(char.isspace() or char in "/\\?#" for char in value): + raise argparse.ArgumentTypeError("task ID must be a nonempty ID, not a URL") + return value + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def transcript_text(document): + if (not isinstance(document, dict) + or not isinstance(document.get("transcripts"), list)): + raise ValueError("transcription JSON has no transcripts list") + texts = [] + for transcript in document["transcripts"]: + text = transcript.get("text") + if text is None: + text = "".join(sentence["text"] for sentence in transcript["sentences"]) + if not isinstance(text, str): + raise ValueError("transcript text must be a string") + texts.append(text) + return "\n".join(texts) + + +def download_results(job, args, metadata, output): + results = (job.output or {}).get("results") + if not isinstance(results, list) or not results: + raise RuntimeError( + "Task returned no per-file results; see task.json for its status" + ) + failures = 0 + # Never reuse the SDK transport: signed download URLs need no API key. + with httpx.Client(timeout=args.timeout, follow_redirects=True) as downloader: + for index, result in enumerate(results, 1): + record = {"index": index} + try: + if not isinstance(result, dict): + raise ValueError("invalid per-file result") + record["subtask_status"] = result.get("subtask_status") + if result.get("subtask_status") != "SUCCEEDED": + raise RuntimeError("subtask did not succeed") + url = https_url(result.get("transcription_url") or "") + response = downloader.get(url) + response.raise_for_status() + document = response.json() + json_path = args.output_dir / f"{index:03d}.json" + with json_path.open("x", encoding="utf-8") as artifact: + json.dump(document, artifact, ensure_ascii=False, indent=2) + artifact.write("\n") + record["json"] = json_path.name + text = transcript_text(document) + text_path = args.output_dir / f"{index:03d}.txt" + with text_path.open("x", encoding="utf-8") as artifact: + artifact.write(text + "\n") + record["text"] = text_path.name + print(f"File {index}: saved {json_path.name} and {text_path.name}", + flush=True) + except (httpx.HTTPError, OSError, ValueError, RuntimeError, + argparse.ArgumentTypeError, KeyError, TypeError, + AttributeError) as error: + failures += 1 + # Exception messages can contain presigned URLs; keep them out of logs. + record["error"] = type(error).__name__ + if isinstance(error, httpx.HTTPStatusError): + record["http_status"] = error.response.status_code + print(f"File {index}: FAILED ({record})", file=sys.stderr, flush=True) + metadata["files"].append(record) + checkpoint(output, metadata) + return failures + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP recorded-file ASR: submit a URL, poll, download transcripts. " + "Default: qwen-audio-3.1-asr-flash-filetrans; no WebSocket or SSE.", + epilog=( + "Upload your own WAV to an HTTPS URL reachable by the service first; " + "local paths are NOT file_urls. This sample never uploads audio.\n" + "Submit: python -m examples.01_atomic_capabilities.audio.asr_file --url \"$AUDIO_URL\" " + "--output-dir batch-new\n" + "Resume without a new paid submission: python -m examples.01_atomic_capabilities.audio.asr_file " + "--task-id TASK_ID --output-dir batch-resumed\n" + "Use your configured DASHSCOPE_API_KEY and the same region/workspace " + "when resuming. Real service usage may incur charges." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--url", action="append", type=https_url, + help="HTTPS audio URL; repeat for multiple files") + source.add_argument("--task-id", type=task_id, help="resume an existing task") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash-filetrans", + choices=("qwen-audio-3.1-asr-flash-filetrans", + "fun-asr", "paraformer-v2"), + help="submission model (ignored when resuming)") + parser.add_argument("--output-dir", type=Path, required=True, + help="new directory in an existing parent; never overwrite") + parser.add_argument("--wait-timeout", type=positive_seconds, default=600.0, + help="maximum polling seconds (default: 600)") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="SDK/download request timeout seconds (default: 30)") + parser.add_argument("--region", help="override DASHSCOPE_REGION") + parser.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if (args.url and args.model == "qwen-audio-3.1-asr-flash-filetrans" + and len(args.url) != 1): + parser.error("qwen-audio-3.1-asr-flash-filetrans requires exactly one --url") + args.output_dir = args.output_dir.resolve() + args.output_dir.mkdir() # Reserve the destination before any paid request. + task_path = args.output_dir / "task.json" + with task_path.open("x", encoding="utf-8") as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, raise_on_error=False, + ) as client: + metadata = { + "model": None if args.task_id else args.model, + "region": client.config.region, + "workspace": client.config.workspace, + "files": [], + } + if args.task_id: + job = args.task_id + metadata.update(task_id=args.task_id, status="RESUMING") + else: + print("Submitting a paid batch task; input URLs will not be logged.", + file=sys.stderr, flush=True) + job = client.audio.asr.jobs.create( + model=args.model, file_urls=args.url, parameters={}, + ) + if not job.task_id: + raise RuntimeError( + f"No task ID returned (request_id={job.request_id}); " + "check the service before submitting again" + ) + metadata.update(task_id=job.task_id, status=job.status) + print(f"task_id: {metadata['task_id']}", flush=True) + checkpoint(output, metadata) + print("Saved task.json; resume with --task-id and a NEW --output-dir " + "in the same region/workspace.", flush=True) + job = client.audio.asr.jobs.wait(job, timeout=args.wait_timeout) + metadata.update(status=job.status, request_id=job.request_id, code=job.code) + checkpoint(output, metadata) + failed = job.status != "SUCCEEDED" or job.is_error + print(f"Task status: {job.status}", flush=True) + # Even a failed batch may contain usable completed files. + failures = download_results(job, args, metadata, output) + if failed or failures: + print(f"Batch unsuccessful; {failures} per-file failure(s). " + "Completed outputs are preserved.", file=sys.stderr) + return 1 + print(f"Saved all transcripts in {args.output_dir}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nInterrupted; keep task.json and resume, rather than resubmit.", + file=sys.stderr) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + print(f"Request failed ({type(error).__name__}); keep task.json to resume. " + "If submission returned no ID, check the service before retrying.", + file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print(f"Batch failed: {error}. Existing outputs are preserved.", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/asr_http_stream.py b/examples/01_atomic_capabilities/audio/asr_http_stream.py new file mode 100644 index 00000000..f625ebf0 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_http_stream.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +"""REST ASR: submit complete WAV audio once and receive cumulative text via SSE.""" + +import argparse +import base64 +import json +import sys +from contextlib import closing +from pathlib import Path + +from httpx_sse import EventSource + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import open_wav, positive_seconds +from .asr_file import https_url + + +PATH = "/services/aigc/multimodal-generation/generation" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="RESTful ASR:一次提交完整 WAV,以 HTTP/SSE 接收累计转写。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.asr_http_stream " + "--file tests/data/asr_example.wav --transcript rest.txt\n" + "本地 WAV 以 Base64 随请求发送,或使用 --url 传服务可访问的 HTTPS 地址。" + "这不是实时音频输入;官方仅对至少 60 秒的音频分批返回。真实服务可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--file", type=Path, help="本地单声道 16-bit PCM WAV") + source.add_argument("--url", type=https_url, help="服务可下载的 HTTPS WAV URL") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash", + choices=("qwen-audio-3.1-asr-flash", + "qwen-audio-3.0-asr-flash")) + parser.add_argument("--sample-rate", type=int, default=16000, choices=(16000,), + help="此示例只发送 16 kHz WAV,不自动重采样") + parser.add_argument("--transcript", type=Path, required=True, + help="新的 UTF-8 TXT;每个累计结果覆盖本次文件内容") + parser.add_argument("--events", type=Path, help="可选:保存服务端原始 JSONL 事件") + parser.add_argument("--timeout", type=positive_seconds, default=120.0, + help="网络超时秒数;长音频首个 SSE 事件可能较慢,默认 120") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def receive_transcript(response, transcript, events=None): + text = "" + count = 0 + request_id = response.headers.get("x-request-id") + usage = None + for event in EventSource(response).iter_sse(): + if not event.data: + continue + body = json.loads(event.data) + if not isinstance(body, dict): + raise ValueError("ASR SSE 事件必须为 JSON 对象") + request_id = body.get("request_id") or request_id + if event.event == "error" or body.get("code"): + raise DashScopeError("ASR SSE 返回业务错误", request_id=request_id, + code=body.get("code")) + if events is not None: + events.write(json.dumps(body, ensure_ascii=False) + "\n") + events.flush() + if body.get("usage") is not None: + usage = body["usage"] + output = body.get("output") + if not isinstance(output, dict) or "text" not in output: + continue + text = output["text"] + if not isinstance(text, str): + raise ValueError("ASR output.text 必须为字符串") + count += 1 + # output.text is cumulative and may revise an earlier partial result. + transcript.seek(0) + transcript.write(text) + transcript.truncate() + transcript.flush() + print(f"[累计结果 {count}] {text}", flush=True) + if not count: + raise RuntimeError("HTTP 流结束,但没有收到 output.text") + print(json.dumps({ + "request_id": request_id, "text_events": count, "usage": usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + return text + + +def main(argv=None): + args = make_parser().parse_args(argv) + audio_data = args.url + if args.file is not None: + with open_wav(args.file, sample_rates=(args.sample_rate,)) as audio: + pcm = audio.readframes(audio.getnframes()) + if len(pcm) != audio.getnframes() * 2 or audio.readframes(1): + raise ValueError("WAV 的 PCM 数据长度与文件头不一致") + audio_data = "data:audio/wav;base64," + base64.b64encode( + args.file.read_bytes(), + ).decode("ascii") + body = { + "model": args.model, + "input": {"messages": [{ + "role": "user", + "content": [{"type": "input_audio", + "input_audio": {"data": audio_data}}], + }]}, + "parameters": {"format": "wav", "sample_rate": str(args.sample_rate)}, + } + with args.transcript.open("x+", encoding="utf-8") as transcript: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + with Client( + region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0, + ) as client: + # Generic chat normalization would change this ASR-specific schema. + with closing(client.transport.open_stream( + "POST", PATH, json=body, headers={"X-DashScope-SSE": "enable"}, + timeout=args.timeout, sdk_module="audio", + )) as response: + receive_transcript(response, transcript, events) + finally: + if events is not None: + events.close() + print(f"HTTP 流已结束,最后累计文本已保存到 {args.transcript.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消;输出可能是未完成的累计结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"REST ASR 失败({type(error).__name__}," + f"request_id={getattr(error, 'request_id', None)});" + "保留已有结果,请检查输入、网络、模型和地域。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/asr_microphone.py b/examples/01_atomic_capabilities/audio/asr_microphone.py new file mode 100644 index 00000000..6e95d779 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_microphone.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""Capture a system microphone and stream PCM to V2 ASR in the main thread.""" + +import argparse +import math +import signal +import sys +import time +from pathlib import Path + +from ._common import ( + input_device, + load_sounddevice, + microphone, + positive_seconds, + wav_output, +) +from .asr_websocket import ( + SampleError, + add_recognition_arguments, + check_receiver, + run_recognition, +) + + +def send_microphone(stream, recording, args, frame_count, session, receiver): + interrupted = False + + def request_stop(_signum, _frame): + nonlocal interrupted + interrupted = True + + # Raising during an SDK send would close the socket before results can drain. + previous_handler = signal.signal(signal.SIGINT, request_stop) + try: + stream.start() + deadline = time.monotonic() + args.seconds + print( + f"Recording up to {args.seconds:g}s; Ctrl+C stops capture and drains ASR.", + file=sys.stderr, flush=True, + ) + remaining = frame_count + while remaining and not interrupted and time.monotonic() < deadline: + check_receiver(receiver) + count = min(args.sample_rate // 10, remaining) + data, overflowed = stream.read(count) + check_receiver(receiver) + if overflowed: + raise SampleError( + "Microphone input overflowed; audio was lost. " + "Reduce system load or check the network before retrying." + ) + pcm = bytes(data) + if len(pcm) != count * 2: + raise SampleError("Microphone returned an incomplete PCM frame.") + if recording is not None: + recording.writeframesraw(pcm) + session.send_audio_frame(pcm) + remaining -= count + finally: + try: + stream.stop() + finally: + signal.signal(signal.SIGINT, previous_handler) + if interrupted: + print("Capture interrupted; draining final ASR results...", file=sys.stderr) + + +def recognize_microphone(args, transcript, recording, frame_count): + with microphone(args.sample_rate, device=args.device) as stream: + # Preflight input before connecting, then avoid buffering during setup. + stream.stop() + + def send_audio(session, receiver): + send_microphone( + stream, recording, args, frame_count, session, receiver, + ) + + run_recognition(args, args.sample_rate, transcript, send_audio) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Recognize system-microphone audio with Qwen-Audio 3.1 streaming ASR " + "by default. Capture uses blocking 100 ms mono int16 reads, " + "never network calls in an audio callback. " + "Uses existing SDK credentials. Ctrl+C during capture stops input, " + "then waits for final sentences. Final text goes to stdout and the " + "transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e '.[acli-voice]'\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --list-devices\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --seconds 10 " + "--transcript microphone.txt\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --device 1 --seconds 5 " + "--recording take.wav --transcript take.txt --partials\n" + " python -m examples.01_atomic_capabilities.audio.asr_microphone --model fun-asr-realtime " + "--no-recording --transcript fun.txt\n\n" + "PortAudio and OS microphone permission are required for recording. " + "Linux may need a system PortAudio package.\n" + "--list-devices is offline: no Client, recording, or output files.\n" + "Existing outputs are never replaced; failed runs may leave partial " + "files. No resampling is performed." + ), + ) + add_recognition_arguments(parser) + parser.add_argument( + "--seconds", type=positive_seconds, default=10.0, + help="maximum capture duration in seconds (default: 10)", + ) + parser.add_argument( + "--sample-rate", type=int, choices=(8000, 16000), default=16000, + help="microphone PCM sample rate (default: 16000); " + "8000 is only enabled for Paraformer in this example", + ) + parser.add_argument( + "--device", type=input_device, + help="sounddevice input index or name substring (default: system input)", + ) + parser.add_argument( + "--list-devices", action="store_true", + help="list local audio devices and exit without connecting or recording", + ) + outputs = parser.add_mutually_exclusive_group() + outputs.add_argument( + "--recording", type=Path, default=Path("recording.wav"), + help="new WAV file for captured PCM (default: recording.wav)", + ) + outputs.add_argument( + "--no-recording", dest="recording", action="store_const", const=None, + help="do not save the captured audio", + ) + args = parser.parse_args(argv) + try: + if args.list_devices: + print(load_sounddevice().query_devices()) + return 0 + if args.model != "paraformer-realtime-v2" and args.sample_rate != 16000: + raise SampleError( + f"This example uses --sample-rate 16000 for {args.model}." + ) + frame_count = max(1, math.ceil(args.seconds * args.sample_rate)) + load_sounddevice() + with args.transcript.open("x", encoding="utf-8") as transcript: + if args.recording is not None: + with wav_output(args.recording, args.sample_rate) as recording: + recognize_microphone(args, transcript, recording, frame_count) + else: + recognize_microphone(args, transcript, None, frame_count) + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/asr_websocket.py b/examples/01_atomic_capabilities/audio/asr_websocket.py new file mode 100644 index 00000000..ccd50de4 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/asr_websocket.py @@ -0,0 +1,210 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex ASR: send PCM from WAV while receiving sentences.""" + +import argparse +import json +import sys +import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError +from pathlib import Path + +from ._common import open_wav, positive_seconds + + +class SampleError(RuntimeError): + """A user-facing sample error with no transport or credential details.""" + + +def add_recognition_arguments(parser): + parser.add_argument( + "--model", choices=("qwen-audio-3.1-asr-flash-streaming", + "paraformer-realtime-v2", "fun-asr-realtime"), + default="qwen-audio-3.1-asr-flash-streaming", + help="ASR model (default: qwen-audio-3.1-asr-flash-streaming); " + "this example uses 16 kHz for Qwen-Audio and Fun-ASR", + ) + parser.add_argument( + "--transcript", type=Path, default=Path("transcript.txt"), + help="new UTF-8 file for final sentences only (default: transcript.txt)", + ) + parser.add_argument( + "--partials", action="store_true", + help="also display provisional text on stderr; never save it", + ) + parser.add_argument( + "--timeout", type=positive_seconds, default=30.0, + help="socket timeout and final-result wait budget in seconds (default: 30)", + ) + parser.add_argument("--region", help="DashScope region; default: SDK configuration") + parser.add_argument("--workspace", help="DashScope workspace ID") + parser.add_argument("--vocabulary-id", help="existing ASR vocabulary ID") + + +def check_receiver(receiver): + if receiver.done(): + receiver.result() + raise SampleError("ASR finished before all audio was sent.") + + +def consume_results(session, transcript, partials): + for result in session.results(): + sentence = result.get_sentence() + if not sentence or not sentence.get("text"): + continue + text = sentence["text"] + if result.is_sentence_end(): + transcript.write(text + "\n") + transcript.flush() + print(text, flush=True) + elif partials: + print(f"[partial] {text}", file=sys.stderr, flush=True) + + +def run_recognition(args, sample_rate, transcript, send_audio): + try: + from dashscope import Client + except ImportError: + raise SampleError( + "The V2 SDK is required; run python -m pip install -e . " + "from the repository root." + ) from None + + try: + with Client( + timeout=args.timeout, region=args.region, workspace=args.workspace, + ) as client: + session = client.audio.asr.recognize( + model=args.model, + format="pcm", + sample_rate=sample_rate, + vocabulary_id=args.vocabulary_id, + timeout=args.timeout, + ) + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = None + try: + session.start() + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_audio(session, receiver) + check_receiver(receiver) + session.stop() + try: + receiver.result(timeout=args.timeout) + except FutureTimeoutError: + if receiver.done(): + receiver.result() + raise SampleError( + f"No task-finished within {args.timeout:g}s after stop." + ) from None + except BaseException: + if receiver is not None and receiver.done(): + receiver.result() + raise + finally: + # Close before executor shutdown so a blocked receiver can exit. + session.close() + print(json.dumps({ + "request_id": session.request_id, + "first_package_delay_ms": session.first_package_delay, + "last_package_delay_ms": session.last_package_delay, + "usage": session.usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + except SampleError: + raise + except Exception as error: + # Transport exceptions can include authorization headers or response bodies. + raise SampleError( + f"ASR failed ({type(error).__name__}). Check network, SDK credentials, " + "model/region access, audio device/output availability and --timeout. " + "Output files may be incomplete." + ) from None + + +def wav_frames(audio): + remaining = audio.getnframes() + block_frames = audio.getframerate() // 10 + while remaining: + count = min(block_frames, remaining) + pcm = audio.readframes(count) + if len(pcm) != count * 2: + raise SampleError("Truncated WAV: fewer PCM bytes than declared frames.") + yield pcm + remaining -= count + if audio.readframes(1): + raise SampleError("Invalid WAV: PCM data is not aligned to 16-bit frames.") + + +def validate_wav_data(audio): + for _ in wav_frames(audio): + pass + audio.rewind() + + +def wait_until(deadline, receiver): + check_receiver(receiver) + delay = deadline - time.monotonic() + if delay > 0: + time.sleep(delay) + check_receiver(receiver) + + +def send_wav(audio, session, receiver): + deadline = time.monotonic() + for pcm in wav_frames(audio): + wait_until(deadline, receiver) + deadline = time.monotonic() + len(pcm) / (2 * audio.getframerate()) + session.send_audio_frame(pcm) + wait_until(deadline, receiver) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "WebSocket duplex ASR with Qwen-Audio 3.1 streaming by default. " + "Send a mono, 16-bit, 16 kHz PCM WAV while receiving text; not HTTP. " + "WAV headers are removed and audio is paced in 100 ms frames. " + "Uses existing SDK credentials. Final sentences go to stdout and " + "the transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e .\n" + " python -m examples.01_atomic_capabilities.audio.asr_websocket speech.wav " + "--transcript speech.txt\n" + " python -m examples.01_atomic_capabilities.audio.asr_websocket speech.wav " + "--model fun-asr-realtime --transcript fun.txt --partials\n\n" + "Existing output files are never replaced. Failed runs may leave " + "partial files.\n" + "This example uses 16 kHz for Qwen-Audio and Fun-ASR; " + "Paraformer also accepts 8 kHz. No resampling is performed." + ), + ) + parser.add_argument("file", type=Path, help="local uncompressed PCM WAV file") + add_recognition_arguments(parser) + args = parser.parse_args(argv) + try: + rates = (8000, 16000) if args.model == "paraformer-realtime-v2" else (16000,) + with open_wav(args.file, sample_rates=rates) as audio: + validate_wav_data(audio) + with args.transcript.open("x", encoding="utf-8") as transcript: + def send_audio(session, receiver): + send_wav(audio, session, receiver) + + run_recognition(args, audio.getframerate(), transcript, send_audio) + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except EOFError: + print("error: invalid or truncated WAV header.", file=sys.stderr) + return 1 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/connection_reuse.py b/examples/01_atomic_capabilities/audio/connection_reuse.py new file mode 100644 index 00000000..65e2bcc3 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/connection_reuse.py @@ -0,0 +1,234 @@ +# -*- coding: utf-8 -*- +"""Run five sequential ASR or TTS tasks on one real WebSocket connection.""" + +import argparse +import json +import sys +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from pathlib import Path +from threading import Event, Timer + +from dashscope import Client +from ._common import open_wav, positive_seconds, wav_output +from .asr_websocket import ( + check_receiver, + consume_results, + send_wav, + validate_wav_data, +) +from .tts_websocket import receive_audio + + +RUNS = 5 + + +def completed_task(session): + task_id = session.session.task_id + header = (session.last_response or {}).get("header", {}) + if header.get("event") != "task-finished" or header.get("task_id") != task_id: + raise RuntimeError("任务未收到匹配的 task-finished,停止复用连接") + return { + "task_id": task_id, + "request_id": session.session.request_id, + "final_event": header["event"], + "first_package_delay_ms": session.first_package_delay, + "usage": session.usage, + } + + +def recognize_one(client, connection, args, source, index): + source.rewind() + path = args.output_dir / f"asr-{index:02d}.txt" + with path.open("x+", encoding="utf-8") as transcript: + with client.audio.asr.recognize( + model=args.model, format="pcm", sample_rate=16000, + connection=connection, timeout=args.timeout, + ) as session: + print(f"ASR {index}/{RUNS}: task_id={session.session.task_id}", flush=True) + with ThreadPoolExecutor(max_workers=1) as executor: + try: + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_wav(source, session, receiver) + check_receiver(receiver) + session.stop() + receiver.result(timeout=args.timeout) + result = completed_task(session) + result["last_package_delay_ms"] = session.last_package_delay + finally: + # Close the task before joining a possibly blocked receiver. + session.close() + transcript.seek(0) + text = transcript.read().strip() + if not text: + raise RuntimeError("没有最终转写,请使用包含清晰语音的 WAV") + result.update(transcript=path.name, text=text) + return result + + +def synthesize_one(client, connection, args, index): + path = args.output_dir / f"tts-{index:02d}.wav" + events_path = args.output_dir / f"tts-{index:02d}-events.jsonl" + text = f"第{index}次。{args.text}" + with wav_output(path, 24000) as output: + with events_path.open("x", encoding="utf-8") as events: + with client.audio.tts.connect( + model=args.model, voice=args.voice, format="pcm", sample_rate=24000, + connection=connection, timeout=args.timeout, + ) as session: + print( + f"TTS {index}/{RUNS}: task_id={session.session.task_id}", + flush=True, + ) + with ThreadPoolExecutor(max_workers=1) as executor: + try: + receiver = executor.submit( + receive_audio, session, output, events, + ) + session.send_text(text) + session.stop() + size = receiver.result(timeout=args.timeout) + result = completed_task(session) + finally: + # Close the task before joining a possibly blocked receiver. + session.close() + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + result.update(audio=path.name, events=events_path.name, input_text=text, + audio_bytes=size, audio_seconds=size / 48000) + return result + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def make_parser(): + parser = argparse.ArgumentParser( + description="在一条真实 WebSocket 上顺序执行 5 次 ASR 或 TTS,不自动重连。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.connection_reuse asr tests/data/asr_example.wav " + "--output-dir reuse-asr\n" + "python -m examples.01_atomic_capabilities.audio.connection_reuse tts --output-dir reuse-tts\n" + "ASR 与 TTS 分别运行,各创建一条连接;不是混合并发测试。" + "使用已配置的 SDK 凭据,真实调用可能计费。不会录音或播放。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--output-dir", type=Path, required=True, + help="新的输出目录,父目录必须存在,不覆盖已有目录") + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="握手、任务接收及结束输入后等待超时秒数,默认 30") + common.add_argument("--task-timeout", type=positive_seconds, default=120.0, + help="每轮任务总预算秒数,超时关闭整条连接,默认 120") + common.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + common.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="kind", required=True) + asr = commands.add_parser("asr", parents=[common], help="同一 WAV 识别 5 次") + asr.add_argument("file", type=Path, help="16 kHz、16-bit、单声道 PCM WAV") + asr.add_argument("--model", default="qwen-audio-3.1-asr-flash-streaming", + choices=("qwen-audio-3.1-asr-flash-streaming",)) + asr.add_argument("--partials", action="store_true", help="显示临时识别文本") + tts = commands.add_parser("tts", parents=[common], help="带轮次编号的文本合成 5 次") + tts.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash",)) + tts.add_argument("--voice", default="longanhuan_v3.1", help="与 TTS 模型匹配的音色") + tts.add_argument("--text", default="你好,这是连接复用测试。", + help="每轮自动在文本前加“第N次。”") + return parser + + +def run_tasks(args, source=None): + args.output_dir.mkdir() + with (args.output_dir / "summary.json").open("x", encoding="utf-8") as output: + data = {"kind": args.kind, "model": args.model, "runs_per_connection": RUNS, + "status": "running", "connection_count": 0, "runs": []} + checkpoint(output, data) + try: + with Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) as client: + data.update( + region=client.config.region, workspace=client.config.workspace, + ) + with closing(client.ws_connection( + timeout=args.timeout, module="audio", + )) as connection: + data["connection_count"] = 1 + checkpoint(output, data) + try: + for index in range(1, RUNS + 1): + expired = Event() + + def abort_task(): + expired.set() + connection.close() + + watchdog = Timer(args.task_timeout, abort_task) + watchdog.daemon = True + watchdog.start() + try: + if args.kind == "asr": + result = recognize_one( + client, connection, args, source, index, + ) + else: + result = synthesize_one( + client, connection, args, index, + ) + finally: + watchdog.cancel() + watchdog.join() + if expired.is_set(): + raise TimeoutError("单轮任务超过 --task-timeout") + if connection.closed: + raise RuntimeError("共享连接已断开,停止后续任务") + if any(item["task_id"] == result["task_id"] + for item in data["runs"]): + raise RuntimeError("不同轮次使用了相同 task_id") + result.update(index=index, connection_open_after_task=True) + data["runs"].append(result) + checkpoint(output, data) + print(json.dumps(result, ensure_ascii=False), flush=True) + finally: + connection.close() + data["connection_closed"] = connection.closed + data["status"] = "completed" + except BaseException as error: + data.update(status="failed", error_type=type(error).__name__) + raise + finally: + checkpoint(output, data) + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.kind == "asr": + with open_wav(args.file, (16000,)) as source: + validate_wav_data(source) + run_tasks(args, source) + else: + if not args.text.strip(): + parser.error("--text 不能为空") + run_tasks(args) + print(f"完成:1 条连接,{RUNS} 个独立任务;{args.output_dir.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消并关闭连接;保留已完成结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"复用测试失败({type(error).__name__});保留已有结果,不自动重试。", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/_common.py b/examples/01_atomic_capabilities/audio/direct_call/_common.py new file mode 100644 index 00000000..0675c4e9 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/_common.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""Local PCM capture, WAV containers, and operating-system playback.""" + +import argparse +import math +import shutil +import subprocess +import sys +import wave +from pathlib import Path + + +def positive_seconds(value): + seconds = float(value) + if not math.isfinite(seconds) or seconds <= 0: + raise argparse.ArgumentTypeError("秒数必须是大于 0 的有限数值") + return seconds + + +def open_wav(path, sample_rates=(8000, 16000)): + audio = wave.open(str(path), "rb") + try: + if ( + audio.getnchannels() != 1 + or audio.getsampwidth() != 2 + or audio.getcomptype() != "NONE" + or audio.getframerate() not in sample_rates + ): + raise ValueError( + f"需要单声道、16-bit PCM WAV,采样率为 {sample_rates};" + f"实际格式:{audio.getparams()}。可先使用 ffmpeg 转换。" + ) + if not audio.getnframes(): + raise ValueError("WAV 文件没有音频帧") + return audio + except BaseException: + audio.close() + raise + + +def wav_output(path, sample_rate): + """Return (raw_file, WAV_writer); close the writer before the raw file.""" + path = Path(path) + if path.suffix.lower() != ".wav": + raise ValueError("PCM 输出必须使用 .wav 扩展名") + raw = path.open("xb") + try: + audio = wave.open(raw, "wb") + try: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(sample_rate) + except BaseException: + audio.close() + raise + return raw, audio + except BaseException: + raw.close() + raise + + +def input_device(value): + return int(value) if value.isdecimal() else value + + +def load_sounddevice(): + try: + import sounddevice + except (ImportError, OSError) as error: + raise RuntimeError( + "录音需要 sounddevice 和 PortAudio;请在仓库根目录运行 " + "python -m pip install -e '.[acli-voice]'。" + "Linux 还需安装系统 PortAudio 库。" + ) from error + return sounddevice + + +def microphone(sample_rate, device=None): + """Return an unstarted input stream; the caller starts, stops and closes it.""" + sd = load_sounddevice() + sd.check_input_settings( + device=device, channels=1, dtype="int16", samplerate=sample_rate, + ) + return sd.RawInputStream( + samplerate=sample_rate, + blocksize=sample_rate // 10, + device=device, + channels=1, + dtype="int16", + ) + + +def player_command(path): + path = str(Path(path).resolve()) + if sys.platform == "darwin" and shutil.which("afplay"): + return ["afplay", path] + if sys.platform == "win32" and shutil.which("powershell"): + return [ + "powershell", "-NoProfile", "-NonInteractive", "-Command", + "$p = New-Object System.Media.SoundPlayer; " + "$p.SoundLocation = $env:DASHSCOPE_SAMPLE_WAV; $p.PlaySync()", + ] + if shutil.which("aplay"): + return ["aplay", path] + if shutil.which("ffplay"): + return ["ffplay", "-nodisp", "-autoexit", "-loglevel", "error", path] + raise RuntimeError( + "找不到 WAV 播放器(afplay / PowerShell / aplay / ffplay);" + "可使用 --no-play 仅保存文件。" + ) + + +def play_wav(path): + import os + + command = player_command(path) + environment = dict(os.environ, DASHSCOPE_SAMPLE_WAV=str(Path(path).resolve())) + process = subprocess.Popen(command, env=environment) + try: + returncode = process.wait() + if returncode: + raise subprocess.CalledProcessError(returncode, command) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_file.py b/examples/01_atomic_capabilities/audio/direct_call/asr_file.py new file mode 100644 index 00000000..7394aefe --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_file.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- +"""Submit or resume URL-based batch transcription using the V2 speech API.""" + +import argparse +import json +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use an HTTPS audio URL without userinfo or fragments, not a local " + "path. Upload your own WAV to a service-reachable URL yourself; " + "this sample never uploads files." + ) + return value + + +def task_id(value): + if not value or any(char.isspace() or char in "/\\?#" for char in value): + raise argparse.ArgumentTypeError("task ID must be a nonempty ID, not a URL") + return value + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def transcript_text(document): + if (not isinstance(document, dict) + or not isinstance(document.get("transcripts"), list)): + raise ValueError("transcription JSON has no transcripts list") + texts = [] + for transcript in document["transcripts"]: + text = transcript.get("text") + if text is None: + text = "".join(sentence["text"] for sentence in transcript["sentences"]) + if not isinstance(text, str): + raise ValueError("transcript text must be a string") + texts.append(text) + return "\n".join(texts) + + +def download_results(job, args, metadata, output): + results = (job.output or {}).get("results") + if not isinstance(results, list) or not results: + raise RuntimeError( + "Task returned no per-file results; see task.json for its status" + ) + failures = 0 + # Never reuse the SDK transport: signed download URLs need no API key. + downloader = httpx.Client(timeout=args.timeout, follow_redirects=True) + try: + for index, result in enumerate(results, 1): + record = {"index": index} + try: + if not isinstance(result, dict): + raise ValueError("invalid per-file result") + record["subtask_status"] = result.get("subtask_status") + if result.get("subtask_status") != "SUCCEEDED": + raise RuntimeError("subtask did not succeed") + url = https_url(result.get("transcription_url") or "") + response = downloader.get(url) + response.raise_for_status() + document = response.json() + json_path = args.output_dir / f"{index:03d}.json" + artifact = json_path.open("x", encoding="utf-8") + try: + json.dump(document, artifact, ensure_ascii=False, indent=2) + artifact.write("\n") + finally: + artifact.close() + record["json"] = json_path.name + text = transcript_text(document) + text_path = args.output_dir / f"{index:03d}.txt" + artifact = text_path.open("x", encoding="utf-8") + try: + artifact.write(text + "\n") + finally: + artifact.close() + record["text"] = text_path.name + print(f"File {index}: saved {json_path.name} and {text_path.name}", + flush=True) + except (httpx.HTTPError, OSError, ValueError, RuntimeError, + argparse.ArgumentTypeError, KeyError, TypeError, + AttributeError) as error: + failures += 1 + # Exception messages can contain presigned URLs; keep them out of logs. + record["error"] = type(error).__name__ + if isinstance(error, httpx.HTTPStatusError): + record["http_status"] = error.response.status_code + print(f"File {index}: FAILED ({record})", file=sys.stderr, flush=True) + metadata["files"].append(record) + checkpoint(output, metadata) + finally: + downloader.close() + return failures + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP recorded-file ASR: submit a URL, poll, download transcripts. " + "Default: qwen-audio-3.1-asr-flash-filetrans; no WebSocket or SSE.", + epilog=( + "Upload your own WAV to an HTTPS URL reachable by the service first; " + "local paths are NOT file_urls. This sample never uploads audio.\n" + "Submit: python -m examples.01_atomic_capabilities.audio.direct_call.asr_file --url \"$AUDIO_URL\" " + "--output-dir batch-new\n" + "Resume without a new paid submission: " + "python -m examples.01_atomic_capabilities.audio.direct_call.asr_file " + "--task-id TASK_ID --output-dir batch-resumed\n" + "Use your configured DASHSCOPE_API_KEY and the same region/workspace " + "when resuming. Real service usage may incur charges." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--url", action="append", type=https_url, + help="HTTPS audio URL; repeat for multiple files") + source.add_argument("--task-id", type=task_id, help="resume an existing task") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash-filetrans", + choices=("qwen-audio-3.1-asr-flash-filetrans", + "fun-asr", "paraformer-v2"), + help="submission model (ignored when resuming)") + parser.add_argument("--output-dir", type=Path, required=True, + help="new directory in an existing parent; never overwrite") + parser.add_argument("--wait-timeout", type=positive_seconds, default=600.0, + help="maximum polling seconds (default: 600)") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="SDK/download request timeout seconds (default: 30)") + parser.add_argument("--region", help="override DASHSCOPE_REGION") + parser.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if (args.url and args.model == "qwen-audio-3.1-asr-flash-filetrans" + and len(args.url) != 1): + parser.error("qwen-audio-3.1-asr-flash-filetrans requires exactly one --url") + args.output_dir = args.output_dir.resolve() + args.output_dir.mkdir() # Reserve the destination before any paid request. + task_path = args.output_dir / "task.json" + output = task_path.open("x", encoding="utf-8") + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, raise_on_error=False, + ) + try: + metadata = { + "model": None if args.task_id else args.model, + "region": client.config.region, + "workspace": client.config.workspace, + "files": [], + } + if args.task_id: + job = args.task_id + metadata.update(task_id=args.task_id, status="RESUMING") + else: + print("Submitting a paid batch task; input URLs will not be logged.", + file=sys.stderr, flush=True) + job = client.audio.asr.jobs.create( + model=args.model, file_urls=args.url, parameters={}, + ) + if not job.task_id: + raise RuntimeError( + f"No task ID returned (request_id={job.request_id}); " + "check the service before submitting again" + ) + metadata.update(task_id=job.task_id, status=job.status) + print(f"task_id: {metadata['task_id']}", flush=True) + checkpoint(output, metadata) + print("Saved task.json; resume with --task-id and a NEW --output-dir " + "in the same region/workspace.", flush=True) + job = client.audio.asr.jobs.wait(job, timeout=args.wait_timeout) + metadata.update(status=job.status, request_id=job.request_id, code=job.code) + checkpoint(output, metadata) + failed = job.status != "SUCCEEDED" or job.is_error + print(f"Task status: {job.status}", flush=True) + # Even a failed batch may contain usable completed files. + failures = download_results(job, args, metadata, output) + if failed or failures: + print(f"Batch unsuccessful; {failures} per-file failure(s). " + "Completed outputs are preserved.", file=sys.stderr) + return 1 + finally: + client.close() + finally: + output.close() + print(f"Saved all transcripts in {args.output_dir}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\nInterrupted; keep task.json and resume, rather than resubmit.", + file=sys.stderr) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + print(f"Request failed ({type(error).__name__}); keep task.json to resume. " + "If submission returned no ID, check the service before retrying.", + file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print(f"Batch failed: {error}. Existing outputs are preserved.", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py b/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py new file mode 100644 index 00000000..1f5c26d0 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +"""REST ASR: submit complete WAV audio once and receive cumulative text via SSE.""" + +import argparse +import base64 +import json +import sys +from pathlib import Path + +from httpx_sse import EventSource + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import open_wav, positive_seconds +from .asr_file import https_url + + +PATH = "/services/aigc/multimodal-generation/generation" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="RESTful ASR:一次提交完整 WAV,以 HTTP/SSE 接收累计转写。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.asr_http_stream " + "--file tests/data/asr_example.wav --transcript rest.txt\n" + "本地 WAV 以 Base64 随请求发送,或使用 --url 传服务可访问的 HTTPS 地址。" + "这不是实时音频输入;官方仅对至少 60 秒的音频分批返回。真实服务可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--file", type=Path, help="本地单声道 16-bit PCM WAV") + source.add_argument("--url", type=https_url, help="服务可下载的 HTTPS WAV URL") + parser.add_argument("--model", default="qwen-audio-3.1-asr-flash", + choices=("qwen-audio-3.1-asr-flash", + "qwen-audio-3.0-asr-flash")) + parser.add_argument("--sample-rate", type=int, default=16000, choices=(16000,), + help="此示例只发送 16 kHz WAV,不自动重采样") + parser.add_argument("--transcript", type=Path, required=True, + help="新的 UTF-8 TXT;每个累计结果覆盖本次文件内容") + parser.add_argument("--events", type=Path, help="可选:保存服务端原始 JSONL 事件") + parser.add_argument("--timeout", type=positive_seconds, default=120.0, + help="网络超时秒数;长音频首个 SSE 事件可能较慢,默认 120") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def receive_transcript(response, transcript, events=None): + text = "" + count = 0 + request_id = response.headers.get("x-request-id") + usage = None + for event in EventSource(response).iter_sse(): + if not event.data: + continue + body = json.loads(event.data) + if not isinstance(body, dict): + raise ValueError("ASR SSE 事件必须为 JSON 对象") + request_id = body.get("request_id") or request_id + if event.event == "error" or body.get("code"): + raise DashScopeError("ASR SSE 返回业务错误", request_id=request_id, + code=body.get("code")) + if events is not None: + events.write(json.dumps(body, ensure_ascii=False) + "\n") + events.flush() + if body.get("usage") is not None: + usage = body["usage"] + output = body.get("output") + if not isinstance(output, dict) or "text" not in output: + continue + text = output["text"] + if not isinstance(text, str): + raise ValueError("ASR output.text 必须为字符串") + count += 1 + # output.text is cumulative and may revise an earlier partial result. + transcript.seek(0) + transcript.write(text) + transcript.truncate() + transcript.flush() + print(f"[累计结果 {count}] {text}", flush=True) + if not count: + raise RuntimeError("HTTP 流结束,但没有收到 output.text") + print(json.dumps({ + "request_id": request_id, "text_events": count, "usage": usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + return text + + +def main(argv=None): + args = make_parser().parse_args(argv) + audio_data = args.url + if args.file is not None: + audio = open_wav(args.file, sample_rates=(args.sample_rate,)) + try: + pcm = audio.readframes(audio.getnframes()) + if len(pcm) != audio.getnframes() * 2 or audio.readframes(1): + raise ValueError("WAV 的 PCM 数据长度与文件头不一致") + finally: + audio.close() + audio_data = "data:audio/wav;base64," + base64.b64encode( + args.file.read_bytes(), + ).decode("ascii") + body = { + "model": args.model, + "input": {"messages": [{ + "role": "user", + "content": [{"type": "input_audio", + "input_audio": {"data": audio_data}}], + }]}, + "parameters": {"format": "wav", "sample_rate": str(args.sample_rate)}, + } + transcript = args.transcript.open("x+", encoding="utf-8") + try: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + client = Client( + region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0, + ) + try: + # Generic chat normalization would change this ASR-specific schema. + response = client.transport.open_stream( + "POST", PATH, json=body, headers={"X-DashScope-SSE": "enable"}, + timeout=args.timeout, sdk_module="audio", + ) + try: + receive_transcript(response, transcript, events) + finally: + response.close() + finally: + client.close() + finally: + if events is not None: + events.close() + finally: + transcript.close() + print(f"HTTP 流已结束,最后累计文本已保存到 {args.transcript.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消;输出可能是未完成的累计结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"REST ASR 失败({type(error).__name__}," + f"request_id={getattr(error, 'request_id', None)});" + "保留已有结果,请检查输入、网络、模型和地域。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_microphone.py b/examples/01_atomic_capabilities/audio/direct_call/asr_microphone.py new file mode 100644 index 00000000..af123010 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_microphone.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +"""Capture a system microphone and stream PCM to V2 ASR in the main thread.""" + +import argparse +import math +import signal +import sys +import time +from pathlib import Path + +from ._common import ( + input_device, + load_sounddevice, + microphone, + positive_seconds, + wav_output, +) +from .asr_websocket import ( + SampleError, + add_recognition_arguments, + check_receiver, + run_recognition, +) + + +def send_microphone(stream, recording, args, frame_count, session, receiver): + interrupted = False + + def request_stop(_signum, _frame): + nonlocal interrupted + interrupted = True + + # Raising during an SDK send would close the socket before results can drain. + previous_handler = signal.signal(signal.SIGINT, request_stop) + try: + stream.start() + deadline = time.monotonic() + args.seconds + print( + f"Recording up to {args.seconds:g}s; Ctrl+C stops capture and drains ASR.", + file=sys.stderr, flush=True, + ) + remaining = frame_count + while remaining and not interrupted and time.monotonic() < deadline: + check_receiver(receiver) + count = min(args.sample_rate // 10, remaining) + data, overflowed = stream.read(count) + check_receiver(receiver) + if overflowed: + raise SampleError( + "Microphone input overflowed; audio was lost. " + "Reduce system load or check the network before retrying." + ) + pcm = bytes(data) + if len(pcm) != count * 2: + raise SampleError("Microphone returned an incomplete PCM frame.") + if recording is not None: + recording.writeframesraw(pcm) + session.send_audio_frame(pcm) + remaining -= count + finally: + try: + stream.stop() + finally: + signal.signal(signal.SIGINT, previous_handler) + if interrupted: + print("Capture interrupted; draining final ASR results...", file=sys.stderr) + + +def recognize_microphone(args, transcript, recording, frame_count): + stream = microphone(args.sample_rate, device=args.device) + try: + def send_audio(session, receiver): + send_microphone( + stream, recording, args, frame_count, session, receiver, + ) + + run_recognition(args, args.sample_rate, transcript, send_audio) + finally: + stream.close() + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "Recognize system-microphone audio with Qwen-Audio 3.1 streaming ASR " + "by default. Capture uses blocking 100 ms mono int16 reads, " + "never network calls in an audio callback. " + "Uses existing SDK credentials. Ctrl+C during capture stops input, " + "then waits for final sentences. Final text goes to stdout and the " + "transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e '.[acli-voice]'\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone --list-devices\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone --seconds 10 " + "--transcript microphone.txt\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone " + "--device 1 --seconds 5 " + "--recording take.wav --transcript take.txt --partials\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_microphone " + "--model fun-asr-realtime " + "--no-recording --transcript fun.txt\n\n" + "PortAudio and OS microphone permission are required for recording. " + "Linux may need a system PortAudio package.\n" + "--list-devices is offline: no Client, recording, or output files.\n" + "Existing outputs are never replaced; failed runs may leave partial " + "files. No resampling is performed." + ), + ) + add_recognition_arguments(parser) + parser.add_argument( + "--seconds", type=positive_seconds, default=10.0, + help="maximum capture duration in seconds (default: 10)", + ) + parser.add_argument( + "--sample-rate", type=int, choices=(8000, 16000), default=16000, + help="microphone PCM sample rate (default: 16000); " + "8000 is only enabled for Paraformer in this example", + ) + parser.add_argument( + "--device", type=input_device, + help="sounddevice input index or name substring (default: system input)", + ) + parser.add_argument( + "--list-devices", action="store_true", + help="list local audio devices and exit without connecting or recording", + ) + outputs = parser.add_mutually_exclusive_group() + outputs.add_argument( + "--recording", type=Path, default=Path("recording.wav"), + help="new WAV file for captured PCM (default: recording.wav)", + ) + outputs.add_argument( + "--no-recording", dest="recording", action="store_const", const=None, + help="do not save the captured audio", + ) + args = parser.parse_args(argv) + try: + if args.list_devices: + print(load_sounddevice().query_devices()) + return 0 + if args.model != "paraformer-realtime-v2" and args.sample_rate != 16000: + raise SampleError( + f"This example uses --sample-rate 16000 for {args.model}." + ) + frame_count = max(1, math.ceil(args.seconds * args.sample_rate)) + load_sounddevice() + transcript = args.transcript.open("x", encoding="utf-8") + try: + if args.recording is not None: + raw, recording = wav_output(args.recording, args.sample_rate) + try: + recognize_microphone(args, transcript, recording, frame_count) + finally: + try: + recording.close() + finally: + raw.close() + else: + recognize_microphone(args, transcript, None, frame_count) + finally: + transcript.close() + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_websocket.py b/examples/01_atomic_capabilities/audio/direct_call/asr_websocket.py new file mode 100644 index 00000000..3a1a7bec --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_websocket.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex ASR: send PCM from WAV while receiving sentences.""" + +import argparse +import json +import sys +import time +from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError +from pathlib import Path + +from ._common import open_wav, positive_seconds + + +class SampleError(RuntimeError): + """A user-facing sample error with no transport or credential details.""" + + +def add_recognition_arguments(parser): + parser.add_argument( + "--model", choices=("qwen-audio-3.1-asr-flash-streaming", + "paraformer-realtime-v2", "fun-asr-realtime"), + default="qwen-audio-3.1-asr-flash-streaming", + help="ASR model (default: qwen-audio-3.1-asr-flash-streaming); " + "this example uses 16 kHz for Qwen-Audio and Fun-ASR", + ) + parser.add_argument( + "--transcript", type=Path, default=Path("transcript.txt"), + help="new UTF-8 file for final sentences only (default: transcript.txt)", + ) + parser.add_argument( + "--partials", action="store_true", + help="also display provisional text on stderr; never save it", + ) + parser.add_argument( + "--timeout", type=positive_seconds, default=30.0, + help="socket timeout and final-result wait budget in seconds (default: 30)", + ) + parser.add_argument("--region", help="DashScope region; default: SDK configuration") + parser.add_argument("--workspace", help="DashScope workspace ID") + parser.add_argument("--vocabulary-id", help="existing ASR vocabulary ID") + + +def check_receiver(receiver): + if receiver.done(): + receiver.result() + raise SampleError("ASR finished before all audio was sent.") + + +def consume_results(session, transcript, partials): + for result in session.results(): + sentence = result.get_sentence() + if not sentence or not sentence.get("text"): + continue + text = sentence["text"] + if result.is_sentence_end(): + transcript.write(text + "\n") + transcript.flush() + print(text, flush=True) + elif partials: + print(f"[partial] {text}", file=sys.stderr, flush=True) + + +def run_recognition(args, sample_rate, transcript, send_audio): + try: + from dashscope import Client + except ImportError: + raise SampleError( + "The V2 SDK is required; run python -m pip install -e . " + "from the repository root." + ) from None + + try: + client = Client( + timeout=args.timeout, region=args.region, workspace=args.workspace, + ) + try: + executor = None + session = client.audio.asr.recognize( + model=args.model, + format="pcm", + sample_rate=sample_rate, + vocabulary_id=args.vocabulary_id, + timeout=args.timeout, + ) + try: + executor = ThreadPoolExecutor(max_workers=1) + receiver = None + try: + session.start() + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_audio(session, receiver) + check_receiver(receiver) + session.stop() + try: + receiver.result(timeout=args.timeout) + except FutureTimeoutError: + if receiver.done(): + receiver.result() + raise SampleError( + f"No task-finished within {args.timeout:g}s after stop." + ) from None + except BaseException: + if receiver is not None and receiver.done(): + receiver.result() + raise + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + print(json.dumps({ + "request_id": session.request_id, + "first_package_delay_ms": session.first_package_delay, + "last_package_delay_ms": session.last_package_delay, + "usage": session.usage, + }, ensure_ascii=False), file=sys.stderr, flush=True) + finally: + client.close() + except SampleError: + raise + except Exception as error: + # Transport exceptions can include authorization headers or response bodies. + raise SampleError( + f"ASR failed ({type(error).__name__}). Check network, SDK credentials, " + "model/region access, audio device/output availability and --timeout. " + "Output files may be incomplete." + ) from None + + +def wav_frames(audio): + remaining = audio.getnframes() + block_frames = audio.getframerate() // 10 + while remaining: + count = min(block_frames, remaining) + pcm = audio.readframes(count) + if len(pcm) != count * 2: + raise SampleError("Truncated WAV: fewer PCM bytes than declared frames.") + yield pcm + remaining -= count + if audio.readframes(1): + raise SampleError("Invalid WAV: PCM data is not aligned to 16-bit frames.") + + +def validate_wav_data(audio): + for _ in wav_frames(audio): + pass + audio.rewind() + + +def wait_until(deadline, receiver): + check_receiver(receiver) + delay = deadline - time.monotonic() + if delay > 0: + time.sleep(delay) + check_receiver(receiver) + + +def send_wav(audio, session, receiver): + deadline = time.monotonic() + for pcm in wav_frames(audio): + wait_until(deadline, receiver) + deadline = time.monotonic() + len(pcm) / (2 * audio.getframerate()) + session.send_audio_frame(pcm) + wait_until(deadline, receiver) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=( + "WebSocket duplex ASR with Qwen-Audio 3.1 streaming by default. " + "Send a mono, 16-bit, 16 kHz PCM WAV while receiving text; not HTTP. " + "WAV headers are removed and audio is paced in 100 ms frames. " + "Uses existing SDK credentials. Final sentences go to stdout and " + "the transcript; request ID, delays (ms) and usage go to stderr." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "Run from the repository root:\n" + " python -m pip install -e .\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_websocket speech.wav " + "--transcript speech.txt\n" + " python -m examples.01_atomic_capabilities.audio.direct_call.asr_websocket speech.wav " + "--model fun-asr-realtime --transcript fun.txt --partials\n\n" + "Existing output files are never replaced. Failed runs may leave " + "partial files.\n" + "This example uses 16 kHz for Qwen-Audio and Fun-ASR; " + "Paraformer also accepts 8 kHz. No resampling is performed." + ), + ) + parser.add_argument("file", type=Path, help="local uncompressed PCM WAV file") + add_recognition_arguments(parser) + args = parser.parse_args(argv) + try: + rates = (8000, 16000) if args.model == "paraformer-realtime-v2" else (16000,) + audio = open_wav(args.file, sample_rates=rates) + try: + validate_wav_data(audio) + transcript = args.transcript.open("x", encoding="utf-8") + try: + def send_audio(session, receiver): + send_wav(audio, session, receiver) + + run_recognition(args, audio.getframerate(), transcript, send_audio) + finally: + transcript.close() + finally: + audio.close() + return 0 + except KeyboardInterrupt: + print("error: interrupted; output may be incomplete.", file=sys.stderr) + return 130 + except EOFError: + print("error: invalid or truncated WAV header.", file=sys.stderr) + return 1 + except Exception as error: + print(f"error: {str(error) or type(error).__name__}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/01_atomic_capabilities/audio/direct_call/connection_reuse.py b/examples/01_atomic_capabilities/audio/direct_call/connection_reuse.py new file mode 100644 index 00000000..b8980a28 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/connection_reuse.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +"""Run five sequential ASR or TTS tasks on one real WebSocket connection.""" + +import argparse +import json +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from threading import Event, Timer + +from dashscope import Client +from ._common import open_wav, positive_seconds, wav_output +from .asr_websocket import ( + check_receiver, + consume_results, + send_wav, + validate_wav_data, +) +from .tts_websocket import receive_audio + + +RUNS = 5 + + +def completed_task(session): + task_id = session.session.task_id + header = (session.last_response or {}).get("header", {}) + if header.get("event") != "task-finished" or header.get("task_id") != task_id: + raise RuntimeError("任务未收到匹配的 task-finished,停止复用连接") + return { + "task_id": task_id, + "request_id": session.session.request_id, + "final_event": header["event"], + "first_package_delay_ms": session.first_package_delay, + "usage": session.usage, + } + + +def recognize_one(client, connection, args, source, index): + source.rewind() + path = args.output_dir / f"asr-{index:02d}.txt" + transcript = path.open("x+", encoding="utf-8") + try: + session = client.audio.asr.recognize( + model=args.model, format="pcm", sample_rate=16000, + connection=connection, timeout=args.timeout, + ) + executor = None + try: + session.start() + print(f"ASR {index}/{RUNS}: task_id={session.session.task_id}", flush=True) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + consume_results, session, transcript, args.partials, + ) + send_wav(source, session, receiver) + check_receiver(receiver) + session.stop() + receiver.result(timeout=args.timeout) + result = completed_task(session) + result["last_package_delay_ms"] = session.last_package_delay + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + transcript.seek(0) + text = transcript.read().strip() + if not text: + raise RuntimeError("没有最终转写,请使用包含清晰语音的 WAV") + finally: + transcript.close() + result.update(transcript=path.name, text=text) + return result + + +def synthesize_one(client, connection, args, index): + path = args.output_dir / f"tts-{index:02d}.wav" + events_path = args.output_dir / f"tts-{index:02d}-events.jsonl" + text = f"第{index}次。{args.text}" + raw, output = wav_output(path, 24000) + try: + events = events_path.open("x", encoding="utf-8") + try: + session = client.audio.tts.connect( + model=args.model, voice=args.voice, format="pcm", sample_rate=24000, + connection=connection, timeout=args.timeout, + ) + executor = None + try: + session.start() + print( + f"TTS {index}/{RUNS}: task_id={session.session.task_id}", + flush=True, + ) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit(receive_audio, session, output, events) + session.send_text(text) + session.stop() + size = receiver.result(timeout=args.timeout) + result = completed_task(session) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + finally: + events.close() + finally: + try: + output.close() + finally: + raw.close() + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + result.update(audio=path.name, events=events_path.name, input_text=text, + audio_bytes=size, audio_seconds=size / 48000) + return result + + +def checkpoint(output, data): + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def make_parser(): + parser = argparse.ArgumentParser( + description="在一条真实 WebSocket 上顺序执行 5 次 ASR 或 TTS,不自动重连。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.connection_reuse asr " + "tests/data/asr_example.wav --output-dir reuse-asr\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.connection_reuse tts " + "--output-dir reuse-tts\n" + "ASR 与 TTS 分别运行,各创建一条连接;不是混合并发测试。" + "使用已配置的 SDK 凭据,真实调用可能计费。不会录音或播放。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--output-dir", type=Path, required=True, + help="新的输出目录,父目录必须存在,不覆盖已有目录") + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="握手、任务接收及结束输入后等待超时秒数,默认 30") + common.add_argument("--task-timeout", type=positive_seconds, default=120.0, + help="每轮任务总预算秒数,超时关闭整条连接,默认 120") + common.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + common.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="kind", required=True) + asr = commands.add_parser("asr", parents=[common], help="同一 WAV 识别 5 次") + asr.add_argument("file", type=Path, help="16 kHz、16-bit、单声道 PCM WAV") + asr.add_argument("--model", default="qwen-audio-3.1-asr-flash-streaming", + choices=("qwen-audio-3.1-asr-flash-streaming",)) + asr.add_argument("--partials", action="store_true", help="显示临时识别文本") + tts = commands.add_parser("tts", parents=[common], help="带轮次编号的文本合成 5 次") + tts.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash",)) + tts.add_argument("--voice", default="longanhuan_v3.1", help="与 TTS 模型匹配的音色") + tts.add_argument("--text", default="你好,这是连接复用测试。", + help="每轮自动在文本前加“第N次。”") + return parser + + +def run_tasks(args, source=None): + args.output_dir.mkdir() + output = (args.output_dir / "summary.json").open("x", encoding="utf-8") + try: + data = {"kind": args.kind, "model": args.model, "runs_per_connection": RUNS, + "status": "running", "connection_count": 0, "runs": []} + checkpoint(output, data) + try: + client = Client( + region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0, + ) + try: + data.update( + region=client.config.region, workspace=client.config.workspace, + ) + connection = client.ws_connection(timeout=args.timeout, module="audio") + try: + data["connection_count"] = 1 + checkpoint(output, data) + for index in range(1, RUNS + 1): + expired = Event() + + def abort_task(): + expired.set() + connection.close() + + watchdog = Timer(args.task_timeout, abort_task) + watchdog.daemon = True + watchdog.start() + try: + if args.kind == "asr": + result = recognize_one( + client, connection, args, source, index, + ) + else: + result = synthesize_one( + client, connection, args, index, + ) + finally: + watchdog.cancel() + watchdog.join() + if expired.is_set(): + raise TimeoutError("单轮任务超过 --task-timeout") + if connection.closed: + raise RuntimeError("共享连接已断开,停止后续任务") + if any(item["task_id"] == result["task_id"] + for item in data["runs"]): + raise RuntimeError("不同轮次使用了相同 task_id") + result.update(index=index, connection_open_after_task=True) + data["runs"].append(result) + checkpoint(output, data) + print(json.dumps(result, ensure_ascii=False), flush=True) + finally: + connection.close() + data["connection_closed"] = connection.closed + finally: + client.close() + data["status"] = "completed" + except BaseException as error: + data.update(status="failed", error_type=type(error).__name__) + raise + finally: + checkpoint(output, data) + finally: + output.close() + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.kind == "asr": + source = open_wav(args.file, (16000,)) + try: + validate_wav_data(source) + run_tasks(args, source) + finally: + source.close() + else: + if not args.text.strip(): + parser.error("--text 不能为空") + run_tasks(args) + print(f"完成:1 条连接,{RUNS} 个独立任务;{args.output_dir.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print("\n已取消并关闭连接;保留已完成结果。", file=sys.stderr) + sys.exit(130) + except Exception as error: + print(f"复用测试失败({type(error).__name__});保留已有结果,不自动重试。", + file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py new file mode 100644 index 00000000..9651e402 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +"""One Omni 3.8 spoken turn from a WAV file or microphone, saved as a WAV reply.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + stream = microphone(16000, args.device) + try: + stream.start() + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + finally: + try: + stream.stop() + finally: + stream.close() + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Omni 3.8 单轮语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_omni --wav input.wav " + "--output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_omni --microphone " + "--seconds 10 --output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "使用手动结束单轮输入模式,不启用独立 Qwen-ASR/Qwen-TTS。" + "Omni 3.8 协议已适配,但本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanlingxin", help="音色,默认 longanlingxin") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + raw, output = wav_output(args.output, 24000) + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + session = client.audio.realtime.connect( + model="qwen3.8-omni-flash-realtime", + ) + executor = None + try: + session.connect() + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], + voice=args.voice, + input_audio_config={"type": "pcm", "sample_rate": 16000}, + output_audio_config={"type": "pcm", "sample_rate": 24000}, + enable_input_audio_transcription=False, + turn_detection=None, + instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + source = open_wav(args.wav, (16000,)) + try: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + finally: + source.close() + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py new file mode 100644 index 00000000..55bb909a --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +"""Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + stream = microphone(16000, args.device) + try: + stream.start() + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + finally: + try: + stream.stop() + finally: + stream.close() + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_qwen_audio --wav input.wav " + "--output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_qwen_audio --microphone " + "--seconds 10 --output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "固定使用 qwen-audio-3.0-realtime-plus,手动结束单轮输入。" + "本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanqian", help="音色,默认 longanqian") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。", + help="Qwen-Audio 回答指令") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region", help="服务地域") + parser.add_argument("--workspace", help="工作空间 ID") + return parser + + +def run_turn(args, source=None): + raw, output = wav_output(args.output, 24000) + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + session = client.audio.realtime.connect( + model="qwen-audio-3.0-realtime-plus", + ) + executor = None + try: + session.connect() + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], voice=args.voice, + input_audio_format="pcm", output_audio_format="pcm", + enable_turn_detection=False, instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + source = open_wav(args.wav, (16000,)) + try: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + finally: + source.close() + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py new file mode 100644 index 00000000..4db13ed7 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +"""Translate speech with Qwen3.8-LiveTranslate, saving the spoken translation.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + responses = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n译文:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n原文:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") not in (None, "completed"): + raise RuntimeError(f"翻译未正常完成:{response.get('status')}") + responses += 1 + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + if target == "session.finished" and not responses: + raise RuntimeError("会话结束,但未收到翻译响应结束事件") + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本次总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + stream = microphone(16000, args.device) + try: + stream.start() + print(f"正在录音翻译,{args.seconds:g} 秒后结束;Ctrl+C 停止输入。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本次总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + pcm = bytes(audio) + if len(pcm) != count * 2: + raise RuntimeError("麦克风返回的 PCM 帧不完整") + session.append_audio(pcm) + sent += len(pcm) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待剩余译文。") + finally: + try: + stream.stop() + finally: + stream.close() + if sent < 3200: + raise ValueError("本次至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen3.8-LiveTranslate:边输入边翻译,保存译文音频后播放。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_translation --wav input.wav " + "--language en --output translation.wav\n" + "python -m examples.01_atomic_capabilities.audio.direct_call.realtime_translation --microphone " + "--seconds 5 --language en --output translation.wav\n" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。" + "使用服务端默认说话人检测;示例未进行云端验证,实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--language", default="en", help="目标语种代码,默认 en(英语)") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="本次翻译总预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + raw, output = wav_output(args.output, 24000) + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + session = client.audio.realtime.connect( + model="qwen3.8-livetranslate-flash-realtime", + ) + executor = None + try: + session.connect() + deadline = time.monotonic() + args.turn_timeout + session.update_session( + output_modalities=["text", "audio"], + translation_params={"language": args.language}, + ) + receive_until(session, "session.updated", deadline) + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit( + receive_until, session, "session.finished", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + # Drain all translations, not just the first response.done. + session.end_session() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + try: + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + if not size or size % 2: + raise RuntimeError(f"翻译音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if not args.language.strip(): + parser.error("--language 需要非空目标语种代码") + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + source = open_wav(args.wav, (16000,)) + try: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + finally: + source.close() + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;翻译文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"翻译音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"翻译失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/tts_http.py b/examples/01_atomic_capabilities/audio/direct_call/tts_http.py new file mode 100644 index 00000000..4f76ad6b --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/tts_http.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +"""HTTP TTS: synthesize once, download raw PCM, then save and play one WAV.""" + +import argparse +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def https_audio_url(url): + """Validate the signed URL; change only an HTTP scheme to HTTPS.""" + if not isinstance(url, str) or not url: + raise SampleError("服务未返回音频下载地址") + if any(ord(char) <= 32 or ord(char) == 127 for char in url): + raise SampleError("音频下载地址包含非法空白或控制字符") + try: + parts = urlsplit(url) + valid = ( + parts.scheme in ("http", "https") + and bool(parts.hostname) + and parts.username is None + and parts.password is None + and "#" not in url + and "\\" not in url + ) + # Validate a supplied port without changing the signed URL. + _ = parts.port + except ValueError: + raise SampleError("音频下载地址无效") from None + if not valid: + raise SampleError("音频下载地址必须为 HTTP(S),有主机且无 userinfo/fragment") + if parts.scheme == "http": + return "https" + url[len(parts.scheme):] + return url + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP 单次语音合成:下载原始 PCM,保存 WAV 后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.tts_http " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成及下载的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + # Reserve the output exclusively before making any billable request. + raw, output = wav_output(args.output, args.sample_rate) + try: + client = Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) + try: + result = client.audio.tts.http.synthesize( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + ) + if result.is_error: + raise SampleError("服务返回合成错误") + print("request_id:", result.request_id, "usage:", result.usage) + url = https_audio_url(result.audio_url) + # No SDK credentials on downloads; redirects cannot downgrade HTTPS. + downloader = httpx.Client(timeout=args.timeout, follow_redirects=False) + try: + request = downloader.build_request("GET", url) + response = downloader.send(request, stream=True) + try: + if not 200 <= response.status_code < 300: + raise SampleError( + f"音频下载失败(HTTP {response.status_code};不跟随重定向)" + ) + for data in response.iter_bytes(): + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + finally: + response.close() + finally: + downloader.close() + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/tts_http_stream.py b/examples/01_atomic_capabilities/audio/direct_call/tts_http_stream.py new file mode 100644 index 00000000..4cb85828 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/tts_http_stream.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""HTTP SSE TTS: stream decoded PCM into one WAV, then play after EOF.""" + +import argparse +import sys +from pathlib import Path + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP SSE 语音合成:逐块保存 PCM WAV,完整结束后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.tts_http_stream " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成流的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + finished = False + # Reserve the output exclusively before making any billable request. + raw, output = wav_output(args.output, args.sample_rate) + try: + client = Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) + try: + chunks = client.audio.tts.http.stream( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + ) + try: + for chunk in chunks: + if chunk.is_error: + raise SampleError("服务返回流式合成错误") + # Drain after stop so later stream errors remain failures. + finished = finished or chunk.is_final + if chunk.is_final: + print("request_id:", chunk.request_id, "usage:", chunk.usage) + data = chunk.audio + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + finally: + chunks.close() + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not finished: + raise SampleError("SSE 流缺少 finish_reason=stop 结束标记") + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + finally: + client.close() + finally: + try: + output.close() + finally: + raw.close() + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py b/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py new file mode 100644 index 00000000..aa89a8ea --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex TTS: send text while receiving audio into one WAV.""" + +import argparse +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_audio(session, output, events): + size = 0 + for frame in session.events(): + if isinstance(frame, bytes): + output.writeframesraw(frame) + size += len(frame) + elif events is not None: + events.write(json.dumps(frame, ensure_ascii=False) + "\n") + events.flush() + return size + + +def synthesize(client, args, parts, output, events): + options = { + "model": args.model, + "voice": args.voice, + "format": "pcm", + "sample_rate": args.sample_rate, + "timeout": args.timeout, + "enable_ssml": args.ssml, + } + if args.instruction: + options["parameters"] = {"instruction": args.instruction} + + executor = None + session = client.audio.tts.connect(**options) + try: + session.start() + executor = ThreadPoolExecutor(max_workers=1) + receiver = executor.submit(receive_audio, session, output, events) + for part in parts: + if receiver.done(): + receiver.result() + raise RuntimeError("文本尚未发送完,合成会话已结束") + if part: + session.send_text(part) + session.stop() + size = receiver.result(timeout=args.timeout) + print("request_id:", session.session.request_id) + print("首包延迟(ms):", session.first_package_delay) + print("usage:", session.usage) + return size + finally: + try: + # Close before executor shutdown to unblock its socket reader. + session.close() + finally: + if executor is not None: + executor.shutdown(wait=True) + + +def make_parser(): + parser = argparse.ArgumentParser( + description="WebSocket 双工流式合成:边发送文本边接收音频,保存 WAV 后播放。", + epilog=( + "默认 qwen-audio-3.1-tts-flash,推荐音色 longanhuan_v3.1。\n" + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.direct_call.tts_websocket " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "按行发送:python -m examples.01_atomic_capabilities.audio.direct_call.tts_websocket " + "--text-file article.txt --output article.wav --events events.jsonl\n" + "先安装 python -m pip install -e .,配置 DASHSCOPE_API_KEY、" + "DASHSCOPE_WORKSPACE 和模型对应地域。真实服务可能计费。" + "不调用独立 qwen-asr、旧 qwen-tts 或 Sambert。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", action="append", help="文本;可重复传入多个片段") + source.add_argument("--text-file", type=Path, help="UTF-8 文本,按行发送") + source.add_argument("--prompt", help="实时朗读大语言模型生成的回答,额外计费") + parser.add_argument("--llm-model", default="qwen-plus", help="--prompt 的文本模型") + parser.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", + "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus")) + parser.add_argument("--voice", + help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan;" + "复刻音色须同时指定创建时的 --model 和 --voice voice_id") + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--events", type=Path, help="原始事件 JSONL 文件") + parser.add_argument("--ssml", action="store_true", help="按所选模型的 SSML 规则合成") + parser.add_argument("--instruction", help="模型支持时使用的合成指令") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="socket 超时及输入结束后的等待秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.voice is None: + args.voice = { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if args.prompt is not None: + if not args.prompt.strip(): + parser.error("--prompt 需要非空提示词") + if args.ssml: + parser.error("--prompt 输出为普通文本,不能与 --ssml 一起使用") + if not args.no_play: + player_command(args.output) + source = args.text_file.open(encoding="utf-8") if args.text_file else None + try: + if source is not None: + if not any(line.strip() for line in source): + parser.error("文本文件不能为空") + source.seek(0) + parts = source + else: + parts = args.text or [] + if not args.prompt and not any(part.strip() for part in parts): + parser.error("文本不能为空") + raw, output = wav_output(args.output, args.sample_rate) + try: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) + try: + if args.prompt: + stream = client.chat.completions.create( + model=args.llm_model, + messages=[{"role": "user", "content": args.prompt}], + stream=True, + parameters={"incremental_output": True}, + ) + try: + size = synthesize( + client, args, stream.text_stream, output, events, + ) + finally: + stream.close() + else: + size = synthesize(client, args, parts, output, events) + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + finally: + client.close() + finally: + if events is not None: + events.close() + finally: + try: + output.close() + finally: + raw.close() + finally: + if source is not None: + source.close() + print(f"已保存 {args.output.resolve()}({size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py b/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py new file mode 100644 index 00000000..a979bd95 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +"""Create and manage Qwen-Audio and CosyVoice clones through the V2 public API.""" + +import argparse +import json +import re +import shlex +import sys +import time +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +MODELS = ( + "qwen-audio-3.0-tts-flash", "cosyvoice-v3-flash", "cosyvoice-v3-plus", +) + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use a service-reachable HTTPS audio URL without userinfo or fragments, " + "not a local path. Upload owned/authorized speaker audio yourself; " + "this sample never uploads it." + ) + return value + + +def voice_prefix(value): + if not re.fullmatch(r"[a-z0-9]{1,10}", value): + raise argparse.ArgumentTypeError("prefix must be 1-10 lowercase letters/digits") + return value + + +def voice_id(value): + if not value or any(char.isspace() for char in value): + raise argparse.ArgumentTypeError( + "voice ID must be nonempty and contain no spaces" + ) + return value + + +def checkpoint(output, data, response): + data["result"] = response.model_dump(mode="json", exclude={"headers"}, + exclude_none=True) + if output is not None: + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def connection_options(client): + options = [] + if client.config.region: + options.extend(["--region", client.config.region]) + if client.config.workspace: + options.extend(["--workspace", client.config.workspace]) + return options + + +def wait_ready(client, args, identifier, output, data): + deadline = time.monotonic() + args.wait_timeout + previous_status = None + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + "Voice is not ready before --wait-timeout; use get to recover" + ) + voice = client.audio.voices.get( + identifier, timeout=min(args.timeout, remaining) + ) + checkpoint(output, data, voice) + if voice.status != previous_status: + print(f"Voice status: {voice.status}", flush=True) + previous_status = voice.status + if voice.status == "OK": + return + if voice.status != "DEPLOYING": + raise RuntimeError( + f"Voice is unavailable or has unsupported status: {voice.status}" + ) + # Only DEPLOYING is retryable; never poll UNDEPLOYED or unknown states. + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(2.0, remaining)) + + +def create_voice(client, args, output, data): + print("Use only your own or explicitly authorized speaker audio. " + "Creating a clone may incur charges; it will NOT be automatically deleted.", + file=sys.stderr, flush=True) + voice = client.audio.voices.create( + target_model=args.model, prefix=args.prefix, url=args.url, + ) + identifier = voice.voice_id + if not identifier: + checkpoint(output, data, voice) + raise RuntimeError( + "No voice ID returned; use list before attempting another create" + ) + print(f"voice_id: {identifier}", flush=True) + data.update(voice_id=identifier, target_model=args.model) + checkpoint(output, data, voice) + print("Recover without creating again: " + shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.direct_call.voice_clone", "get", + "--voice-id", identifier, *connection_options(client), + ]), flush=True) + wait_ready(client, args, identifier, output, data) + print("Ready. Synthesize separately (same model; may incur charges):") + print(shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.direct_call.tts_websocket", + "--model", args.model, + "--voice", identifier, "--text", "Hello, this is my cloned voice.", + "--output", "cloned.wav", "--no-play", *connection_options(client), + ]), flush=True) + + +def make_parser(): + parser = argparse.ArgumentParser( + description=( + "Qwen-Audio / CosyVoice voice cloning; no synthesis, playback, " + "or auto-deletion." + ), + epilog=( + "Default target: qwen-audio-3.0-tts-flash (voice-enrollment API).\n" + "Create: python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone create " + "--url \"$AUDIO_URL\" --output new-voice.json\n" + "Recover: python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone get " + "--voice-id VOICE_ID\n" + "List: python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone list --prefix demo\n" + "Delete only when intended: " + "python -m examples.01_atomic_capabilities.audio.direct_call.voice_clone " + "delete --voice-id VOICE_ID --yes\n" + "Use your configured DASHSCOPE_API_KEY; keep the same region/workspace. " + "Use subcommand --help for options." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--output", type=Path, help="optional NEW .json file; never overwrite" + ) + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="request timeout seconds (default: 30)") + common.add_argument("--region", help="override DASHSCOPE_REGION") + common.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="command", required=True) + create = commands.add_parser( + "create", parents=[common], help="create a clone and wait until OK", + description=( + "Use only owned/authorized speaker audio at a service-reachable HTTPS URL. " + "Upload it yourself; this sample never uploads local files. Recommended: " + "10-20 seconds, WAV (16-bit)/MP3/M4A, >=16 kHz, <=10 MB, " + "at most 60 seconds. " + "Include at least 5 seconds of continuous clear speech, without music " + "or other speakers. Only the first channel is used for stereo audio. " + "The voice persists, including after timeout or interruption." + ), + ) + create.add_argument("--url", type=https_url, required=True) + create.add_argument("--prefix", type=voice_prefix, default="demo", + help="1-10 lowercase letters/digits (default: demo)") + create.add_argument("--model", choices=MODELS, default="qwen-audio-3.0-tts-flash", + help="target TTS model (default: qwen-audio-3.0-tts-flash); " + "synthesis must use this same version") + create.add_argument("--wait-timeout", type=positive_seconds, default=300.0, + help="maximum readiness polling seconds (default: 300)") + get = commands.add_parser("get", parents=[common], + help="query once; exit nonzero unless the voice is OK") + get.add_argument("--voice-id", type=voice_id, required=True) + listing = commands.add_parser( + "list", parents=[common], help="list one page of clones" + ) + listing.add_argument("--prefix", type=voice_prefix) + listing.add_argument( + "--page-index", type=int, default=0, help="zero-based page index" + ) + listing.add_argument("--page-size", type=int, default=10) + delete = commands.add_parser( + "delete", parents=[common], help="explicitly delete one clone" + ) + delete.add_argument("--voice-id", type=voice_id, required=True) + delete.add_argument("--yes", action="store_true", help="confirm permanent deletion") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.command == "delete" and not args.yes: + parser.error("delete requires --yes; nothing has been deleted") + if args.command == "list" and (args.page_index < 0 or args.page_size <= 0): + parser.error("--page-index must be >= 0 and --page-size must be > 0") + if args.output and args.output.suffix.lower() != ".json": + parser.error("--output must be a new .json file") + # Open exclusively before any request can create a billable resource. + output = args.output.open("x", encoding="utf-8") if args.output else None + try: + client = Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, + ) + try: + data = {"action": args.command, "region": client.config.region, + "workspace": client.config.workspace} + if args.command == "create": + create_voice(client, args, output, data) + elif args.command == "get": + voice = client.audio.voices.get(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, voice) + print(f"voice_id: {args.voice_id}; status: {voice.status}", flush=True) + if voice.status != "OK": + print( + "Voice is not ready; do not synthesize with it yet.", + file=sys.stderr, + ) + return 1 + elif args.command == "list": + voices = client.audio.voices.list( + prefix=args.prefix, page_index=args.page_index, + page_size=args.page_size, + ) + data.update(page_index=args.page_index, page_size=args.page_size) + checkpoint(output, data, voices) + print(f"Page {args.page_index}: {len(voices.voices)} voice(s)") + for voice in voices.voices: + print( + f"voice_id: {voice.get('voice_id')}; " + f"status: {voice.get('status')}" + ) + else: + result = client.audio.voices.delete(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, result) + print(f"Deleted {args.voice_id}; request_id: {result.request_id}") + finally: + client.close() + finally: + if output is not None: + output.close() + if args.output: + print(f"Saved {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print( + "\nInterrupted; no automatic deletion. Recover with get/list, not create.", + file=sys.stderr, + ) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + # Do not expose signed source/preview URLs through exception messages. + print(f"Request failed ({type(error).__name__}). Use get/list to recover; " + "existing clones and saved results are retained.", file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print( + f"Voice operation failed: {error}. No automatic deletion.", file=sys.stderr + ) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/realtime_omni.py b/examples/01_atomic_capabilities/audio/realtime_omni.py new file mode 100644 index 00000000..9d7b72e4 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/realtime_omni.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +"""One Omni 3.8 spoken turn from a WAV file or microphone, saved as a WAV reply.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + with microphone(16000, args.device) as stream: + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Omni 3.8 单轮语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.realtime_omni --wav input.wav --output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.realtime_omni --microphone --seconds 10 " + "--output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "使用手动结束单轮输入模式,不启用独立 Qwen-ASR/Qwen-TTS。" + "Omni 3.8 协议已适配,但本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanlingxin", help="音色,默认 longanlingxin") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + with wav_output(args.output, 24000) as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + with client.audio.realtime.connect( + model="qwen3.8-omni-flash-realtime", + ) as session: + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], + voice=args.voice, + input_audio_config={"type": "pcm", "sample_rate": 16000}, + output_audio_config={"type": "pcm", "sample_rate": 24000}, + enable_input_audio_transcription=False, + turn_detection=None, + instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + try: + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + session.close() + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + with open_wav(args.wav, (16000,)) as source: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py b/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py new file mode 100644 index 00000000..6e4406d7 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +"""Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n完整回答:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n输入转写:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") != "completed": + raise RuntimeError(f"回答未正常完成:{response.get('status')}") + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本轮总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + with microphone(16000, args.device) as stream: + print(f"正在录音,{args.seconds:g} 秒后结束;Ctrl+C 提前结束本轮。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本轮总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + session.append_audio(bytes(audio)) + sent += len(audio) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待回答。") + if sent < 3200: + raise ValueError("本轮至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.realtime_qwen_audio --wav input.wav " + "--output reply.wav\n" + "python -m examples.01_atomic_capabilities.audio.realtime_qwen_audio --microphone --seconds 10 " + "--output reply.wav\n" + "安装源码:python -m pip install -e '.[acli-voice]'。" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。\n" + "固定使用 qwen-audio-3.0-realtime-plus,手动结束单轮输入。" + "本示例未进行云端验证;实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--voice", default="longanqian", help="音色,默认 longanqian") + parser.add_argument("--instructions", default="请用简洁自然的中文回答。", + help="Qwen-Audio 回答指令") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存回答,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="整轮等待预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region", help="服务地域") + parser.add_argument("--workspace", help="工作空间 ID") + return parser + + +def run_turn(args, source=None): + with wav_output(args.output, 24000) as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + with client.audio.realtime.connect( + model="qwen-audio-3.0-realtime-plus", + ) as session: + deadline = time.monotonic() + args.turn_timeout + session.update_session( + ["text", "audio"], voice=args.voice, + input_audio_format="pcm", output_audio_format="pcm", + enable_turn_detection=False, instructions=args.instructions, + ) + receive_until(session, "session.updated", deadline) + with ThreadPoolExecutor(max_workers=1) as executor: + try: + receiver = executor.submit( + receive_until, session, "response.done", deadline, output, + ) + send_input(session, receiver, args, source, deadline) + session.commit() + session.create_response() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + session.close() + if not size or size % 2: + raise RuntimeError(f"回答音频为空或不完整({size} 字节)") + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + with open_wav(args.wav, (16000,)) as source: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的单轮 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;回答文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"回答音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"对话失败:{error};已创建的回答文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/realtime_translation.py b/examples/01_atomic_capabilities/audio/realtime_translation.py new file mode 100644 index 00000000..186f9df2 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/realtime_translation.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +"""Translate speech with Qwen3.8-LiveTranslate, saving the spoken translation.""" + +import argparse +import base64 +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + input_device, + load_sounddevice, + microphone, + open_wav, + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_until(session, target, deadline, output=None): + size = 0 + responses = 0 + while time.monotonic() < deadline: + event = session.recv_event() + if event is None: + raise RuntimeError(f"连接已关闭,未收到 {target}") + kind = event.get("type") + if kind == "response.audio.delta" and output is not None: + audio = base64.b64decode(event["delta"], validate=True) + output.writeframesraw(audio) + size += len(audio) + elif kind in ("response.text.delta", "response.audio_transcript.delta"): + print(event.get("delta", ""), end="", flush=True) + elif kind in ("response.text.done", "response.audio_transcript.done"): + print("\n译文:", event.get("text", event.get("transcript", ""))) + elif kind == "conversation.item.input_audio_transcription.completed": + print("\n原文:", event.get("transcript", "")) + elif kind == "response.done": + response = event.get("response") or {} + if response.get("status") not in (None, "completed"): + raise RuntimeError(f"翻译未正常完成:{response.get('status')}") + responses += 1 + print("\nresponse_id:", response.get("id")) + print("usage:", response.get("usage")) + if kind == target: + if target == "session.finished" and not responses: + raise RuntimeError("会话结束,但未收到翻译响应结束事件") + return size + raise TimeoutError(f"等待 {target} 超过总时限") + + +def send_input(session, receiver, args, source, turn_deadline): + sent = 0 + if source is not None: + deadline = time.monotonic() + while True: + if time.monotonic() >= turn_deadline: + raise TimeoutError("发送音频超过本次总时限") + audio = source.readframes(1600) + if not audio: + break + if receiver.done(): + receiver.result() + raise RuntimeError("输入尚未结束,接收线程已退出") + session.append_audio(audio) + sent += len(audio) + deadline += len(audio) / 32000 + time.sleep(max(0, deadline - time.monotonic())) + if sent != source.getnframes() * 2: + raise ValueError("WAV 数据被截断,实际音频长度小于文件头声明") + else: + with microphone(16000, args.device) as stream: + print(f"正在录音翻译,{args.seconds:g} 秒后结束;Ctrl+C 停止输入。") + remaining = round(args.seconds * 16000) + try: + while remaining > 0: + if time.monotonic() >= turn_deadline: + raise TimeoutError("录音超过本次总时限") + if receiver.done(): + receiver.result() + raise RuntimeError("录音尚未结束,接收线程已退出") + count = min(1600, remaining) + audio, overflow = stream.read(count) + if overflow: + raise RuntimeError("麦克风输入溢出,音频已丢失") + pcm = bytes(audio) + if len(pcm) != count * 2: + raise RuntimeError("麦克风返回的 PCM 帧不完整") + session.append_audio(pcm) + sent += len(pcm) + remaining -= count + except KeyboardInterrupt: + print("\n录音已结束,正在等待剩余译文。") + if sent < 3200: + raise ValueError("本次至少需要 0.1 秒音频") + + +def make_parser(): + parser = argparse.ArgumentParser( + description="Qwen3.8-LiveTranslate:边输入边翻译,保存译文音频后播放。", + epilog=( + "python -m examples.01_atomic_capabilities.audio.realtime_translation --wav input.wav " + "--language en --output translation.wav\n" + "python -m examples.01_atomic_capabilities.audio.realtime_translation --microphone --seconds 5 " + "--language en --output translation.wav\n" + "配置 DASHSCOPE_API_KEY / DASHSCOPE_WORKSPACE / DASHSCOPE_REGION。" + "使用服务端默认说话人检测;示例未进行云端验证,实际调用可能计费。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--wav", type=Path, help="0.1~20 秒、16 kHz/16-bit/单声道 WAV") + source.add_argument("--microphone", action="store_true", help="实际开启系统麦克风") + parser.add_argument("--seconds", type=positive_seconds, default=10.0, + help="录音时长,0.1~20 秒,默认 10") + parser.add_argument("--device", type=input_device, help="录音设备 ID 或名称") + parser.add_argument("--language", default="en", help="目标语种代码,默认 en(英语)") + parser.add_argument("--output", type=Path, required=True, help="新的 24 kHz WAV 文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="单次 socket 操作超时(秒)") + parser.add_argument("--turn-timeout", type=positive_seconds, default=120.0, + help="本次翻译总预算;阻塞操作最多额外耗时 --timeout 秒") + parser.add_argument("--region") + parser.add_argument("--workspace") + return parser + + +def run_turn(args, source=None): + with wav_output(args.output, 24000) as output, Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + with client.audio.realtime.connect( + model="qwen3.8-livetranslate-flash-realtime", + ) as session: + deadline = time.monotonic() + args.turn_timeout + session.update_session( + output_modalities=["text", "audio"], + translation_params={"language": args.language}, + ) + receive_until(session, "session.updated", deadline) + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = executor.submit( + receive_until, session, "session.finished", deadline, output, + ) + try: + send_input(session, receiver, args, source, deadline) + # Drain all translations, not just the first response.done. + session.end_session() + size = receiver.result(timeout=max(0, deadline - time.monotonic())) + finally: + session.close() + if not size or size % 2: + raise RuntimeError(f"翻译音频为空或不完整({size} 字节)") + return size + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if not args.language.strip(): + parser.error("--language 需要非空目标语种代码") + if args.microphone and not 0.1 <= args.seconds <= 20: + parser.error("--seconds 应在 0.1~20 之间") + if not args.no_play: + player_command(args.output) + if args.wav: + with open_wav(args.wav, (16000,)) as source: + duration = source.getnframes() / 16000 + if not 0.1 <= duration <= 20: + parser.error("本示例的 WAV 输入限制为 0.1~20 秒") + size = run_turn(args, source) + else: + load_sounddevice().check_input_settings( + device=args.device, channels=1, dtype="int16", samplerate=16000, + ) + size = run_turn(args) + print(f"已保存 {args.output.resolve()}({size / 48000:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;翻译文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"翻译音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"翻译失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio_speech/tingwu.py b/examples/01_atomic_capabilities/audio/tingwu.py similarity index 100% rename from examples/01_atomic_capabilities/audio_speech/tingwu.py rename to examples/01_atomic_capabilities/audio/tingwu.py diff --git a/examples/01_atomic_capabilities/audio/tts_http.py b/examples/01_atomic_capabilities/audio/tts_http.py new file mode 100644 index 00000000..8c0a1bdf --- /dev/null +++ b/examples/01_atomic_capabilities/audio/tts_http.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""HTTP TTS: synthesize once, download raw PCM, then save and play one WAV.""" + +import argparse +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def https_audio_url(url): + """Validate the signed URL; change only an HTTP scheme to HTTPS.""" + if not isinstance(url, str) or not url: + raise SampleError("服务未返回音频下载地址") + if any(ord(char) <= 32 or ord(char) == 127 for char in url): + raise SampleError("音频下载地址包含非法空白或控制字符") + try: + parts = urlsplit(url) + valid = ( + parts.scheme in ("http", "https") + and bool(parts.hostname) + and parts.username is None + and parts.password is None + and "#" not in url + and "\\" not in url + ) + # Validate a supplied port without changing the signed URL. + _ = parts.port + except ValueError: + raise SampleError("音频下载地址无效") from None + if not valid: + raise SampleError("音频下载地址必须为 HTTP(S),有主机且无 userinfo/fragment") + if parts.scheme == "http": + return "https" + url[len(parts.scheme):] + return url + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP 单次语音合成:下载原始 PCM,保存 WAV 后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.tts_http " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成及下载的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + # Reserve the output exclusively before making any billable request. + with wav_output(args.output, args.sample_rate) as output: + with Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) as client: + result = client.audio.tts.http.synthesize( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + ) + if result.is_error: + raise SampleError("服务返回合成错误") + print("request_id:", result.request_id, "usage:", result.usage) + url = https_audio_url(result.audio_url) + # No SDK credentials on downloads; redirects cannot downgrade HTTPS. + with httpx.Client(timeout=args.timeout, + follow_redirects=False) as downloader: + with downloader.stream("GET", url) as response: + if not 200 <= response.status_code < 300: + raise SampleError( + f"音频下载失败(HTTP {response.status_code};不跟随重定向)" + ) + for data in response.iter_bytes(): + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/tts_http_stream.py b/examples/01_atomic_capabilities/audio/tts_http_stream.py new file mode 100644 index 00000000..3e39cb5a --- /dev/null +++ b/examples/01_atomic_capabilities/audio/tts_http_stream.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""HTTP SSE TTS: stream decoded PCM into one WAV, then play after EOF.""" + +import argparse +import sys +from contextlib import closing +from pathlib import Path + +from dashscope import Client +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +class SampleError(Exception): + """Locally authored errors whose messages contain no signed URLs.""" + + +def make_parser(): + parser = argparse.ArgumentParser( + description="HTTP SSE 语音合成:逐块保存 PCM WAV,完整结束后播放。真实请求可能计费。", + epilog=( + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.tts_http_stream " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "使用 DASHSCOPE_API_KEY、DASHSCOPE_WORKSPACE 和模型对应地域。" + "--no-play 仅保存文件。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", help="待合成的非空文本") + source.add_argument("--text-file", type=Path, help="非空 UTF-8 文本文件") + parser.add_argument( + "--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus"), + ) + parser.add_argument( + "--voice", help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan", + ) + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="合成流的网络超时秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + text = (args.text_file.read_text(encoding="utf-8") + if args.text_file is not None else args.text) + if not text.strip(): + parser.error("合成文本不能为空或仅含空白") + voice = args.voice if args.voice is not None else { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if not args.no_play: + player_command(args.output) + + size = 0 + pending = b"" + header_checked = False + finished = False + # Reserve the output exclusively before making any billable request. + with wav_output(args.output, args.sample_rate) as output: + with Client(region=args.region, workspace=args.workspace, + timeout=args.timeout, max_retries=0) as client: + with closing(client.audio.tts.http.stream( + text, model=args.model, voice=voice, format="pcm", + sample_rate=args.sample_rate, timeout=args.timeout, + )) as chunks: + for chunk in chunks: + if chunk.is_error: + raise SampleError("服务返回流式合成错误") + # Drain after stop so later stream errors remain failures. + finished = finished or chunk.is_final + if chunk.is_final: + print("request_id:", chunk.request_id, "usage:", chunk.usage) + data = chunk.audio + if not data: + continue + if not header_checked: + pending += data + if len(pending) < 12: + continue + if pending[:4] == b"RIFF" and pending[8:12] == b"WAVE": + raise SampleError( + "服务返回 RIFF/WAVE 容器而非原始 PCM;拒绝二次封装" + ) + data, pending = pending, b"" + header_checked = True + output.writeframesraw(data) + size += len(data) + # Very short PCM can legitimately be shorter than a WAV signature. + if pending: + output.writeframesraw(pending) + size += len(pending) + if not finished: + raise SampleError("SSE 流缺少 finish_reason=stop 结束标记") + if not size or size % 2: + raise SampleError(f"返回的 PCM 音频为空或不是偶数字节({size} 字节)") + print(f"已保存 {args.output.resolve()}({size} 字节 PCM," + f"{size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except SampleError as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) + except Exception as error: + # SDK/httpx messages may contain signed URLs; do not print them. + print(f"运行失败:{type(error).__name__};已创建的输出文件可能不完整。" + "播放失败时可使用 --no-play。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/tts_websocket.py b/examples/01_atomic_capabilities/audio/tts_websocket.py new file mode 100644 index 00000000..b2a82593 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/tts_websocket.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +"""WebSocket duplex TTS: send text while receiving audio into one WAV.""" + +import argparse +import json +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from pathlib import Path + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import ( + play_wav, + player_command, + positive_seconds, + wav_output, +) + + +def receive_audio(session, output, events): + size = 0 + for frame in session.events(): + if isinstance(frame, bytes): + output.writeframesraw(frame) + size += len(frame) + elif events is not None: + events.write(json.dumps(frame, ensure_ascii=False) + "\n") + events.flush() + return size + + +def synthesize(client, args, parts, output, events): + options = { + "model": args.model, + "voice": args.voice, + "format": "pcm", + "sample_rate": args.sample_rate, + "timeout": args.timeout, + "enable_ssml": args.ssml, + } + if args.instruction: + options["parameters"] = {"instruction": args.instruction} + + with client.audio.tts.connect(**options) as session: + with ThreadPoolExecutor(max_workers=1) as executor: + receiver = executor.submit(receive_audio, session, output, events) + try: + for part in parts: + if receiver.done(): + receiver.result() + raise RuntimeError("文本尚未发送完,合成会话已结束") + if part: + session.send_text(part) + session.stop() + size = receiver.result(timeout=args.timeout) + print("request_id:", session.session.request_id) + print("首包延迟(ms):", session.first_package_delay) + print("usage:", session.usage) + return size + finally: + # Close before executor shutdown to unblock its socket reader. + session.close() + + +def make_parser(): + parser = argparse.ArgumentParser( + description="WebSocket 双工流式合成:边发送文本边接收音频,保存 WAV 后播放。", + epilog=( + "默认 qwen-audio-3.1-tts-flash,推荐音色 longanhuan_v3.1。\n" + "在仓库根目录执行:python -m examples.01_atomic_capabilities.audio.tts_websocket " + "--text '你好,欢迎使用语音合成。' --output hello.wav\n" + "按行发送:python -m examples.01_atomic_capabilities.audio.tts_websocket " + "--text-file article.txt --output article.wav --events events.jsonl\n" + "先安装 python -m pip install -e .,配置 DASHSCOPE_API_KEY、" + "DASHSCOPE_WORKSPACE 和模型对应地域。真实服务可能计费。" + "不调用独立 qwen-asr、旧 qwen-tts 或 Sambert。" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--text", action="append", help="文本;可重复传入多个片段") + source.add_argument("--text-file", type=Path, help="UTF-8 文本,按行发送") + source.add_argument("--prompt", help="实时朗读大语言模型生成的回答,额外计费") + parser.add_argument("--llm-model", default="qwen-plus", help="--prompt 的文本模型") + parser.add_argument("--model", default="qwen-audio-3.1-tts-flash", + choices=("qwen-audio-3.1-tts-flash", + "qwen-audio-3.0-tts-flash", + "cosyvoice-v3-flash", "cosyvoice-v3-plus")) + parser.add_argument("--voice", + help="3.1 默认 longanhuan_v3.1;3.0 默认 longanhuan_v3.6;" + "CosyVoice 默认 longanhuan;" + "复刻音色须同时指定创建时的 --model 和 --voice voice_id") + parser.add_argument("--sample-rate", type=int, default=24000, + choices=(8000, 16000, 22050, 24000, 44100, 48000)) + parser.add_argument("--output", type=Path, required=True, + help="新的 .wav 文件,不覆盖已有文件") + parser.add_argument("--no-play", action="store_true", help="只保存,不播放") + parser.add_argument("--events", type=Path, help="原始事件 JSONL 文件") + parser.add_argument("--ssml", action="store_true", help="按所选模型的 SSML 规则合成") + parser.add_argument("--instruction", help="模型支持时使用的合成指令") + parser.add_argument("--timeout", type=positive_seconds, default=30.0, + help="socket 超时及输入结束后的等待秒数,默认 30") + parser.add_argument("--region", help="覆盖 DASHSCOPE_REGION") + parser.add_argument("--workspace", help="覆盖 DASHSCOPE_WORKSPACE") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.voice is None: + args.voice = { + "qwen-audio-3.1-tts-flash": "longanhuan_v3.1", + "qwen-audio-3.0-tts-flash": "longanhuan_v3.6", + "cosyvoice-v3-flash": "longanhuan", + "cosyvoice-v3-plus": "longanhuan", + }[args.model] + if args.prompt is not None: + if not args.prompt.strip(): + parser.error("--prompt 需要非空提示词") + if args.ssml: + parser.error("--prompt 输出为普通文本,不能与 --ssml 一起使用") + if not args.no_play: + player_command(args.output) + source = args.text_file.open(encoding="utf-8") if args.text_file else None + try: + if source is not None: + if not any(line.strip() for line in source): + parser.error("文本文件不能为空") + source.seek(0) + parts = source + else: + parts = args.text or [] + if not args.prompt and not any(part.strip() for part in parts): + parser.error("文本不能为空") + with wav_output(args.output, args.sample_rate) as output: + events = args.events.open("x", encoding="utf-8") if args.events else None + try: + with Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + ) as client: + if args.prompt: + with closing(client.chat.completions.create( + model=args.llm_model, + messages=[{"role": "user", "content": args.prompt}], + stream=True, + parameters={"incremental_output": True}, + )) as stream: + size = synthesize( + client, args, stream.text_stream, output, events, + ) + else: + size = synthesize(client, args, parts, output, events) + if not size or size % 2: + raise RuntimeError(f"返回的 PCM 音频为空或不完整({size} 字节)") + finally: + if events is not None: + events.close() + finally: + if source is not None: + source.close() + print(f"已保存 {args.output.resolve()}({size / 2 / args.sample_rate:.2f} 秒)") + if not args.no_play: + play_wav(args.output) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n已取消;输出文件可能不完整。", file=sys.stderr) + sys.exit(130) + except subprocess.SubprocessError as error: + print(f"音频已保存,但播放器失败:{error}", file=sys.stderr) + sys.exit(1) + except (DashScopeError, OSError, ValueError, RuntimeError, TimeoutError) as error: + print(f"合成失败:{error};已创建的输出文件可能不完整。", file=sys.stderr) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio/voice_clone.py b/examples/01_atomic_capabilities/audio/voice_clone.py new file mode 100644 index 00000000..d6ab2b56 --- /dev/null +++ b/examples/01_atomic_capabilities/audio/voice_clone.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +"""Create and manage Qwen-Audio and CosyVoice clones through the V2 public API.""" + +import argparse +import json +import re +import shlex +import sys +import time +from pathlib import Path +from urllib.parse import urlsplit + +import httpx + +from dashscope import Client +from dashscope.v2.errors import DashScopeError +from ._common import positive_seconds + + +MODELS = ( + "qwen-audio-3.0-tts-flash", "cosyvoice-v3-flash", "cosyvoice-v3-plus", +) + + +def https_url(value): + try: + url = urlsplit(value) + valid = ( + url.scheme == "https" and url.hostname and url.port != 0 + and not url.username and not url.password and not url.fragment + and not any(char.isspace() or ord(char) < 32 for char in value) + ) + except ValueError: + valid = False + if not valid: + raise argparse.ArgumentTypeError( + "Use a service-reachable HTTPS audio URL without userinfo or fragments, " + "not a local path. Upload owned/authorized speaker audio yourself; " + "this sample never uploads it." + ) + return value + + +def voice_prefix(value): + if not re.fullmatch(r"[a-z0-9]{1,10}", value): + raise argparse.ArgumentTypeError("prefix must be 1-10 lowercase letters/digits") + return value + + +def voice_id(value): + if not value or any(char.isspace() for char in value): + raise argparse.ArgumentTypeError( + "voice ID must be nonempty and contain no spaces" + ) + return value + + +def checkpoint(output, data, response): + data["result"] = response.model_dump(mode="json", exclude={"headers"}, + exclude_none=True) + if output is not None: + output.seek(0) + json.dump(data, output, ensure_ascii=False, indent=2) + output.write("\n") + output.truncate() + output.flush() + + +def connection_options(client): + options = [] + if client.config.region: + options.extend(["--region", client.config.region]) + if client.config.workspace: + options.extend(["--workspace", client.config.workspace]) + return options + + +def wait_ready(client, args, identifier, output, data): + deadline = time.monotonic() + args.wait_timeout + previous_status = None + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + "Voice is not ready before --wait-timeout; use get to recover" + ) + voice = client.audio.voices.get( + identifier, timeout=min(args.timeout, remaining) + ) + checkpoint(output, data, voice) + if voice.status != previous_status: + print(f"Voice status: {voice.status}", flush=True) + previous_status = voice.status + if voice.status == "OK": + return + if voice.status != "DEPLOYING": + raise RuntimeError( + f"Voice is unavailable or has unsupported status: {voice.status}" + ) + # Only DEPLOYING is retryable; never poll UNDEPLOYED or unknown states. + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(2.0, remaining)) + + +def create_voice(client, args, output, data): + print("Use only your own or explicitly authorized speaker audio. " + "Creating a clone may incur charges; it will NOT be automatically deleted.", + file=sys.stderr, flush=True) + voice = client.audio.voices.create( + target_model=args.model, prefix=args.prefix, url=args.url, + ) + identifier = voice.voice_id + if not identifier: + checkpoint(output, data, voice) + raise RuntimeError( + "No voice ID returned; use list before attempting another create" + ) + print(f"voice_id: {identifier}", flush=True) + data.update(voice_id=identifier, target_model=args.model) + checkpoint(output, data, voice) + print("Recover without creating again: " + shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.voice_clone", "get", "--voice-id", identifier, + *connection_options(client), + ]), flush=True) + wait_ready(client, args, identifier, output, data) + print("Ready. Synthesize separately (same model; may incur charges):") + print(shlex.join([ + "python", "-m", "examples.01_atomic_capabilities.audio.tts_websocket", "--model", args.model, + "--voice", identifier, "--text", "Hello, this is my cloned voice.", + "--output", "cloned.wav", "--no-play", *connection_options(client), + ]), flush=True) + + +def make_parser(): + parser = argparse.ArgumentParser( + description=( + "Qwen-Audio / CosyVoice voice cloning; no synthesis, playback, " + "or auto-deletion." + ), + epilog=( + "Default target: qwen-audio-3.0-tts-flash (voice-enrollment API).\n" + "Create: python -m examples.01_atomic_capabilities.audio.voice_clone create --url \"$AUDIO_URL\" " + "--output new-voice.json\n" + "Recover: python -m examples.01_atomic_capabilities.audio.voice_clone get --voice-id VOICE_ID\n" + "List: python -m examples.01_atomic_capabilities.audio.voice_clone list --prefix demo\n" + "Delete only when intended: python -m examples.01_atomic_capabilities.audio.voice_clone delete " + "--voice-id VOICE_ID --yes\n" + "Use your configured DASHSCOPE_API_KEY; keep the same region/workspace. " + "Use subcommand --help for options." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--output", type=Path, help="optional NEW .json file; never overwrite" + ) + common.add_argument("--timeout", type=positive_seconds, default=30.0, + help="request timeout seconds (default: 30)") + common.add_argument("--region", help="override DASHSCOPE_REGION") + common.add_argument("--workspace", help="override DASHSCOPE_WORKSPACE") + commands = parser.add_subparsers(dest="command", required=True) + create = commands.add_parser( + "create", parents=[common], help="create a clone and wait until OK", + description=( + "Use only owned/authorized speaker audio at a service-reachable HTTPS URL. " + "Upload it yourself; this sample never uploads local files. Recommended: " + "10-20 seconds, WAV (16-bit)/MP3/M4A, >=16 kHz, <=10 MB, " + "at most 60 seconds. " + "Include at least 5 seconds of continuous clear speech, without music " + "or other speakers. Only the first channel is used for stereo audio. " + "The voice persists, including after timeout or interruption." + ), + ) + create.add_argument("--url", type=https_url, required=True) + create.add_argument("--prefix", type=voice_prefix, default="demo", + help="1-10 lowercase letters/digits (default: demo)") + create.add_argument("--model", choices=MODELS, default="qwen-audio-3.0-tts-flash", + help="target TTS model (default: qwen-audio-3.0-tts-flash); " + "synthesis must use this same version") + create.add_argument("--wait-timeout", type=positive_seconds, default=300.0, + help="maximum readiness polling seconds (default: 300)") + get = commands.add_parser("get", parents=[common], + help="query once; exit nonzero unless the voice is OK") + get.add_argument("--voice-id", type=voice_id, required=True) + listing = commands.add_parser( + "list", parents=[common], help="list one page of clones" + ) + listing.add_argument("--prefix", type=voice_prefix) + listing.add_argument( + "--page-index", type=int, default=0, help="zero-based page index" + ) + listing.add_argument("--page-size", type=int, default=10) + delete = commands.add_parser( + "delete", parents=[common], help="explicitly delete one clone" + ) + delete.add_argument("--voice-id", type=voice_id, required=True) + delete.add_argument("--yes", action="store_true", help="confirm permanent deletion") + return parser + + +def main(argv=None): + parser = make_parser() + args = parser.parse_args(argv) + if args.command == "delete" and not args.yes: + parser.error("delete requires --yes; nothing has been deleted") + if args.command == "list" and (args.page_index < 0 or args.page_size <= 0): + parser.error("--page-index must be >= 0 and --page-size must be > 0") + if args.output and args.output.suffix.lower() != ".json": + parser.error("--output must be a new .json file") + # Open exclusively before any request can create a billable resource. + output = args.output.open("x", encoding="utf-8") if args.output else None + try: + with Client( + region=args.region, workspace=args.workspace, timeout=args.timeout, + max_retries=0, + ) as client: + data = {"action": args.command, "region": client.config.region, + "workspace": client.config.workspace} + if args.command == "create": + create_voice(client, args, output, data) + elif args.command == "get": + voice = client.audio.voices.get(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, voice) + print(f"voice_id: {args.voice_id}; status: {voice.status}", flush=True) + if voice.status != "OK": + print( + "Voice is not ready; do not synthesize with it yet.", + file=sys.stderr, + ) + return 1 + elif args.command == "list": + voices = client.audio.voices.list( + prefix=args.prefix, page_index=args.page_index, + page_size=args.page_size, + ) + data.update(page_index=args.page_index, page_size=args.page_size) + checkpoint(output, data, voices) + print(f"Page {args.page_index}: {len(voices.voices)} voice(s)") + for voice in voices.voices: + print( + f"voice_id: {voice.get('voice_id')}; " + f"status: {voice.get('status')}" + ) + else: + result = client.audio.voices.delete(args.voice_id) + data["voice_id"] = args.voice_id + checkpoint(output, data, result) + print(f"Deleted {args.voice_id}; request_id: {result.request_id}") + finally: + if output is not None: + output.close() + if args.output: + print(f"Saved {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print( + "\nInterrupted; no automatic deletion. Recover with get/list, not create.", + file=sys.stderr, + ) + sys.exit(130) + except (DashScopeError, httpx.HTTPError) as error: + # Do not expose signed source/preview URLs through exception messages. + print(f"Request failed ({type(error).__name__}). Use get/list to recover; " + "existing clones and saved results are retained.", file=sys.stderr) + sys.exit(1) + except (OSError, ValueError, RuntimeError) as error: + print( + f"Voice operation failed: {error}. No automatic deletion.", file=sys.stderr + ) + sys.exit(1) diff --git a/examples/01_atomic_capabilities/audio_speech/audio_asr.py b/examples/01_atomic_capabilities/audio_speech/audio_asr.py deleted file mode 100644 index a810d193..00000000 --- a/examples/01_atomic_capabilities/audio_speech/audio_asr.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""dashscope 2.0 speech-recognition (ASR) example: batch -transcription over audio file URLs.""" - -import os - -import dashscope - -# Authentication: the client reads the API key from the -# DASHSCOPE_API_KEY environment variable by default; you can also -# pass it explicitly via dashscope.Client(api_key="sk-xxx"). -MODEL = os.environ.get("DASHSCOPE_MODEL", "paraformer-v2") - -# Replace with your own audio file URLs (must be publicly accessible) -FILE_URLS = [ - "https://dashscope.oss-cn-beijing.aliyuncs.com/audios/welcome.mp3", -] - - -if __name__ == "__main__": - client = dashscope.Client() - - # Batch transcription: submits the task and waits for the result - print("----- transcribe request -----") - job = client.audio.asr.transcribe( - FILE_URLS, - model=MODEL, - parameters={"language_hints": ["zh"]}, - ) - if job.succeeded: - assert job.output is not None - for item in job.output["results"]: - print(f"file: {item['file_url']}") - print(f"transcription url: {item['transcription_url']}") - else: - print(f"job failed: {job.output}") - - # Realtime recognition of a local file (websocket) is available - # via client.audio.asr.recognize_file(path, model=...), which - # yields recognition events as an iterator. - - -# --- Production springboard (Best Practices & Patterns) ------- -# Minimal runnable atomic call above; to productionize it, see: -# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py -# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py -# Realtime hosting: 04_real_world_scenarios/realtime_audio_meeting_harness.py -# ------------------------------------------------------------- diff --git a/examples/01_atomic_capabilities/audio_speech/audio_tts.py b/examples/01_atomic_capabilities/audio_speech/audio_tts.py deleted file mode 100644 index b31a6ddd..00000000 --- a/examples/01_atomic_capabilities/audio_speech/audio_tts.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""dashscope 2.0 speech-synthesis (TTS) example: one-shot URL -result and streaming audio chunks.""" - -import os - -import dashscope - -# Authentication: the client reads the API key from the -# DASHSCOPE_API_KEY environment variable by default; you can also -# pass it explicitly via dashscope.Client(api_key="sk-xxx"). -MODEL = os.environ.get("DASHSCOPE_MODEL", "sambert-zhichu-v1") -VOICE = os.environ.get("DASHSCOPE_TTS_VOICE", "zhichu") - -TEXT = "你好,这是 dashscope 2.0 的语音合成示例。" - - -if __name__ == "__main__": - client = dashscope.Client() - - # One-shot synthesis: the rendered audio comes back as a - # temporary URL - print("----- synthesize request -----") - result = client.audio.tts.http.synthesize( - TEXT, - model=MODEL, - voice=VOICE, - format="wav", - sample_rate=16000, - ) - print(f"audio url: {result.audio_url}") - - # Streaming synthesis: audio arrives as binary chunks that can be - # written straight to a file - print("----- streaming synthesis request -----") - stream = client.audio.tts.http.stream( - TEXT, - model=MODEL, - voice=VOICE, - ) - with open("tts_output.wav", "wb") as f: - for chunk in stream: - if chunk.audio: - f.write(chunk.audio) - print("audio saved to tts_output.wav") - - -# --- Production springboard (Best Practices & Patterns) ------- -# Minimal runnable atomic call above; to productionize it, see: -# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py -# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py -# No dedicated harness for this capability yet; saving the -# stream to disk plus the generic patterns above is enough. -# ------------------------------------------------------------- diff --git a/examples/04_real_world_scenarios/README.md b/examples/04_real_world_scenarios/README.md index 5b00078f..2636abe7 100644 --- a/examples/04_real_world_scenarios/README.md +++ b/examples/04_real_world_scenarios/README.md @@ -145,7 +145,7 @@ The offline demo runs 4 rooms concurrently, reports `completed` / `degraded` sep ### Adapt it into your own meeting platform 1. **Swap the audio source**: `_read_pcm` currently reads local 16 kHz PCM/WAV; in production feed `_pump_audio` frames from a microphone / RTC downlink / message-queue byte stream, keeping `pace=True` for realtime pacing. -2. **True speak-while-transcribing**: the demo is "send all audio → stop → read events" (consistent with `01_atomic_capabilities/audio_speech/tingwu.py`, for offline determinism); for live-caption style, drain `realtime.events()` on a reader thread concurrently while sending — the socket itself is full-duplex. +2. **True speak-while-transcribing**: the demo is "send all audio → stop → read events" (consistent with `01_atomic_capabilities/audio/tingwu.py`, for offline determinism); for live-caption style, drain `realtime.events()` on a reader thread concurrently while sending — the socket itself is full-duplex. 3. **Tune concurrency and admission**: `MAX_CONCURRENT` is the active-socket cap — set it by your machine's file-descriptor / thread budget; `RECONNECT_ATTEMPTS` and `BASE_DELAY` (production suggests ≈0.5s) control reconnect aggressiveness. 4. **Plug in persistence and observability**: `MeetingSession.transition` is the single exit for state; in production wire it to logging / metrics / meeting-record storage, persisting transcripts and states by `meeting_id`. diff --git a/examples/04_real_world_scenarios/README_zh.md b/examples/04_real_world_scenarios/README_zh.md index bbe91964..59c5dedc 100644 --- a/examples/04_real_world_scenarios/README_zh.md +++ b/examples/04_real_world_scenarios/README_zh.md @@ -145,7 +145,7 @@ MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_m ### 改造成你自己的会议平台 1. **换音频源**:`_read_pcm` 目前读本地 16 kHz PCM/WAV;生产环境从麦克风 / RTC 下行 / 消息队列字节流向 `_pump_audio` 喂帧,保持 `pace=True` 的实时节奏。 -2. **真正的边说边转写**:演示是"发完全部音频 → stop → 读事件"(与 `01_atomic_capabilities/audio_speech/tingwu.py` 一致,为了离线确定性);实时字幕风格需要在发送的同时用读线程排空 `realtime.events()`——socket 本身是全双工的。 +2. **真正的边说边转写**:演示是"发完全部音频 → stop → 读事件"(与 `01_atomic_capabilities/audio/tingwu.py` 一致,为了离线确定性);实时字幕风格需要在发送的同时用读线程排空 `realtime.events()`——socket 本身是全双工的。 3. **调并发与准入**:`MAX_CONCURRENT` 是活跃 socket 上限——按你机器的 fd / 线程预算设定;`RECONNECT_ATTEMPTS` 和 `BASE_DELAY`(生产建议 ≈0.5s)控制重连激进程度。 4. **接持久化与可观测性**:`MeetingSession.transition` 是状态的唯一出口;生产环境接到日志 / 指标 / 会议记录存储上,按 `meeting_id` 持久化转写稿与状态。 diff --git a/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py b/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py index 1906a537..13ce5c36 100644 --- a/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +++ b/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py @@ -174,7 +174,7 @@ def _pump_audio(session, realtime): Production variant: to transcribe *live* rather than after stop, drain ``realtime.events()`` on a reader thread while frames are still being sent -- the socket is duplex. The sequential shape here matches - 01_atomic_capabilities/audio_speech/tingwu.py and keeps the offline + 01_atomic_capabilities/audio/tingwu.py and keeps the offline mock deterministic. """ for frame in session.frames: diff --git a/examples/README.md b/examples/README.md index 76089506..fbb4584e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,9 +93,8 @@ Uniform style: `client = dashscope.Client()` (reads the key from the `DASHSCOPE_ | `text_generation/chat.py` | Text generation: one-line `dashscope.chat()`, non-streaming, streaming (text_stream) | | `text_generation/tokenization.py` | Token counting | | `multimodal_vision/multimodal_chat.py` | Multimodal chat (qwen-vl image understanding) | -| `audio_speech/audio_tts.py` | Speech synthesis (one-shot + streaming); 2.0 audio API is a preview and may change | -| `audio_speech/audio_asr.py` | Batch speech recognition; 2.0 audio API is a preview and may change | -| `audio_speech/tingwu.py` | TingWu offline tasks and realtime sessions | +| `audio/` | Speech samples: ASR/TTS/realtime/voice CLIs (see `audio/README.md`; `direct_call/` holds the no-`with` variants) | +| `audio/tingwu.py` | TingWu offline tasks and realtime sessions | | `image_video_synthesis/images.py` | Image generation: one-call generate-and-wait + the manual jobs flow | | `image_video_synthesis/videos.py` | Video generation: one-call generate-and-wait + the manual jobs flow | | `embeddings/embeddings.py` | Text embedding (with batch-task entry notes) | diff --git a/examples/README_zh.md b/examples/README_zh.md index 6759b7ef..73db1211 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -93,9 +93,8 @@ examples/ | `text_generation/chat.py` | 文本生成:一行 `dashscope.chat()`、非流式、流式(text_stream) | | `text_generation/tokenization.py` | token 计数 | | `multimodal_vision/multimodal_chat.py` | 多模态对话(qwen-vl 图像理解) | -| `audio_speech/audio_tts.py` | 语音合成(一次性 + 流式);2.0 audio API 为预览,后续可能变更 | -| `audio_speech/audio_asr.py` | 批量语音识别;2.0 audio API 为预览,后续可能变更 | -| `audio_speech/tingwu.py` | 听悟离线任务与实时会话 | +| `audio/` | 语音示例:ASR/TTS/realtime/音色 CLI(见 `audio/README.md`;`direct_call/` 为不使用 `with` 的副本) | +| `audio/tingwu.py` | 听悟离线任务与实时会话 | | `image_video_synthesis/images.py` | 图像生成:一键生成并等待 + 手动 jobs 流程 | | `image_video_synthesis/videos.py` | 视频生成:一键生成并等待 + 手动 jobs 流程 | | `embeddings/embeddings.py` | 文本向量(含批量任务入口说明) | diff --git a/tests/unit/test_acli_usage_report.py b/tests/unit/test_acli_usage_report.py index 634e1689..dd5a7e81 100644 --- a/tests/unit/test_acli_usage_report.py +++ b/tests/unit/test_acli_usage_report.py @@ -126,6 +126,30 @@ def _read(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) +def _await_usage(path: Path, predicate: Callable[[dict], bool]) -> dict: + """Return the first flushed usage satisfying ``predicate``. + + The flusher ticks on a daemon thread a shared CI runner can starve for + longer than any fixed sleep's slack (a 0.3s sleep leaves the duration + assertion only 0.1s), so poll instead of sleep-then-read. + """ + deadline = time.monotonic() + 5.0 + usage: dict | None = None + while time.monotonic() < deadline: + try: + usage = _read(path) + except FileNotFoundError: + # The first tick is what creates the file; it may not have landed. + pass + else: + if predicate(usage): + return usage + time.sleep(0.05) + raise AssertionError( + f"no flush within 5s satisfied the predicate; last read: {usage}", + ) + + def test_the_flusher_writes_while_nothing_is_streaming( tmp_path, ticking_flusher, @@ -138,9 +162,7 @@ def test_the_flusher_writes_while_nothing_is_streaming( executor = Executor() ticking_flusher(str(path), executor) executor.record_api_call({"input_tokens": 111, "output_tokens": 222}) - time.sleep(0.3) - usage = _read(path) - assert usage["api_calls"] == 1 + usage = _await_usage(path, lambda u: u["api_calls"] == 1) assert usage["input_tokens"] == 111 assert usage["output_tokens"] == 222 @@ -156,8 +178,8 @@ def test_duration_keeps_advancing_after_the_run_goes_quiet( _write_usage_file(str(path), executor) first = _read(path)["duration_sec"] ticking_flusher(str(path), executor) - time.sleep(0.3) - assert _read(path)["duration_sec"] > first + 0.2 + usage = _await_usage(path, lambda u: u["duration_sec"] > first + 0.2) + assert usage["duration_sec"] > first + 0.2 def test_stop_waits_for_a_tick_that_is_already_in_flight( @@ -250,6 +272,6 @@ def test_a_tick_that_raises_does_not_stop_the_flusher( path = tmp_path / "usage.json" executor = RacyExecutor(2) ticking_flusher(str(path), executor) - time.sleep(0.4) + usage = _await_usage(path, lambda u: u["duration_sec"] > 0.2) assert executor.failures_left == 0 - assert _read(path)["duration_sec"] > 0.2 + assert usage["duration_sec"] > 0.2 diff --git a/tests/v2/protocols/test_anthropic_conformance.py b/tests/v2/protocols/test_anthropic_conformance.py index 09a4b3bb..4d86b073 100644 --- a/tests/v2/protocols/test_anthropic_conformance.py +++ b/tests/v2/protocols/test_anthropic_conformance.py @@ -22,8 +22,13 @@ # pylint: disable=protected-access import json +from copy import deepcopy + +import httpx +import pytest from dashscope.acli.providers.anthropic import AnthropicProvider +from dashscope.v2 import AsyncClient, Client from dashscope.v2.protocols import AnthropicProtocol MODEL = "qwen3-max" @@ -212,3 +217,110 @@ def test_text_content_parts(): ) request = _v2_request([{"role": "user", "content": list(parts)}]) assert request.body["messages"] == acli_messages + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "entry_point", + ["named", "parameters", "parameters_override"], +) +@pytest.mark.parametrize( + "tool_choice, expected", + [ + (None, None), + ("auto", {"type": "auto"}), + ("none", {"type": "none"}), + ("required", {"type": "any"}), + ( + {"type": "function", "function": {"name": "get_weather"}}, + {"type": "tool", "name": "get_weather"}, + ), + ( + {"type": "tool", "name": "get_weather"}, + {"type": "tool", "name": "get_weather"}, + ), + ( + {"type": "any", "disable_parallel_tool_use": True}, + {"type": "any", "disable_parallel_tool_use": True}, + ), + ], +) +async def test_public_tool_choice_translation_and_null_compatibility( + monkeypatch, + asynchronous, + entry_point, + tool_choice, + expected, +): + monkeypatch.setattr("dashscope.v2._config._load_config_file", lambda _: {}) + monkeypatch.setattr( + "dashscope.v2._config._api_key_from_file", + lambda: None, + ) + via_parameters = entry_point != "named" + kwargs = {"tool_choice": deepcopy(tool_choice)} + if via_parameters: + kwargs = { + "parameters": { + "tool_choice": deepcopy(tool_choice), + "metadata": {"trace": "test-only"}, + }, + } + if entry_point == "parameters_override": + kwargs["tool_choice"] = "required" + original = deepcopy(kwargs) + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response( + 200, + json={ + "id": "test-message", + "model": MODEL, + "role": "assistant", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + }, + ) + + base_url = "https://anthropic.test.local" + http_client = (httpx.AsyncClient if asynchronous else httpx.Client)( + transport=httpx.MockTransport(handler), + base_url=base_url, + ) + client = (AsyncClient if asynchronous else Client)( + api_key="sk-test-only", + base_url=base_url, + protocol="anthropic", + max_retries=0, + http_client=http_client, + ) + try: + result = client.chat.completions.create( + model=MODEL, + messages="hi", + **kwargs, + ) + if asynchronous: + result = await result + assert result.output_text == "ok" + assert len(requests) == 1 + assert str(requests[0].url) == f"{base_url}/v1/messages" + body = json.loads(requests[0].content) + if via_parameters or tool_choice is not None: + assert "tool_choice" in body + assert body["tool_choice"] == expected + else: + assert "tool_choice" not in body + if via_parameters: + assert body["metadata"] == original["parameters"]["metadata"] + assert kwargs == original + finally: + if asynchronous: + await client.aclose() + await http_client.aclose() + else: + client.close() + http_client.close() diff --git a/tests/v2/smoke/test_full_surface.py b/tests/v2/smoke/test_full_surface.py index a91c1d06..7b69cbbe 100644 --- a/tests/v2/smoke/test_full_surface.py +++ b/tests/v2/smoke/test_full_surface.py @@ -255,31 +255,6 @@ def handler(request): assert done.succeeded -def test_smoke_decision(): - def handler(request): - assert request.url.path == "/api/v1/systemone" - body = json.loads(request.content) - assert body["questions"]["refund"]["type"] == "noul" - return _json( - { - "request_id": "req-dec", - "model": "decision-model-preview", - "answers": {"refund": {"type": "noul", "noul": 0.99}}, - "usage": {"input_tokens": 9}, - "latency_ms": 12.3, - }, - ) - - client = _make_client(handler) - resp = client.decision.create( - model="decision-model-preview", - state="这段评论是否在索要退款?", - questions={"refund": {"type": "noul"}}, - ) - assert resp.answers["refund"].noul == 0.99 - assert resp.usage.input_tokens == 9 - - def test_smoke_files_models(tmp_path): data_file = tmp_path / "train.jsonl" data_file.write_text('{"prompt": "hi"}\n', encoding="utf-8") @@ -397,7 +372,16 @@ def send(self, payload, opcode=1): def recv(self): if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + event = json.loads(raw) + header = event.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True @@ -635,14 +619,6 @@ def handler(request): "output": {"text": "hi"}, }, ) - if request.url.path.endswith("/systemone"): - return _json( - { - "request_id": "req-dec", - "model": "decision-model-preview", - "answers": {"q": {"type": "noul", "noul": 0.5}}, - }, - ) if request.method == "POST": return _json( { @@ -674,12 +650,6 @@ def handler(request): messages=[{"role": "user", "content": "hi"}], ) assert resp.output_text == "Hello" - decision = await client.decision.create( - model="decision-model-preview", - state="s", - questions={"q": {"type": "noul"}}, - ) - assert decision.answers["q"].noul == 0.5 img = await client.images.generate( model="wanx2.1-t2i", prompt="a cat", diff --git a/tests/v2/test_agentstudio_errors.py b/tests/v2/test_agentstudio_errors.py index 2c45e7cb..01840c0a 100644 --- a/tests/v2/test_agentstudio_errors.py +++ b/tests/v2/test_agentstudio_errors.py @@ -2,7 +2,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Tests for the AgentStudio -> v2 error-alignment proxy (M3 3.12). -Three things are pinned here: +Two things are pinned here: * :func:`translate_agentstudio_error` maps the 1.x AgentStudio family onto the v2 family by *two* routes -- HTTP-status errors through @@ -10,9 +10,6 @@ through ``error_from_sdk_exception``. * The transparent proxy keeps ``isinstance`` / ``.transport`` working while making every failure catchable as ``DashScopeError``. -* Lazily-failing return values -- event streams and cursor pages -- - translate errors raised during iteration and pagination, not just - errors raised by the call itself. """ # Mock handlers intentionally ignore the request argument. @@ -20,6 +17,9 @@ from __future__ import annotations +import asyncio +import json + import httpx import pytest @@ -27,6 +27,7 @@ from dashscope.agentstudio import Client as AgentStudioClient from dashscope.agentstudio import exceptions as as_exc from dashscope.agentstudio.pagination import AsyncCursorPage, CursorPage +from dashscope.agentstudio.types import ServerEvent, Session from dashscope.v2 import errors as v2_errors from dashscope.v2.resources._agentstudio import ( _wrap_result, @@ -37,6 +38,17 @@ BASE_URL = "https://as.test.local" +@pytest.fixture(autouse=True) +def _clear_credentials(monkeypatch): + for name in ( + "DASHSCOPE_API_KEY", + "GATEWAY_API_KEY", + "GATEWAY_MODEL", + "GATEWAY_BASE_URL", + ): + monkeypatch.delenv(name, raising=False) + + # --------------------------------------------------------------------------- # Two-branch translation, exercised directly (no network) # --------------------------------------------------------------------------- @@ -242,127 +254,504 @@ def test_sync_stream_proxy_translates_iteration_error(): assert ei.value.code == "sdk.agentstudio.StreamClosedError" -# --------------------------------------------------------------------------- -# Cursor pages: the fetch_next closure captures the raw 1.x resource -# method, so pagination fetches bypass the method guards and the page -# proxy must translate errors raised while iterating / calling get_next() -# --------------------------------------------------------------------------- +@pytest.mark.parametrize("compat", [False, True]) +@pytest.mark.parametrize("header_id", [None, "header-id"]) +def test_translation_preserves_v1_metadata(compat, header_id): + body = { + "type": "error", + "message": "top-level message must not replace the parsed message", + "request_id": "body-id", + "error": { + "error_code" if compat else "code": "rate_limit_error", + "error_message" if compat else "message": "canonical message", + }, + } + headers = {"x-request-id": header_id} if header_id else {} + original = as_exc.from_response( + status_code=429, + body=body, + headers=headers, + ) + translated = translate_agentstudio_error(original) + assert isinstance(translated, v2_errors.RateLimitError) + assert ( + translated.request_id + == original.request_id + == (header_id or "body-id") + ) + assert translated.code == original.code == "rate_limit_error" + assert translated.message == original.message == "canonical message" + assert translated.body is body + assert body["request_id"] == "body-id" -def _sync_page(items, fetch_next): - return CursorPage( - data=items, - next_page="cursor-2", - request_id="req-p1", - fetch_next=fetch_next, +def test_agentstudio_code_does_not_override_http_status(): + original = as_exc.from_response( + status_code=429, + body={"error": {"code": "InvalidApiKey", "message": "quota"}}, ) + translated = translate_agentstudio_error(original) + assert isinstance(translated, v2_errors.RateLimitError) + assert translated.code == "InvalidApiKey" + assert translated.body is original.raw + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_proxy_observes_attribute_and_method_replacements(asynchronous): + factory = _make_async_proxy if asynchronous else _make_proxy + proxy = factory(lambda request: httpx.Response(200, json={"id": "s-1"})) + target = object.__getattribute__(proxy, "_target") + http_client = target.transport._client + try: + target.labels = {"before": True} + assert proxy.labels is target.labels + proxy.labels = {"after": True} + assert proxy.labels == {"after": True} + target.labels = {"direct": True} + assert proxy.labels is target.labels + assert proxy.labels == {"direct": True} + assert "labels" not in object.__getattribute__(proxy, "_cache") + + resource = proxy.sessions + assert proxy.sessions is resource + result = resource.retrieve("s-1") + if asynchronous: + result = await result + assert type(result) is Session + assert result.id == "s-1" + assert "retrieve" not in object.__getattribute__(resource, "_cache") + + first = Session(id="replacement") + + async def async_retrieve(session_id): + return first + + replacement = async_retrieve if asynchronous else lambda _: first + resource.retrieve = replacement + result = resource.retrieve("s-1") + assert (await result if asynchronous else result) is first + + async def fail(session_id): + raise as_exc.RateLimitError("new method") + + def sync_fail(session_id): + raise as_exc.RateLimitError("new method") + + target.sessions.retrieve = fail if asynchronous else sync_fail + with pytest.raises(v2_errors.RateLimitError): + result = resource.retrieve("s-1") + if asynchronous: + await result + + target.sessions = type(target.sessions)(target) + assert proxy.sessions is not resource + assert proxy.sessions.events is proxy.sessions.events + resource = proxy.sessions + proxy.sessions = type(target.sessions)(target) + assert proxy.sessions is not resource + + transport = proxy.transport + proxy.transport = object() + assert proxy.transport is target.transport + assert proxy.transport is not transport + target.transport = transport + assert proxy.transport is transport + finally: + if asynchronous: + await proxy.aclose() + assert not http_client.is_closed + await http_client.aclose() + else: + proxy.close() + assert not http_client.is_closed + http_client.close() + + +def _page_handler(requests, fail_at=None): + def handler(request): + cursor = request.url.params.get("page") + number = int(cursor) if cursor else 1 + requests.append(number) + assert request.url.params["limit"] == "1" + if number == fail_at: + return httpx.Response( + 429, + headers={"x-request-id": "page-header"}, + json={ + "type": "error", + "error": {"error_code": "quota", "error_message": "slow"}, + "request_id": "page-body", + }, + ) + return httpx.Response( + 200, + json={ + "data": [{"id": f"s-{number}", "title": "session"}], + "next_page": str(number + 1) if number < 3 else None, + "request_id": f"page-{number}", + }, + ) + return handler -def test_sync_page_proxy_translates_auto_pagination_error(): - def fetch_next(cursor): - raise as_exc.APIConnectionError("dropped mid-pagination") - page = _wrap_result(_sync_page([{"id": "a-1"}], fetch_next)) - iterator = iter(page) - assert next(iterator) == {"id": "a-1"} - with pytest.raises(v2_errors.SdkError) as ei: - next(iterator) - assert ei.value.code == "sdk.agentstudio.APIConnectionError" +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("auto", [False, True]) +@pytest.mark.parametrize("fail_at", [2, 3]) +@pytest.mark.asyncio +async def test_pagination_lazily_translates_later_pages( + asynchronous, + auto, + fail_at, +): + requests = [] + factory = _make_async_proxy if asynchronous else _make_proxy + proxy = factory(_page_handler(requests, fail_at)) + try: + page = proxy.sessions.list(limit=1) + if asynchronous: + page = await page + assert isinstance( + page, + AsyncCursorPage if asynchronous else CursorPage, + ) + assert len(page) == 1 + assert page.has_next() + assert page.request_id == "page-1" + assert type(page.data[0]) is Session + assert requests == [1] + with pytest.raises(v2_errors.RateLimitError) as caught: + if auto: + if asynchronous: + iterator = page.__aiter__() + assert await iterator.__anext__() is page.data[0] + assert requests == [1] + async for _ in iterator: + pass + else: + iterator = iter(page) + assert next(iterator) is page.data[0] + assert requests == [1] + list(iterator) + else: + while page.has_next(): + page = page.get_next() + if asynchronous: + page = await page + assert requests == list(range(1, fail_at + 1)) + assert caught.value.code == "quota" + assert caught.value.request_id == "page-header" + assert caught.value.message == "slow" + assert caught.value.status_code == 429 + assert isinstance(caught.value.__cause__, as_exc.RateLimitError) + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_pagination_success_preserves_models_and_mutability( + asynchronous, +): + requests = [] + factory = _make_async_proxy if asynchronous else _make_proxy + proxy = factory(_page_handler(requests)) + try: + page = proxy.sessions.list(limit=1) + if asynchronous: + page = await page + items = [item async for item in page] if asynchronous else list(page) + assert [item.id for item in items] == ["s-1", "s-2", "s-3"] + assert all(type(item) is Session for item in items) + assert items[0] is page.data[0] + assert len(page) == 1 + page.data = [Session(id="local")] + page.next_page = None + assert not page.has_next() + result = page.get_next() + assert (await result if asynchronous else result) is None + assert page.data[0].to_dict()["id"] == "local" + assert requests == [1, 2, 3] + page.data = [] + assert len(page) == 0 + assert not page + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +_SSE_EVENTS = [ + { + "id": "m-1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + }, + { + "type": "event_delta", + "event_id": "m-1", + "delta": { + "type": "content_delta", + "index": 0, + "content": {"type": "text", "text": "delta"}, + }, + }, +] + + +def _sse_frames(): + for event in _SSE_EVENTS: + yield f"event: message\ndata: {json.dumps(event)}\n\n".encode() + + +class _SyncSSEBody(httpx.SyncByteStream): + def __init__(self, error=None): + self.error = error + self.closed = 0 + + def __iter__(self): + yield from _sse_frames() + if self.error is not None: + raise self.error + def close(self): + self.closed += 1 -def test_sync_page_proxy_translates_get_next_error(): - def fetch_next(cursor): - raise as_exc.NotFoundError("gone", status_code=404, request_id="r-2") - page = _wrap_result(_sync_page([], fetch_next)) - with pytest.raises(v2_errors.NotFoundError): - page.get_next() +class _AsyncSSEBody(httpx.AsyncByteStream): + def __init__(self, error=None): + self.error = error + self.closed = 0 + async def __aiter__(self): + for frame in _sse_frames(): + yield frame + if self.error is not None: + raise self.error -def test_sync_page_proxy_stays_transparent_and_wraps_next_page(): - second = CursorPage( - data=[{"id": "a-2"}], - next_page=None, - request_id="req-p2", - ) - page = _wrap_result(_sync_page([{"id": "a-1"}], lambda cursor: second)) - assert isinstance(page, CursorPage) - assert len(page) == 1 - assert page.data == [{"id": "a-1"}] - assert page.has_next() - assert list(page) == [{"id": "a-1"}, {"id": "a-2"}] - page2 = page.get_next() - assert isinstance(page2, CursorPage) - assert list(page2) == [{"id": "a-2"}] - assert page2.get_next() is None + async def aclose(self): + self.closed += 1 -@pytest.mark.asyncio -async def test_async_page_proxy_translates_auto_pagination_error(): - async def fetch_next(cursor): - raise as_exc.APITimeoutError("timed out mid-pagination") - - page = _wrap_result( - AsyncCursorPage( - data=[{"id": "a-1"}], - next_page="cursor-2", - request_id="req-p1", - fetch_next=fetch_next, - ), +def _sse_client(asynchronous, error=None): + body = _AsyncSSEBody(error) if asynchronous else _SyncSSEBody(error) + response = httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=body, ) - seen = [] - with pytest.raises(v2_errors.SdkError) as ei: - async for item in page: - seen.append(item) - assert seen == [{"id": "a-1"}] - assert ei.value.code == "sdk.agentstudio.APITimeoutError" + factory = _make_async_proxy if asynchronous else _make_proxy + return factory(lambda request: response), response, body + +async def _consume_stream(stream, view, asynchronous): + source = stream if view == "events" else getattr(stream, view) + return [item async for item in source] if asynchronous else list(source) + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("view", ["events", "text_stream", "text_deltas"]) +@pytest.mark.parametrize( + "error_cls,code", + [ + (httpx.ReadTimeout, "sdk.agentstudio.APITimeoutError"), + (httpx.ReadError, "sdk.agentstudio.APIConnectionError"), + (httpx.RemoteProtocolError, "sdk.agentstudio.APIConnectionError"), + ], +) @pytest.mark.asyncio -async def test_async_page_proxy_translates_get_next_error(): - async def fetch_next(cursor): - raise as_exc.RateLimitError("slow", status_code=429, request_id="r-2") - - page = _wrap_result( - AsyncCursorPage( - data=[], - next_page="cursor-2", - request_id="req-p1", - fetch_next=fetch_next, - ), - ) - with pytest.raises(v2_errors.RateLimitError): - await page.get_next() +async def test_real_sse_transport_errors(asynchronous, view, error_cls, code): + original = error_cls("SSE transport failed") + proxy, response, body = _sse_client(asynchronous, original) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + assert not response.is_closed + with pytest.raises(v2_errors.SdkError) as caught: + await _consume_stream(stream, view, asynchronous) + assert caught.value.code == code + assert caught.value.__cause__ is original + assert caught.value.message == "SSE transport failed" + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("view", ["events", "text_stream", "text_deltas"]) +@pytest.mark.asyncio +async def test_real_sse_closed_views(asynchronous, view): + proxy, response, body = _sse_client(asynchronous) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + await stream.aclose() + await stream.aclose() + else: + stream.close() + stream.close() + assert response.is_closed + assert body.closed == 1 + with pytest.raises(v2_errors.SdkError) as caught: + await _consume_stream(stream, view, asynchronous) + assert caught.value.code == "sdk.agentstudio.StreamClosedError" + assert isinstance(caught.value.__cause__, as_exc.StreamClosedError) + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("view", ["events", "text_stream", "text_deltas"]) +@pytest.mark.parametrize("early_exit", [False, True]) +@pytest.mark.asyncio +async def test_real_sse_normal_results_and_context_close( + asynchronous, + view, + early_exit, +): + proxy, response, body = _sse_client(asynchronous) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + async with stream as entered: + assert entered is stream + if early_exit: + source = ( + stream if view == "events" else getattr(stream, view) + ) + iterator = source.__aiter__() + await iterator.__anext__() + else: + items = await _consume_stream(stream, view, asynchronous) + if early_exit: + await iterator.aclose() + else: + with stream as entered: + assert entered is stream + if early_exit: + source = ( + stream if view == "events" else getattr(stream, view) + ) + iterator = iter(source) + next(iterator) + else: + items = await _consume_stream(stream, view, asynchronous) + if early_exit: + iterator.close() + if not early_exit: + if view == "events": + assert all(type(item) is ServerEvent for item in items) + assert [item.type for item in items] == [ + "message", + "event_delta", + ] + else: + assert items == ["hello" if view == "text_stream" else "delta"] + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "error_cls", + [ValueError, TypeError, httpx.LocalProtocolError], +) +@pytest.mark.asyncio +async def test_real_sse_programming_errors_are_not_translated( + asynchronous, + error_cls, +): + original = error_cls("programming error") + proxy, response, body = _sse_client(asynchronous, original) + try: + stream = proxy.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + with pytest.raises(error_cls) as caught: + await _consume_stream(stream, "text_stream", asynchronous) + assert caught.value is original + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await proxy.transport._client.aclose() + else: + proxy.transport._client.close() -def test_proxy_translates_pagination_fetch_error_end_to_end(): - # Page 2's fetch goes through the raw 1.x resource method captured in - # fetch_next -- without the page proxy this raises the 1.x error. - def handler(request): - if "page=cursor-2" in str(request.url): - return httpx.Response( - 404, - json={ - "type": "error", - "error": { - "code": "not_found_error", - "message": "no such page", - }, - "request_id": "req-p2", - }, +@pytest.mark.asyncio +async def test_real_sse_consumer_cancellation_is_not_translated(): + reading = asyncio.Event() + + class BlockingBody(_AsyncSSEBody): + async def __aiter__(self): + reading.set() + await asyncio.Event().wait() + yield b"" + + body = BlockingBody() + response = httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + stream=body, + ) + proxy = _make_async_proxy(lambda request: response) + try: + stream = await proxy.sessions.events.stream("s-1") + async with stream: + task = asyncio.create_task( + _consume_stream(stream, "text_stream", True), ) - return httpx.Response( - 200, - json={ - "data": [{"id": "a-1", "name": "demo", "model": "qwen-plus"}], - "next_page": "cursor-2", - "request_id": "req-p1", - }, - ) + await asyncio.wait_for(reading.wait(), timeout=2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert response.is_closed + assert body.closed == 1 + finally: + await proxy.transport._client.aclose() - proxy = _make_proxy(handler) - page = proxy.agents.list() - assert isinstance(page, CursorPage) - with pytest.raises(v2_errors.NotFoundError): - list(page) - proxy.close() + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.asyncio +async def test_standalone_v1_keeps_native_errors(asynchronous): + original = httpx.ReadTimeout("native read timeout") + proxy, response, body = _sse_client(asynchronous, original) + client = object.__getattribute__(proxy, "_target") + try: + stream = client.sessions.events.stream("s-1") + if asynchronous: + stream = await stream + with pytest.raises(httpx.ReadTimeout) as caught: + await _consume_stream(stream, "text_stream", asynchronous) + assert caught.value is original + with pytest.raises(as_exc.StreamClosedError): + await _consume_stream(stream, "text_stream", asynchronous) + assert response.is_closed + assert body.closed == 1 + finally: + if asynchronous: + await client.transport._client.aclose() + else: + client.transport._client.close() diff --git a/tests/v2/test_audio.py b/tests/v2/test_audio.py index 9702786f..e918f513 100644 --- a/tests/v2/test_audio.py +++ b/tests/v2/test_audio.py @@ -13,9 +13,11 @@ import pytest from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import DashScopeError +from dashscope.v2.errors import APIConnectionError, DashScopeError +from dashscope.v2.resources import audio as audio_resources from dashscope.v2.resources.audio._callbacks import run_with_callback from dashscope.v2.resources.audio.asr import RecognitionResult +from dashscope.v2.transport.ws import WsEventError BASE_URL = "https://api.test.local/api/v1" TRANSCRIPTION_PATH = "/api/v1/services/audio/asr/transcription" @@ -65,7 +67,19 @@ def send(self, payload, opcode=1): def recv(self): if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + try: + event = json.loads(raw) + except ValueError: + return raw + header = event.get("header") if isinstance(event, dict) else None + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True @@ -87,6 +101,21 @@ def _sent_frames(ws): return [json.loads(item) for item in ws.sent if isinstance(item, str)] +def test_audio_public_resources_exclude_translation(): + with _make_bare_client() as client: + assert isinstance(client.audio, audio_resources.Audio) + assert not hasattr(client.audio, "translation") + assert isinstance(client.audio.asr, audio_resources.Asr) + assert isinstance(client.audio.tts, audio_resources.Tts) + assert isinstance(client.audio.realtime, audio_resources.Realtime) + assert callable(client.audio.asr.recognize) + assert callable(client.audio.tts.synthesize) + assert callable(client.audio.realtime.connect) + for name in ("translation", "Translation", "TranslationSession"): + assert not hasattr(audio_resources, name) + assert name not in audio_resources.__all__ + + def test_transcription_transcribe_submit_and_wait(): captured = {} @@ -128,6 +157,59 @@ def handler(request): assert job.output["results"][0]["transcription_url"] +@pytest.mark.parametrize("entry_point", ["transcribe", "create"]) +@pytest.mark.parametrize( + "parameter_kwargs", + [ + pytest.param({}, id="omitted"), + pytest.param({"parameters": None}, id="none"), + pytest.param({"parameters": {}}, id="empty"), + pytest.param( + { + "parameters": { + "keep_dialect": False, + "vocabulary": {"通义千问": 5}, + "language_hints": ["zh", "en"], + "channel_id": [0], + "diarization_enabled": False, + }, + }, + id="custom", + ), + ], +) +def test_transcription_serializes_parameters(entry_point, parameter_kwargs): + bodies = [] + + def handler(request): + assert request.method == "POST" + assert request.url.path == TRANSCRIPTION_PATH + assert request.headers["X-DashScope-Async"] == "enable" + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + with _make_client(handler) as client: + if entry_point == "transcribe": + job = client.audio.asr.transcribe( + ["https://example.com/a.wav"], + **parameter_kwargs, + ) + else: + job = client.audio.asr.jobs.create( + model="qwen-audio-3.1-asr-flash-filetrans", + file_urls=["https://example.com/a.wav"], + **parameter_kwargs, + ) + assert job.succeeded + assert bodies == [ + { + "model": "qwen-audio-3.1-asr-flash-filetrans", + "input": {"file_urls": ["https://example.com/a.wav"]}, + "parameters": parameter_kwargs.get("parameters") or {}, + }, + ] + + def test_transcription_requires_file_urls(): client = _make_bare_client() with pytest.raises(ValueError, match="file_urls"): @@ -164,17 +246,91 @@ def handler(request): @pytest.mark.asyncio -async def test_async_transcription_transcribe(): +@pytest.mark.parametrize( + "model_kwargs, expected_model", + [ + ({}, "qwen-audio-3.1-asr-flash-filetrans"), + ({"model": "paraformer-v2"}, "paraformer-v2"), + ], +) +async def test_async_transcription_transcribe(model_kwargs, expected_model): + captured = {} + def handler(request): if request.method == "POST": + captured["body"] = json.loads(request.content) return httpx.Response(200, json=_task_body("PENDING")) return httpx.Response(200, json=_task_body("SUCCEEDED")) - client = _make_async_client(handler) - job = await client.audio.asr.transcribe( - ["https://example.com/a.wav"], - ) + async with _make_async_client(handler) as client: + job = await client.audio.asr.transcribe( + ["https://example.com/a.wav"], + poll_interval=0, + **model_kwargs, + ) assert job.succeeded is True + assert captured["body"] == { + "model": expected_model, + "input": {"file_urls": ["https://example.com/a.wav"]}, + "parameters": {}, + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("entry_point", ["transcribe", "create"]) +@pytest.mark.parametrize( + "parameter_kwargs", + [ + pytest.param({}, id="omitted"), + pytest.param({"parameters": None}, id="none"), + pytest.param({"parameters": {}}, id="empty"), + pytest.param( + { + "parameters": { + "keep_dialect": False, + "vocabulary": {"通义千问": 5}, + "language_hints": ["zh", "en"], + "channel_id": [0], + "diarization_enabled": False, + }, + }, + id="custom", + ), + ], +) +async def test_async_transcription_serializes_parameters( + entry_point, + parameter_kwargs, +): + bodies = [] + + def handler(request): + assert request.method == "POST" + assert request.url.path == TRANSCRIPTION_PATH + assert request.headers["X-DashScope-Async"] == "enable" + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_task_body("SUCCEEDED")) + + async with _make_async_client(handler) as client: + if entry_point == "transcribe": + job = await client.audio.asr.transcribe( + ["https://example.com/a.wav"], + **parameter_kwargs, + ) + else: + job = await client.audio.asr.jobs.create( + model="qwen-audio-3.1-asr-flash-filetrans", + file_urls=["https://example.com/a.wav"], + **parameter_kwargs, + ) + assert job.succeeded + assert bodies == [ + { + "model": "qwen-audio-3.1-asr-flash-filetrans", + "input": {"file_urls": ["https://example.com/a.wav"]}, + "parameters": parameter_kwargs.get("parameters") or {}, + }, + ] def test_recognition_session_lifecycle(): @@ -689,3 +845,323 @@ def test_run_with_callback_delivers_errors_via_on_error(): kinds = [call[0] for call in callback.calls] assert kinds == ["on_open", "on_error", "on_close"] + + +def test_recognition_audio_parameter_merge_precedence(): + parameters = { + "format": "wav", + "sample_rate": 8000, + "vocabulary_id": "old", + "diarization_enabled": True, + "disfluency_removal_enabled": True, + "language_hints": ["zh"], + } + original = dict(parameters) + ws = FakeWebSocket([_event("task-started")]) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + format="pcm", + sample_rate=16000, + vocabulary_id="new", + diarization_enabled=False, + disfluency_removal_enabled=False, + parameters=parameters, + connector=lambda url, headers: ws, + ): + actual = _sent_frames(ws)[0]["payload"]["parameters"] + assert parameters == original + assert actual == { + **original, + "vocabulary_id": "new", + "diarization_enabled": False, + "disfluency_removal_enabled": False, + } + + +def test_recognition_results_preserve_optional_request_id_and_extra_fields(): + generated = { + "header": {"event": "result-generated", "task_id": "t"}, + "payload": { + "output": {"sentence": {"text": "final", "end_time": 0}}, + "usage": {"duration": 0}, + "future_field": {"ok": True}, + }, + } + ws = FakeWebSocket( + [ + _event("task-started"), + json.dumps(generated), + _event("task-finished"), + ], + ) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + result = next(rec.results()) + assert result.get_sentence()["end_time"] == 0 + assert result.is_sentence_end() + assert result.get_usage() == {"duration": 0} + assert result.future_field == {"ok": True} + assert result.request_id is None + assert rec.request_id == rec.session.task_id + + +def test_recognition_same_object_restarts_with_fresh_state_after_close(): + sockets = [] + + def connector(url, headers): + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + { + "output": { + "sentence": {"text": "hello", "end_time": 0}, + }, + "usage": {"duration": 1}, + }, + ), + _event("task-finished"), + ], + ) + sockets.append(ws) + return ws + + task_ids = [] + with _make_bare_client() as client: + rec = client.audio.asr.recognize( + model="fun-asr-realtime", + connector=connector, + ) + for _ in range(2): + with rec: + task_ids.append(rec.session.task_id) + assert rec.usage is None + assert rec.first_package_delay is None + assert rec.last_package_delay is None + assert rec.last_response["header"]["event"] == "task-started" + rec.send_audio_frame(b"audio") + rec.stop() + assert len(list(rec.results())) == 1 + assert rec.usage == {"duration": 1} + assert rec.first_package_delay >= 0 + assert rec.last_package_delay >= 0 + rec.close() + assert task_ids[0] != task_ids[1] + assert len(sockets) == 2 and all(ws.closed for ws in sockets) + + +@pytest.mark.parametrize("raw", [None, "", "peer-close"]) +def test_recognition_eof_before_terminal_is_not_success(raw): + from websocket import WebSocketConnectionClosedException + + class PeerCloseSocket(FakeWebSocket): + def recv(self): + value = super().recv() + if value == "peer-close": + raise WebSocketConnectionClosedException("peer closed") + return value + + ws = PeerCloseSocket([_event("task-started"), raw]) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + with pytest.raises( + APIConnectionError, + match="before task-finished", + ): + list(rec.events()) + assert ws.closed + + +@pytest.mark.parametrize( + "raw, error_type", + [ + ("{", json.JSONDecodeError), + ("[]", WsEventError), + ('{"header": []}', WsEventError), + ], +) +def test_recognition_invalid_frame_raises_and_closes(raw, error_type): + ws = FakeWebSocket([_event("task-started"), raw]) + with _make_bare_client() as client: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + with pytest.raises(error_type): + next(rec.events()) + assert ws.closed + + +@pytest.mark.parametrize("phase", ["start", "result"]) +def test_recognition_rejects_foreign_task_id(phase): + class ForeignTaskSocket(FakeWebSocket): + def send(self, payload, opcode=1): + super().send(payload, opcode=opcode) + if ( + opcode == 1 + and json.loads(payload)["header"]["action"] == "run-task" + ): + task_id = json.loads(payload)["header"]["task_id"] + self.inbox.append( + json.dumps( + { + "header": { + "event": "task-started", + "task_id": "foreign-task" + if phase == "start" + else task_id, + }, + "payload": {}, + }, + ), + ) + self.inbox.append( + json.dumps( + { + "header": { + "event": "task-finished", + "task_id": "foreign-task", + }, + "payload": {}, + }, + ), + ) + + ws = ForeignTaskSocket() + with _make_bare_client() as client: + with pytest.raises(WsEventError): + with client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) as rec: + if phase == "result": + next(rec.events()) + assert ws.closed + + +@pytest.mark.parametrize( + "action", + ["run-task", "continue-task", "finish-task"], +) +def test_recognition_control_send_failure_closes_and_can_restart(action): + class BrokenControlSocket(FakeWebSocket): + def send(self, payload, opcode=1): + if ( + opcode == 1 + and json.loads(payload)["header"]["action"] == action + ): + raise OSError("control send failed") + super().send(payload, opcode=opcode) + + broken = BrokenControlSocket([_event("task-started")]) + healthy = FakeWebSocket([_event("task-started"), _event("task-finished")]) + sockets = iter([broken, healthy]) + with _make_bare_client() as client: + rec = client.audio.asr.recognize( + model="fun-asr-realtime", + connector=lambda url, headers: next(sockets), + ) + with pytest.raises(OSError, match="control send failed"): + with rec: + if action == "continue-task": + rec.update_context({"context": "next"}) + else: + rec.stop() + assert broken.closed + with rec: + rec.stop() + assert len(list(rec.events())) == 1 + assert healthy.closed + + +def test_recognize_file_close_after_partial_iteration_releases_socket( + tmp_path, +): + path = tmp_path / "local.pcm" + path.write_bytes(b"audio") + ws = FakeWebSocket( + [ + _event("task-started"), + _event( + "result-generated", + {"output": {"sentence": {"text": "partial"}}}, + ), + _event("task-finished"), + ], + ) + with _make_bare_client() as client: + events = client.audio.asr.recognize_file( + str(path), + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) + try: + assert next(events)["header"]["event"] == "result-generated" + finally: + events.close() + assert ws.closed + + +def test_recognize_file_removed_before_iteration_closes_opened_socket( + tmp_path, +): + path = tmp_path / "local.pcm" + path.write_bytes(b"audio") + ws = FakeWebSocket([_event("task-started")]) + with _make_bare_client() as client: + events = client.audio.asr.recognize_file( + str(path), + model="fun-asr-realtime", + connector=lambda url, headers: ws, + ) + path.unlink() + with pytest.raises(FileNotFoundError): + next(events) + assert ws.closed + + +@pytest.mark.parametrize("family", ["asr", "tts"]) +def test_audio_ws_call_auth_is_case_insensitive_and_isolated(family): + captured = [] + headers = { + "authorization": "Bearer old-key", + "x-dashscope-workspace": "old-workspace", + "User-Agent": "custom-agent", + } + original = dict(headers) + + def connector(url, actual): + captured.append(actual) + return FakeWebSocket([_event("task-started")]) + + with _make_bare_client() as client: + connect = ( + client.audio.asr.recognize + if family == "asr" + else client.audio.tts.connect + ) + with connect( + model="test-model", + connector=connector, + headers=headers, + api_key="call-key", + workspace="call-workspace", + ): + pass + with connect(model="test-model", connector=connector): + pass + first, second = [httpx.Headers(value) for value in captured] + assert first.get_list("authorization") == ["Bearer call-key"] + assert first.get_list("x-dashscope-workspace") == ["call-workspace"] + assert first.get_list("user-agent") == ["custom-agent"] + assert second.get_list("authorization") == ["Bearer sk-test"] + assert second.get_list("x-dashscope-workspace") == ["ws-test"] + assert headers == original diff --git a/tests/v2/test_audio_connection.py b/tests/v2/test_audio_connection.py index d93a593a..d8d962d4 100644 --- a/tests/v2/test_audio_connection.py +++ b/tests/v2/test_audio_connection.py @@ -10,6 +10,7 @@ import pytest from dashscope.v2.client import Client +from dashscope.v2.errors import DashScopeError BASE_URL = "https://api.test.local/api/v1" WS_URL = "wss://ws.test.local/api-ws/v1/inference" @@ -98,29 +99,128 @@ def test_ws_connection_factory_uses_client_config(): def test_recognition_sequential_reuse_on_shared_connection(): client, conn, connects = _make_client_and_connection() ws = connects[0]["ws"] - - for _ in range(2): - rec = client.audio.asr.recognize( - model="paraformer-realtime-v2", - connection=conn, + task_ids = [] + audio_frames = [bytes([index, index + 1]) for index in range(5)] + try: + for index, audio in enumerate(audio_frames): + rec = client.audio.asr.recognize( + model="qwen-audio-3.1-asr-flash-streaming", + connection=conn, + timeout=5, + ) + try: + rec.start() + task_id = rec.session.task_id + task_ids.append(task_id) + rec.send_audio_frame(audio) + assert conn._binary_uplink_owner is rec.session + result = AutoWebSocket._event( + "result-generated", + task_id, + { + "output": { + "sentence": {"text": f"recognized task {index}"}, + }, + }, + ) + ws.inbox.put(result) + rec.stop() + assert list(rec.events()) == [ + json.loads(result), + json.loads(AutoWebSocket._event("task-finished", task_id)), + ] + assert rec.session.finished + assert conn._binary_uplink_owner is None + finally: + rec.close() + assert not conn._tasks + assert not conn.closed and not ws.closed + assert conn._reader.is_alive() + + assert len(connects) == 1 + run_tasks = _run_task_frames(ws) + assert [frame["header"]["task_id"] for frame in run_tasks] == task_ids + assert len(set(task_ids)) == 5 + assert all( + frame["payload"]["model"] == "qwen-audio-3.1-asr-flash-streaming" + for frame in run_tasks ) - rec.start() - rec.send_audio_frame(b"\x00\x01") - rec.stop() - list(rec.events()) - rec.close() - assert not conn.closed - - assert len(connects) == 1 - run_tasks = _run_task_frames(ws) - assert len(run_tasks) == 2 - assert ( - run_tasks[0]["header"]["task_id"] != run_tasks[1]["header"]["task_id"] - ) - # Exactly one binary frame per recognition, both on the shared socket. - assert len([item for item in ws.sent if isinstance(item, bytes)]) == 2 - conn.close() - client.close() + assert [ + item for item in ws.sent if isinstance(item, bytes) + ] == audio_frames + finally: + try: + conn.close() + finally: + client.close() + assert conn.closed and ws.closed + assert not conn._reader.is_alive() + + +def test_synthesis_sequential_reuse_on_shared_connection(): + client, conn, connects = _make_client_and_connection() + ws = connects[0]["ws"] + task_ids = [] + texts = [f"Synthesize task {index}." for index in range(5)] + try: + for index, text in enumerate(texts): + tts = client.audio.tts.connect( + model="qwen-audio-3.1-tts-flash", + voice="longanhuan_v3.1", + connection=conn, + timeout=5, + ) + try: + tts.start() + task_id = tts.session.task_id + task_ids.append(task_id) + assert conn._binary_downlink_owner is tts.session + tts.send_text(text) + audio = f"synthesized-audio-{index}".encode("utf-8") + ws.inbox.put(audio) + tts.stop() + assert list(tts.audio_chunks()) == [audio] + assert tts.last_response == json.loads( + AutoWebSocket._event("task-finished", task_id), + ) + assert tts.session.finished + assert conn._binary_downlink_owner is None + finally: + tts.close() + assert not conn._tasks + assert conn._binary_uplink_owner is None + assert conn._binary_downlink_owner is None + assert not conn.closed and not ws.closed + assert conn._reader.is_alive() + + assert len(connects) == 1 + run_tasks = _run_task_frames(ws) + assert [frame["header"]["task_id"] for frame in run_tasks] == task_ids + assert len(set(task_ids)) == 5 + assert all( + frame["payload"]["model"] == "qwen-audio-3.1-tts-flash" + and frame["payload"]["parameters"]["voice"] == "longanhuan_v3.1" + for frame in run_tasks + ) + text_frames = [ + json.loads(item) + for item in ws.sent + if isinstance(item, str) + and json.loads(item)["header"].get("action") == "continue-task" + ] + assert [ + frame["header"]["task_id"] for frame in text_frames + ] == task_ids + assert [frame["payload"]["input"] for frame in text_frames] == [ + {"text": text} for text in texts + ] + finally: + try: + conn.close() + finally: + client.close() + assert conn.closed and ws.closed + assert not conn._reader.is_alive() def test_recognition_and_synthesis_concurrently_on_one_connection(): @@ -269,3 +369,183 @@ def connector(url, headers): assert sockets[0]["ws"].closed assert sockets[1]["ws"].closed client.close() + + +@pytest.fixture +def bounded_audio_connection(): + resources = [] + + def make(ws=None): + ws = AutoWebSocket() if ws is None else ws + client = Client(api_key="sk-test", base_url=BASE_URL, timeout=0.5) + try: + conn = client.ws_connection( + url=WS_URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + except BaseException: + client.close() + raise + resources.append((client, conn)) + return client, conn, ws + + yield make + for client, conn in resources: + try: + conn.close() + conn._reader.join(timeout=1) + assert not conn._reader.is_alive(), "websocket reader leaked" + finally: + client.close() + + +@pytest.mark.parametrize( + "reply", + ["task-failed", "result-generated", "timeout"], +) +def test_shared_recognition_start_failure_detaches_and_allows_retry( + bounded_audio_connection, + reply, +): + class FailOnceSocket(AutoWebSocket): + failed_once = False + + def send(self, payload, opcode=1): + if ( + opcode == 1 + and json.loads(payload)["header"]["action"] == "run-task" + ): + if not self.failed_once: + self.failed_once = True + self.sent.append(payload) + if reply != "timeout": + task_id = json.loads(payload)["header"]["task_id"] + self.inbox.put(self._event(reply, task_id)) + return + super().send(payload, opcode=opcode) + + client, conn, ws = bounded_audio_connection(FailOnceSocket()) + rec = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + timeout=0.05, + ) + try: + expected = TimeoutError if reply == "timeout" else DashScopeError + with pytest.raises(expected): + rec.start() + assert not conn._tasks + assert not conn.closed and not ws.closed + with pytest.raises(RuntimeError, match="not open"): + _ = rec.session + with rec: + rec.send_audio_frame(b"retry") + rec.stop() + assert list(rec.events())[-1]["header"]["event"] == "task-finished" + assert not conn._tasks + assert len(_run_task_frames(ws)) == 2 + finally: + rec.close() + + +def test_shared_recognition_same_object_restarts_and_releases_owners( + bounded_audio_connection, +): + client, conn, ws = bounded_audio_connection() + rec = client.audio.asr.recognize(model="fun-asr-realtime", connection=conn) + ids = [] + try: + for _ in range(2): + with rec: + ids.append(rec.session.task_id) + assert rec.usage is None and rec.first_package_delay is None + assert rec.last_package_delay is None + rec.send_audio_frame(b"audio") + ws.inbox.put( + ws._event( + "result-generated", + rec.session.task_id, + { + "output": { + "sentence": {"text": "hello", "end_time": 0}, + }, + "usage": {"duration": 1}, + }, + ), + ) + rec.stop() + assert len(list(rec.results())) == 1 + assert rec.usage == {"duration": 1} + assert not conn._tasks + assert conn._binary_uplink_owner is None + assert not conn.closed + assert ids[0] != ids[1] + assert len(_run_task_frames(ws)) == 2 + finally: + rec.close() + + +def test_shared_recognition_close_after_uplink_conflict_does_not_break_owner( + bounded_audio_connection, +): + client, conn, ws = bounded_audio_connection() + first = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + second = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + try: + first.start() + second.start() + first.send_audio_frame(b"first") + with pytest.raises(RuntimeError, match="binary uplink"): + second.send_audio_frame(b"blocked") + second.close() + first.send_audio_frame(b"still first") + first.stop() + assert list(first.events())[-1]["header"]["event"] == "task-finished" + with second: + second.send_audio_frame(b"now second") + second.stop() + list(second.events()) + assert [frame for frame in ws.sent if isinstance(frame, bytes)] == [ + b"first", + b"still first", + b"now second", + ] + assert not conn.closed + finally: + first.close() + second.close() + + +def test_shared_recognition_task_failure_keeps_other_task_usable( + bounded_audio_connection, +): + client, conn, ws = bounded_audio_connection() + first = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + second = client.audio.asr.recognize( + model="fun-asr-realtime", + connection=conn, + ) + try: + first.start() + second.start() + first.send_audio_frame(b"first") + ws.inbox.put(ws._event("task-failed", first.session.task_id)) + with pytest.raises(DashScopeError): + next(first.events()) + second.send_audio_frame(b"second") + second.stop() + assert list(second.events())[-1]["header"]["event"] == "task-finished" + assert not conn.closed + finally: + first.close() + second.close() diff --git a/tests/v2/test_audio_management.py b/tests/v2/test_audio_management.py index f9880751..f51386b3 100644 --- a/tests/v2/test_audio_management.py +++ b/tests/v2/test_audio_management.py @@ -18,7 +18,14 @@ import pytest from dashscope.v2 import Client -from dashscope.v2.errors import BadRequestError +from dashscope.v2.errors import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + InternalServerError, +) BASE_URL = "https://api.test.local/api/v1" @@ -279,8 +286,8 @@ def handler(request): parameters={"sample_rate": 24000, "response_format": "wav"}, ) assert created.voice == "announcer" - assert created.preview_audio is not None - # pylint: disable-next=unsubscriptable-object # Optional narrowed above + # The mock always returns a dict; pylint only sees Optional[Dict]. + # pylint: disable-next=unsubscriptable-object assert created.preview_audio["response_format"] == "wav" assert calls[0] == { "model": "qwen-voice-design", @@ -474,3 +481,599 @@ def handler(request): prefix="BAD PREFIX", vocabulary=[{"text": "x"}], ) + + +_MANAGEMENT_RESOURCES = ("vocabularies", "voices", "voice_design", "phrases") + + +@pytest.mark.parametrize( + "resource, method", + [ + (resource, method) + for resource in _MANAGEMENT_RESOURCES + for method in ("create", "get", "list", "update", "delete") + if (resource, method) != ("voice_design", "update") + ], +) +def test_management_all_actions_keep_real_response_headers(resource, method): + response = httpx.Response( + 200, + headers={"x-request-id": "real-id", "x-gateway": "test"}, + json={"output": {}, "headers": {"x-request-id": "body-id"}}, + ) + client = _make_client(lambda request: response) + create_args = { + "vocabularies": { + "target_model": "paraformer-v2", + "prefix": "test", + "vocabulary": [{"text": "test", "weight": 1}], + }, + "voices": { + "target_model": "cosyvoice-v3-flash", + "prefix": "test", + "url": "https://cdn.test/audio.wav", + }, + "voice_design": { + "target_model": "qwen-tts-vd", + "voice_prompt": "warm", + "preview_text": "hello", + }, + "phrases": {"model": "paraformer-v2", "phrases": {"test": 1}}, + } + args = () if method in ("create", "list") else ("resource-1",) + kwargs = create_args[resource] if method == "create" else {} + if method == "update": + kwargs = { + "vocabularies": {"vocabulary": [{"text": "test", "weight": 1}]}, + "voices": {"url": "https://cdn.test/audio.wav"}, + "phrases": create_args["phrases"], + }[resource] + try: + result = getattr(getattr(client.audio, resource), method)( + *args, + **kwargs, + ) + assert result.headers == dict(response.headers) + assert result.status_code == 200 + assert not result.is_error + result.headers["x-gateway"] = "changed" + assert response.headers["x-gateway"] == "test" + finally: + client.close() + client.transport.http_client.close() + + +@pytest.mark.parametrize( + "resource, wire_key, attribute, item", + [ + ("vocabularies", key, "vocabularies", {"vocabulary_id": "v-1"}) + for key in ("vocabularies", "vocabulary_list") + ] + + [ + (resource, key, "voices", {"voice": "voice-1", "enabled": False}) + for resource in ("voices", "voice_design") + for key in ("voices", "voice_list") + ] + + [ + ("phrases", key, "phrases", {"finetuned_output": "phrase-1"}) + for key in ("phrases", "jobs", "outputs") + ], +) +def test_management_list_aliases_and_envelope( + resource, + wire_key, + attribute, + item, +): + body = { + "request_id": "outer-id", + "status_code": 200, + "usage": {"count": 0}, + "output": { + wire_key: [item], + "request_id": "inner-id", + "total": 1, + "page_no": 2, + "page_size": 1, + "enabled": False, + }, + } + client = _make_client(lambda request: httpx.Response(200, json=body)) + result = getattr(client.audio, resource).list() + values = getattr(result, attribute) + if resource == "phrases": + assert values[0].phrase_id == "phrase-1" + assert result.total_count == 1 + assert (result.page_no, result.page_size) == (2, 1) + else: + assert values == [item] + assert result.request_id == "outer-id" + assert result.status_code == 200 + assert result.usage == {"count": 0} + assert result.enabled is False + assert not result.is_error + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("status", [200, 400, 401, 500]) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_management_error_envelope(resource, status, raise_on_error): + body = { + "code": "InvalidApiKey" if status == 401 else "InvalidParameter", + "message": "management rejected", + "request_id": "body-error-id", + "status_code": status, + } + client = _make_client( + lambda request: httpx.Response( + status, + json=body, + headers={"x-request-id": "header-error-id"}, + ), + raise_on_error=raise_on_error, + max_retries=0, + ) + call = getattr(client.audio, resource).list + if raise_on_error: + error_type = AuthenticationError if status == 401 else BadRequestError + with pytest.raises(error_type, match="management rejected") as caught: + call() + assert caught.value.code == body["code"] + assert caught.value.status_code == status + assert caught.value.request_id == "body-error-id" + assert caught.value.body == body + assert caught.value.headers["x-request-id"] == "header-error-id" + else: + result = call() + assert result.is_error + assert result.code == body["code"] + assert result.message == body["message"] + assert result.request_id == "body-error-id" + assert result.status_code == status + assert result.headers["x-request-id"] == "header-error-id" + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +def test_management_nonraising_http_failure_without_code_is_error(resource): + # A non-2xx response must not look like a successful empty resource list. + client = _make_client( + lambda request: httpx.Response(503, json={"message": "unavailable"}), + raise_on_error=False, + max_retries=0, + ) + result = getattr(client.audio, resource).list() + assert result.is_error + assert result.status_code == 503 + assert result.message == "unavailable" + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +def test_management_redirect_is_not_success(resource): + client = _make_client( + lambda request: httpx.Response( + 302, + json={}, + headers={"location": "https://other.test/"}, + ), + max_retries=0, + ) + with pytest.raises(APIStatusError) as caught: + getattr(client.audio, resource).list() + assert caught.value.status_code == 302 + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +def test_management_non_json_http_error_keeps_header_request_id(resource): + client = _make_client( + lambda request: httpx.Response( + 500, + text="gateway failed", + headers={"x-request-id": "gateway-id"}, + ), + max_retries=0, + ) + with pytest.raises(InternalServerError, match="gateway failed") as caught: + getattr(client.audio, resource).list() + assert caught.value.status_code == 500 + assert caught.value.request_id == "gateway-id" + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_management_malformed_json_is_not_suppressed(resource, raise_on_error): + client = _make_client( + lambda request: httpx.Response(200, content=b'{"output":'), + raise_on_error=raise_on_error, + ) + # Compatibility mode suppresses service errors, not parsing failures. + with pytest.raises(ValueError): + getattr(client.audio, resource).list() + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize( + "transport_error, sdk_error", + [ + (httpx.ConnectError, APIConnectionError), + (httpx.ReadTimeout, APITimeoutError), + ], +) +def test_management_transport_error_not_suppressed( + resource, + transport_error, + sdk_error, +): + def handler(request): + raise transport_error("offline", request=request) + + client = _make_client(handler, raise_on_error=False, max_retries=0) + with pytest.raises(sdk_error) as caught: + getattr(client.audio, resource).list() + assert isinstance(caught.value.__cause__, transport_error) + + +@pytest.mark.parametrize("method", ["create", "update"]) +def test_phrase_id_prefers_compiled_resource_over_job_id(method): + # The management guide §3 records this official dual-ID response shape. + output = {"job_id": "ft-job-1", "finetuned_output": "paraformer-v1-ft-1"} + client = _make_client( + lambda request: httpx.Response(200, json={"output": output}), + ) + args = ("paraformer-v1-ft-1",) if method == "update" else () + result = getattr(client.audio.phrases, method)( + *args, + model="paraformer-realtime-v1", + phrases={"下一首": 4}, + ) + assert result.phrase_id == output["finetuned_output"] + assert result.job_id == output["job_id"] + + +def test_phrases_list_maps_finetuned_outputs(): + client = _make_client( + lambda request: httpx.Response( + 200, + json={ + "output": { + "finetuned_outputs": [ + {"finetuned_output": "paraformer-v1-ft-1"}, + ], + "total": 1, + }, + }, + ), + ) + result = client.audio.phrases.list() + assert [phrase.phrase_id for phrase in result.phrases] == [ + "paraformer-v1-ft-1", + ] + assert result.total_count == 1 + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("value", [None, "", " ", False]) +def test_management_empty_id_rejected_without_network(resource, value): + def handler(request): + pytest.fail("invalid IDs must be rejected before HTTP") + + client = _make_client(handler) + for method in ("get", "delete"): + with pytest.raises(ValueError): + getattr(getattr(client.audio, resource), method)(value) + + +@pytest.mark.parametrize("phrases", [None, {}, False]) +def test_empty_phrases_rejected_without_network(phrases): + def handler(request): + pytest.fail("empty phrases must be rejected before HTTP") + + client = _make_client(handler) + for method, args in (("create", ()), ("update", ("phrase-1",))): + with pytest.raises(ValueError, match="phrases"): + getattr(client.audio.phrases, method)( + *args, + model="paraformer-realtime-v1", + phrases=phrases, + ) + + +@pytest.mark.parametrize("resource", ["vocabularies", "voices"]) +@pytest.mark.parametrize("prefix", [None, "", False]) +def test_management_empty_prefix_omitted_and_zero_page_preserved( + resource, + prefix, +): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + getattr(client.audio, resource).list( + prefix=prefix, + page_index=0, + page_size=0, + ) + assert "prefix" not in bodies[0]["input"] + assert bodies[0]["input"]["page_index"] == 0 + assert bodies[0]["input"]["page_size"] == 0 + + +@pytest.mark.parametrize("explicit", [False, True]) +def test_customization_optional_fields_and_caller_dicts(explicit): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + voice_extra = {"language_hints": ["en"], "max_prompt_audio_length": 10} + design_extra = {"language": "en", "preferred_name": "original"} + original = json.loads(json.dumps([voice_extra, design_extra])) + parameters = {} if explicit else None + client.audio.voices.create( + target_model="cosyvoice-v3-plus", + prefix="demo", + url="https://cdn.test/a.wav", + language_hints=[] if explicit else None, + max_prompt_audio_length=0 if explicit else None, + extra_input=voice_extra, + parameters=parameters, + ) + client.audio.voice_design.create( + target_model="qwen3-tts-vd-realtime-2026-01-15", + voice_prompt="低沉", + preview_text="你好", + language="" if explicit else None, + preferred_name="" if explicit else None, + extra_input=design_extra, + parameters=parameters, + ) + assert bodies[0]["input"]["language_hints"] == ([] if explicit else ["en"]) + assert bodies[0]["input"]["max_prompt_audio_length"] == ( + 0 if explicit else 10 + ) + assert bodies[1]["input"]["language"] == ("" if explicit else "en") + assert bodies[1]["input"]["preferred_name"] == ( + "" if explicit else "original" + ) + assert [voice_extra, design_extra] == original + for body in bodies: + if explicit: + assert body["parameters"] == {} + else: + assert "parameters" not in body + + +@pytest.mark.parametrize("resource", ["voices", "voice_design"]) +def test_customization_extra_input_cannot_change_create_action(resource): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + if resource == "voices": + client.audio.voices.create( + target_model="cosyvoice-v3-plus", + prefix="demo", + url="https://cdn.test/a.wav", + extra_input={"action": "delete_voice"}, + ) + expected_action = "create_voice" + else: + client.audio.voice_design.create( + target_model="qwen3-tts-vd-realtime-2026-01-15", + voice_prompt="低沉", + preview_text="你好", + extra_input={"action": "delete"}, + ) + expected_action = "create" + assert bodies[0]["input"]["action"] == expected_action + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("auth_header", ["Authorization", "authorization"]) +def test_management_call_auth_is_single_valued_and_does_not_leak( + resource, + auth_header, +): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + http_client = client.transport.http_client + http_client.headers.update( + { + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) + workspace_header = ( + "X-DashScope-WorkSpace" + if auth_header == "Authorization" + else "x-dashscope-workspace" + ) + headers = {auth_header: "Bearer header-key", workspace_header: "header-ws"} + original = dict(headers) + call = getattr(client.audio, resource).list + call(api_key="call-key", workspace="call-ws", headers=headers, timeout=0) + call(api_key=None, workspace=None, timeout=None) + assert requests[0].headers.get_list("authorization") == ["Bearer call-key"] + assert requests[0].headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert requests[0].extensions["timeout"]["read"] == 0 + assert requests[1].headers["authorization"] == "Bearer pool-key" + assert requests[1].headers["x-dashscope-workspace"] == "pool-ws" + assert ( + requests[1].extensions["timeout"]["read"] == http_client.timeout.read + ) + assert all(request.url.host == "api.test.local" for request in requests) + assert headers == original + + +@pytest.mark.parametrize("request_id", [None, "outer-id"]) +def test_designed_voice_keeps_raw_preview_and_unknown_fields(request_id): + # Preview audio is explicitly a raw dict, not the decoded SSE bytes API. + preview = {"data": "%%%", "sample_rate": 24000, "response_format": "wav"} + body = { + "request_id": request_id, + "output": { + "request_id": "inner-id", + "voice": "full-voice-id", + "preview_audio": preview, + "enabled": False, + "future_field": [], + }, + } + client = _make_client(lambda request: httpx.Response(200, json=body)) + result = client.audio.voice_design.get("full-voice-id") + assert result.voice == "full-voice-id" + assert result.preview_audio == preview + assert result.request_id == (request_id or "inner-id") + assert result.enabled is False + assert result.model_dump()["future_field"] == [] + + +@pytest.mark.parametrize("id_key", ["phrase_id", "job_id", "finetuned_output"]) +def test_phrase_single_id_alias_preserves_status_and_details(id_key): + client = _make_client( + lambda request: httpx.Response( + 200, + json={ + "output": { + id_key: "returned-id", + "status": "PENDING", + "hyper_parameters": {"phrase_list": {"下一首": 4}}, + }, + }, + ), + ) + result = client.audio.phrases.get("queried-id") + assert result.phrase_id == "returned-id" + assert result.status == "PENDING" + assert result.hyper_parameters == {"phrase_list": {"下一首": 4}} + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("status", [302, 503]) +@pytest.mark.parametrize("body_kind", ["json", "text"]) +def test_management_nonraising_failure_keeps_http_metadata( + resource, + status, + body_kind, +): + body = {"message": "unavailable", "status_code": 200} + kwargs = {"json": body} if body_kind == "json" else {"text": "unavailable"} + client = _make_client( + lambda request: httpx.Response( + status, + headers={"x-request-id": "header-id"}, + **kwargs, + ), + raise_on_error=False, + max_retries=0, + ) + result = getattr(client.audio, resource).list() + assert result.status_code == status + assert result.request_id == "header-id" + assert result.message == "unavailable" + assert result.code is None + assert result.is_error + + +@pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) +@pytest.mark.parametrize("status", [200, 400]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + ("", "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_management_request_id_precedence( + resource, + status, + raise_on_error, + outer_id, + inner_id, + expected_id, +): + body = { + "request_id": outer_id, + "output": {"request_id": inner_id}, + } + with _make_client( + lambda request: httpx.Response( + status, + json=body, + headers={"x-request-id": "header-id"}, + ), + raise_on_error=raise_on_error, + max_retries=0, + ) as client: + if raise_on_error and status == 400: + with pytest.raises(APIStatusError) as caught: + getattr(client.audio, resource).list() + assert caught.value.request_id == expected_id + assert caught.value.body == body + assert caught.value.status_code == status + else: + result = getattr(client.audio, resource).list() + assert result.request_id == expected_id + assert result.status_code == status + assert result.is_error == (status != 200) + + +@pytest.mark.parametrize( + "method", + ["create", "update", "get", "list", "delete"], +) +def test_phrase_methods_share_nonraising_http_metadata(method): + client = _make_client( + lambda request: httpx.Response( + 503, + text="unavailable", + headers={"x-request-id": "phrase-error"}, + ), + raise_on_error=False, + max_retries=0, + ) + args = ("phrase-id",) if method in {"update", "get", "delete"} else () + kwargs = ( + {"model": "paraformer-v1", "phrases": {"下一首": 4}} + if method in {"create", "update"} + else {} + ) + result = getattr(client.audio.phrases, method)(*args, **kwargs) + assert result.status_code == 503 + assert result.request_id == "phrase-error" + assert result.message == "unavailable" + assert result.is_error + + +def test_phrase_explicit_resource_id_wins_over_both_aliases(): + client = _make_client( + lambda request: httpx.Response( + 200, + json={ + "output": { + "phrase_id": "explicit-id", + "finetuned_output": "compiled-id", + "job_id": "job-id", + }, + }, + ), + ) + result = client.audio.phrases.get("queried-id") + assert result.phrase_id == "explicit-id" + assert result.finetuned_output == "compiled-id" + assert result.job_id == "job-id" diff --git a/tests/v2/test_audio_omni_session.py b/tests/v2/test_audio_omni_session.py index 4eca4fea..380c46b2 100644 --- a/tests/v2/test_audio_omni_session.py +++ b/tests/v2/test_audio_omni_session.py @@ -511,26 +511,6 @@ def connector(url, headers): session.close() -def test_every_session_opens_its_own_socket(client): - # Contract relied on by examples/04_real_world_scenarios/ - # realtime_audio_meeting_harness.py: realtime sessions never share a - # socket, so concurrent sessions need one dedicated connection each. - sockets = [FakeWebSocket(), FakeWebSocket()] - calls = [] - - def connector(url, headers): - calls.append((url, headers)) - return sockets[len(calls) - 1] - - first = client.audio.realtime.connect(model="omni", connector=connector) - second = client.audio.realtime.connect(model="omni", connector=connector) - with first, second: - assert len(calls) == 2 - first.send_event({"type": "ping"}) - assert len(sockets[0].sent) == 1 - assert sockets[1].sent == [] - - @pytest.mark.parametrize("timeout, expected", [(None, 12.5), (7, 7)]) def test_default_connector_applies_timeout_during_handshake( client, @@ -1067,7 +1047,12 @@ def test_disconnect_after_response_done_is_normal(opened): "qwen3.8-livetranslate-flash-realtime" + suffix for suffix in ("", "-2026-09-01") ] -_PATCH_MODELS = _QWEN_AUDIO_MODELS + _LIVE_MODELS + _LIVE38_MODELS +_OMNI38_MODELS = [ + "qwen3.8-omni-flash-realtime" + suffix for suffix in ("", "-2026-09-01") +] +_PATCH_MODELS = ( + _QWEN_AUDIO_MODELS + _LIVE_MODELS + _LIVE38_MODELS + _OMNI38_MODELS +) @pytest.fixture @@ -1099,6 +1084,12 @@ def test_new_protocol_updates_are_incremental(model_opened, model): "output": {"voice": "Cherry"}, }, } + elif model in _OMNI38_MODELS: + configured = { + "modalities": ["text", "audio"], + "turn_detection": {"type": "server_vad"}, + "audio": {"output": {"voice": "Cherry"}}, + } else: configured = { "modalities": ["text", "audio"], @@ -1146,6 +1137,12 @@ def test_new_protocol_updates_preserve_falsey_values(model_opened, model): "output": {"voice": None}, }, ) + elif model in _OMNI38_MODELS: + expected.update( + modalities=[], + turn_detection={}, + audio={"output": {"voice": None}}, + ) else: expected.update(modalities=[], voice=None, turn_detection={}) assert _payloads(ws) == [{"type": "session.update", "session": expected}] @@ -1837,6 +1834,16 @@ def receive(kind, at, **fields): "qwen-omni-turbo-realtime", _default_session(modalities=["text"], voice="Cherry"), ), + ( + "qwen3.5-omni-flash-realtime", + "qwen3.8-omni-flash-realtime-2026-09-01", + {"modalities": ["text"], "audio": {"output": {"voice": "Cherry"}}}, + ), + ( + "qwen3.8-omni-flash-realtime", + "qwen3.5-omni-flash-realtime", + _default_session(modalities=["text"], voice="Cherry"), + ), ], ) def test_url_model_selects_wire_protocol_without_changing_public_model( @@ -1879,3 +1886,1228 @@ def connector(url, headers): ws.inbox.append({"type": "session.finished"}) assert list(session.events()) == [{"type": "session.finished"}] assert session.model == model + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +def test_omni38_manual_audio_and_response(model_opened): + session, ws = model_opened + input_format = { + "type": "pcm", + "sample_rate": 16000, + "sample_format": "s16le", + "channels": 1, + "packing": "interleaved", + "channel_layout": "mono", + } + output_format = {"type": "pcm", "sample_rate": 24000} + session.update_session( + ["text", "audio"], + voice="longanlingxin", + input_audio_config=input_format, + output_audio_config=output_format, + enable_turn_detection=False, + ) + session.append_audio(b"audio") + session.commit() + session.create_response() + session.create_response( + modalities=["text"], + instructions="", + temperature=0, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": { + "modalities": ["text", "audio"], + "turn_detection": None, + "audio": { + "input": {"format": input_format}, + "output": { + "format": output_format, + "voice": "longanlingxin", + }, + }, + }, + }, + {"type": "input_audio_buffer.append", "audio": "YXVkaW8="}, + {"type": "input_audio_buffer.commit"}, + {"type": "response.create"}, + { + "type": "response.create", + "response": { + "modalities": ["text"], + "instructions": "", + "temperature": 0, + }, + }, + ] + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize( + "raw_audio, expected_audio", + [ + ( + {"input": {"format": {"type": "wav"}}, "output": {"voice": ""}}, + { + "input": {"format": {"type": "wav", "sample_rate": 16000}}, + "output": {"format": {"type": "pcm"}, "voice": ""}, + }, + ), + ({"input": None, "output": None}, {"input": None, "output": None}), + (None, None), + ], +) +def test_omni38_nested_overrides_leave_vad_at_top_level( + model_opened, + raw_audio, + expected_audio, +): + session, ws = model_opened + options = { + "input_audio_format": "pcm", + "sample_rate": 16000, + "output_audio_format": "pcm", + "voice": "longanlingxin", + "turn_detection_type": "semantic_vad", + "turn_detection_threshold": 0, + "audio": raw_audio, + } + original = copy.deepcopy(options) + session.update_session(**options) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": { + "turn_detection": {"type": "semantic_vad", "threshold": 0}, + "audio": expected_audio, + }, + }, + ] + assert options == original + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +def test_omni38_multichannel_and_extensions_are_not_mutated(model_opened): + session, ws = model_opened + audio_format = { + "type": "pcm", + "sample_rate": 16000, + "sample_format": "s16le", + "channels": 2, + "packing": "interleaved", + "channel_layout": "raw_mic_array", + } + tools = [{"type": "function", "name": "get_weather", "parameters": {}}] + original = copy.deepcopy([audio_format, tools]) + session.update_session( + input_audio_config=audio_format, + input_audio_format="wav", + sample_rate=8000, + input_video_representation_compact="normal", + tools=tools, + ) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": { + "audio": {"input": {"format": audio_format}}, + "video": {"input": {"representation_compact": "normal"}}, + "tools": tools, + }, + }, + ] + assert [audio_format, tools] == original + approval = { + "type": "mcp_approval_request", + "id": "approval-1", + "server_label": "example", + "name": "get_weather", + "arguments": "{}", + } + ws.inbox.append({"type": "response.output_item.done", "item": approval}) + assert session.recv_event()["item"] == approval + response = { + "type": "mcp_approval_response", + "approval_request_id": "approval-1", + "approve": False, + } + session.create_item(response) + assert _payloads(ws)[-1] == { + "type": "conversation.item.create", + "item": response, + } + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize( + "options, expected", + [ + ( + {"enable_input_audio_transcription": True}, + {"model": "qwen3-asr-flash-realtime"}, + ), + ({"enable_input_audio_transcription": False}, None), + ({"transcription_params": {}}, {}), + ({"input_audio_transcription_model": None}, {"model": None}), + ( + { + "enable_input_audio_transcription": True, + "input_audio_transcription": None, + }, + None, + ), + ], +) +def test_omni38_transcription_is_explicit(model_opened, options, expected): + session, ws = model_opened + session.update_session(**options) + assert _payloads(ws) == [ + { + "type": "session.update", + "session": {"input_audio_transcription": expected}, + }, + ] + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize( + "eof", + [None, "", b"", WebSocketConnectionClosedException("closed")], +) +@pytest.mark.parametrize("completed", [False, True]) +def test_omni38_finish_needs_response_done_not_session_finished( + model_opened, + eof, + completed, +): + session, ws = model_opened + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + session.end_session() + session.end_session() + assert _payloads(ws) == [{"type": "session.finish"}] + assert ws.close_calls == 0 + with pytest.raises(RuntimeError, match="finishing"): + session.append_audio(b"late") + if completed: + ws.inbox.append({"type": "response.done", "response": {"id": "r1"}}) + ws.inbox.append(eof) + if completed: + assert len(list(session.events())) == 1 + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + list(session.events()) + assert ws.close_calls == 1 + + +# Audit cases assert the protocol and lifecycle contracts. +# All sockets are local stubs; no credentials or cloud are used. +_AUDIT_MODELS = [ + "qwen3.5-omni-flash-realtime", + _OMNI38_MODELS[0], + _QWEN_AUDIO_MODELS[0], + _LIVE_MODELS[0], + _LIVE38_MODELS[0], +] + + +@pytest.mark.parametrize("model", _AUDIT_MODELS[1:]) +def test_audit_patch_snapshot_survives_caller_mutation(model_opened, model): + session, ws = model_opened + options = { + "input_audio_config": {"type": "pcm", "sample_rate": 0}, + "output_audio_config": {"type": "opus", "channels": 1}, + "translation_params": {"language": "", "corpus": {"text": []}}, + "transcription_params": {"language": None, "corpus_text": ""}, + "turn_detection_param": {"threshold": 0, "create_response": False}, + "tools": [{"name": "tool", "parameters": {"required": []}}], + } + original = copy.deepcopy(options) + session.update_session(**options) + assert options == original + expected = { + "audio": { + "input": {"format": original["input_audio_config"]}, + "output": {"format": original["output_audio_config"]}, + }, + "translation": original["translation_params"], + "input_audio_transcription": { + "language": None, + "corpus": {"text": ""}, + }, + "tools": original["tools"], + } + if model in _LIVE38_MODELS: + expected["audio"]["input"]["turn_detection"] = original[ + "turn_detection_param" + ] + else: + expected["turn_detection"] = original["turn_detection_param"] + options["input_audio_config"]["sample_rate"] = 48000 + options["translation_params"]["corpus"]["text"].append("later") + options["tools"][0]["parameters"]["required"].append("later") + session.update_session(instructions="next") + assert _payloads(ws) == [ + {"type": "session.update", "session": expected}, + {"type": "session.update", "session": {"instructions": "next"}}, + ] + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "kind", + ["session.update", "response.create", "session.finish"], +) +def test_audit_serialization_failure_does_not_poison_session( + model_opened, + kind, +): + session, ws = model_opened + event = {"type": kind, "extension": {"not-json": {"a", "b"}}} + original = copy.deepcopy(event) + with pytest.raises(TypeError): + session.send_event(event) + assert event == original + assert ws.sent == [] + assert ws.close_calls == 0 + session.commit() + assert _payloads(ws) == [{"type": "input_audio_buffer.commit"}] + assert session.recv_event() is None + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "operation", + ["append_audio", "update_session", "create_response", "cancel_response"], +) +def test_audit_helper_send_failure_preserves_error_and_inputs( + model_opened, + operation, +): + session, ws = model_opened + failure = ConnectionResetError("audit send failed") + ws.send_error = failure + ws.close_error = OSError("cleanup must not mask send") + options = {"metadata": {"values": [0, False, None]}} + original = copy.deepcopy(options) + with pytest.raises(ConnectionResetError) as caught: + if operation == "append_audio": + session.append_audio(b"audio") + elif operation == "update_session": + session.update_session(["text"], **options) + elif operation == "create_response": + session.create_response(**options) + else: + session.cancel_response() + assert caught.value is failure + assert options == original + assert ws.sent == [] + assert ws.close_calls == 1 + session.close() + assert ws.close_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.commit() + + +@pytest.mark.parametrize("stage", ["send", "recv", "settimeout"]) +@pytest.mark.parametrize("failure_type", [KeyboardInterrupt, SystemExit]) +def test_audit_base_exception_cleanup_and_reconnect( + client, + stage, + failure_type, +): + failed, fresh = FakeWebSocket(), FakeWebSocket() + failure = failure_type("interrupted") + if stage == "settimeout": + failed.timeout_error = failure + elif stage == "send": + failed.send_error = failure + else: + failed.inbox.append(failure) + sockets = iter([failed, fresh]) + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: next(sockets), + ) + try: + with pytest.raises(failure_type) as caught: + session.connect() + if stage == "send": + session.commit() + elif stage == "recv": + session.recv_event() + assert caught.value is failure + assert failed.close_calls == 1 + session.connect() + session.commit() + assert _payloads(fresh) == [{"type": "input_audio_buffer.commit"}] + assert session.last_message is None + assert session.last_response_id is None + finally: + session.close() + assert failed.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize("use_default", [False, True]) +def test_audit_settimeout_failure_after_previous_response_resets_state( + client, + monkeypatch, + use_default, +): + first, failed, fresh = (FakeWebSocket() for _ in range(3)) + failed.timeout_error = OSError("cannot set timeout") + failed.close_error = OSError("cleanup failed") + sockets = iter([first, failed, fresh]) + options = {} + if use_default: + monkeypatch.setattr( + "websocket.create_connection", + lambda *a, **kw: next(sockets), + ) + else: + options["connector"] = lambda url, headers: next(sockets) + session = client.audio.realtime.connect(model=_OMNI38_MODELS[0], **options) + try: + session.connect() + first.inbox.append( + {"type": "response.created", "response": {"id": "old"}}, + ) + session.recv_event() + session.end_session() + session.close() + with pytest.raises(OSError, match="cannot set timeout"): + session.connect() + assert failed.close_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.recv_event() + session.connect() + assert session.last_message is None + assert session.last_response_id is None + assert session.last_response_create_time is None + assert session.last_first_text_delay is None + assert session.last_first_audio_delay is None + session.commit() + assert session.recv_event() is None + finally: + session.close() + assert first.close_calls == failed.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize("active", [False, True]) +@pytest.mark.parametrize( + "raw, error_type", + [ + ("null", ValueError), + ("false", ValueError), + ("0", ValueError), + ('"text"', ValueError), + ('[{"type": "response.done"}]', ValueError), + ('{"type": "response.done"} trailing', json.JSONDecodeError), + (" ", json.JSONDecodeError), + (bytearray(b"\xff"), UnicodeDecodeError), + ], +) +def test_audit_invalid_frames_close_without_masking_parse_error( + opened, + active, + raw, + error_type, +): + session, ws = opened + previous = {"type": "response.created", "response": {"id": "r1"}} + if active: + ws.inbox.append(previous) + session.recv_event() + ws.inbox.append(raw) + ws.close_error = OSError("cleanup failed") + with pytest.raises(error_type): + session.recv_event() + assert ws.close_calls == 1 + assert session.last_message == (previous if active else None) + session.close() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("error", ["bad", ["bad"], 1, True, "", [], 0, False]) +def test_audit_malformed_error_object_is_rejected_and_closes(opened, error): + session, ws = opened + ws.inbox.append({"type": "error", "error": error}) + ws.close_error = OSError("cleanup failed") + # Falsey non-objects are invalid too, not an absent error object. + with pytest.raises(ValueError, match="JSON error object"): + session.recv_event() + assert ws.close_calls == 1 + with pytest.raises(RuntimeError, match="not open"): + session.commit() + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("completed", [False, True]) +def test_audit_cancel_still_requires_matching_response_done( + model_opened, + completed, +): + session, ws = model_opened + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + session.cancel_response() + assert _payloads(ws) == [{"type": "response.cancel"}] + assert ws.close_calls == 0 + ws.inbox.append({"type": "response.done", "response": {"id": "stale"}}) + session.recv_event() + if completed: + done = { + "type": "response.done", + "response": { + "id": "r1", + "status": "cancelled", + "status_details": { + "type": "cancelled", + "reason": "client_cancelled", + }, + }, + } + ws.inbox.append(done) + assert session.recv_event() == done + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +def test_audit_failed_response_done_remains_raw_terminal_event(model_opened): + session, ws = model_opened + done = { + "type": "response.done", + "response": { + "id": "r1", + "status": "failed", + "status_details": { + "type": "failed", + "error": { + "code": "InternalError", + "message": "generation failed", + }, + }, + }, + } + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + done, + ], + ) + assert list(session.events())[-1] == done + assert session.last_message == done + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +def test_audit_recoverable_error_keeps_active_response_tracking(model_opened): + session, ws = model_opened + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "cannot cancel this request", + }, + }, + ], + ) + session.recv_event() + started = session.last_response_create_time + session.cancel_response() + with pytest.raises(BadRequestError, match="cannot cancel"): + session.recv_event() + assert ws.close_calls == 0 + assert session.last_response_create_time == started + assert session.last_response_id == "r1" + session.commit() + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +def test_audit_fatal_error_during_response_preserves_server_failure( + model_opened, +): + session, ws = model_opened + failure = { + "type": "error", + "error": { + "type": "server_error", + "code": "Throttling", + "message": "busy", + }, + } + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + failure, + ], + ) + session.recv_event() + ws.close_error = OSError("cleanup failed") + with pytest.raises(RateLimitError, match="busy") as caught: + session.recv_event() + assert caught.value.body == failure + assert session.last_message == failure + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS[:3]) +@pytest.mark.parametrize("raw_send", [False, True]) +def test_audit_requested_response_cannot_disappear_before_created( + model_opened, + raw_send, +): + session, ws = model_opened + if raw_send: + session.send_event({"type": "response.create"}) + else: + session.create_response() + # A successful send followed by EOF is not a successful empty response. + with pytest.raises(APIConnectionError, match="response"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _OMNI38_MODELS) +@pytest.mark.parametrize("request_finish", [False, True]) +def test_audit_omni38_optional_session_ack_cannot_replace_response_done( + model_opened, + request_finish, +): + session, ws = model_opened + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + if request_finish: + session.end_session() + ws.inbox.append({"type": "session.finished"}) + with pytest.raises(APIConnectionError, match="before response.done"): + list(session.events()) + assert ws.close_calls == 1 + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + + +@pytest.mark.parametrize( + "wire_model, needs_ack", + [ + ("qwen3.5-omni-flash-realtime", True), + (_OMNI38_MODELS[0], False), + (_LIVE_MODELS[0], True), + (_LIVE38_MODELS[0], True), + ], +) +def test_audit_finish_ack_uses_wire_protocol_even_without_response( + client, + wire_model, + needs_ack, +): + ws = FakeWebSocket() + with client.audio.realtime.connect( + model=_QWEN_AUDIO_MODELS[0], + ws_url=f"wss://example.test/realtime?model={wire_model}", + connector=lambda url, headers: ws, + ) as session: + session.end_session() + session.end_session() + assert _payloads(ws) == [{"type": "session.finish"}] + if needs_ack: + with pytest.raises( + APIConnectionError, + match="before session.finished", + ): + session.recv_event() + else: + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "model", + [_AUDIT_MODELS[0], _OMNI38_MODELS[0], _LIVE38_MODELS[0]], +) +@pytest.mark.parametrize( + "operation", + ["commit", "cancel_response", "create_response", "clear_appended_audio"], +) +def test_audit_finish_rejects_other_sends_but_keeps_reader( + model_opened, + operation, +): + session, ws = model_opened + session.end_session() + with pytest.raises(RuntimeError, match="finishing"): + getattr(session, operation)() + assert _payloads(ws) == [{"type": "session.finish"}] + assert ws.close_calls == 0 + ws.inbox.append({"type": "session.finished"}) + assert session.recv_event() == {"type": "session.finished"} + session.close() + session.close() + assert ws.close_calls == 1 + + +def _audit_start_call(call): + import threading + from concurrent.futures import Future + + result = Future() + + def run(): + try: + result.set_result(call()) + except BaseException as error: + result.set_exception(error) + + thread = threading.Thread(target=run, daemon=True) + thread.start() + return thread, result + + +def test_audit_concurrent_connect_and_close_are_single_shot(client): + import threading + + barrier = threading.Barrier(2, timeout=2) + ws = FakeWebSocket() + calls = [] + + def connector(url, headers): + calls.append(url) + return ws + + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=connector, + ) + + def connect(): + barrier.wait() + return session.connect() + + workers = [_audit_start_call(connect) for _ in range(2)] + try: + results = [] + for thread, future in workers: + thread.join(2) + assert not thread.is_alive() + try: + results.append(future.result(timeout=0)) + except RuntimeError as error: + assert "already open" in str(error) + assert results == [session] + assert len(calls) == 1 + assert ws.close_calls == 0 + + def close(): + barrier.wait() + session.close() + + workers = [_audit_start_call(close) for _ in range(2)] + for thread, future in workers: + thread.join(2) + assert not thread.is_alive() + future.result(timeout=0) + finally: + session.close() + assert ws.close_calls == 1 + + +def test_audit_concurrent_receivers_cannot_reorder_response_tracking( + opened, + monkeypatch, +): + import threading + + session, ws = opened + entered, release = threading.Event(), threading.Event() + created = {"type": "response.created", "response": {"id": "r1"}} + done = {"type": "response.done", "response": {"id": "r1"}} + ws.inbox.extend([created, done]) + track = session._track_event + + def delayed_track(event): + if event["type"] == "response.created": + entered.set() + assert release.wait(2), "test did not release first reader" + track(event) + + monkeypatch.setattr(session, "_track_event", delayed_track) + workers = [_audit_start_call(session.recv_event)] + second_started, second_finished = threading.Event(), threading.Event() + + def second_read(): + second_started.set() + try: + return session.recv_event() + finally: + second_finished.set() + + try: + assert entered.wait(2) + workers.append(_audit_start_call(second_read)) + assert second_started.wait(2) + assert not second_finished.wait(0.1) + assert ws.recv_calls == 1, "second reader read before first tracked" + finally: + release.set() + for thread, _ in workers: + thread.join(2) + assert not thread.is_alive() + assert workers[0][1].result(timeout=0) == created + assert workers[1][1].result(timeout=0) == done + assert session.last_message == done + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize( + "old_result", + [ + None, + ConnectionResetError("old socket failed"), + "{invalid", + {"type": "session.created", "session": {"id": "stale"}}, + {"type": "response.created", "response": {"id": "stale"}}, + {"type": "session.finished"}, + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "event_id": "pending", + }, + }, + ], +) +@pytest.mark.parametrize("reuse_socket", [False, True]) +def test_audit_old_reader_cannot_close_or_contaminate_reconnected_session( + client, + old_result, + reuse_socket, +): + import threading + + entered, release = threading.Event(), threading.Event() + + class DelayedSocket(FakeWebSocket): + def recv(self): + entered.set() + assert release.wait(2), "test did not release old socket" + return super().recv() + + old = DelayedSocket([old_result]) + # A custom connector may return the same wrapper on a new connection. + fresh = old if reuse_socket else FakeWebSocket() + sockets = iter([old, fresh]) + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: next(sockets), + ) + session.connect() + thread, future = _audit_start_call(session.recv_event) + try: + assert entered.wait(2) + session.close() + session.connect() + session.send_event({"type": "response.create", "event_id": "pending"}) + release.set() + thread.join(2) + assert not thread.is_alive() + # The retired read may report cancellation/transport/parse failure; + # none of these may act on the newly installed connection. + try: + future.result(timeout=0) + except (RuntimeError, APIConnectionError, OSError, ValueError): + pass + assert fresh.close_calls == int(reuse_socket) + assert session.session_id is None + assert session.last_message is None + assert session.last_response_id is None + session.commit() + assert ( + json.loads(fresh.sent[-1])["type"] == "input_audio_buffer.commit" + ) + # Neither a stale created nor a stale rejection may consume this. + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + finally: + release.set() + thread.join(2) + session.close() + assert not thread.is_alive() + if reuse_socket: + assert old.close_calls == 2 + else: + assert old.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize( + "code", + [["InvalidParameter"], {"bad": "code"}, [], {}, 0, False, 1, True], +) +def test_audit_malformed_recoverable_error_code_cannot_leak_socket( + opened, + code, +): + session, ws = opened + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": code, + "message": "invalid wire code", + }, + }, + ) + ws.close_error = OSError("cleanup failed") + with pytest.raises(ValueError, match="string error code"): + session.recv_event() + assert ws.close_calls == 1 + + +def test_audit_blocked_reader_does_not_block_sender_or_close(client): + import threading + + entered, release = threading.Event(), threading.Event() + + class DelayedSocket(FakeWebSocket): + def recv(self): + entered.set() + assert release.wait(2), "test did not release reader" + return super().recv() + + ws = DelayedSocket() + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: ws, + ) + session.connect() + workers = [_audit_start_call(session.recv_event)] + + def send_and_close(): + session.commit() + session.close() + + try: + assert entered.wait(2) + workers.append(_audit_start_call(send_and_close)) + workers[1][1].result(timeout=1) + assert not release.is_set() + assert _payloads(ws) == [{"type": "input_audio_buffer.commit"}] + assert ws.close_calls == 1 + finally: + release.set() + for thread, _ in workers: + thread.join(2) + assert not thread.is_alive() + session.close() + assert workers[0][1].result(timeout=0) is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("raw_send", [False, True]) +@pytest.mark.parametrize("cancelled", [False, True]) +def test_rejected_create_allows_another_response( + model_opened, + raw_send, + cancelled, +): + session, ws = model_opened + if raw_send: + session.send_event( + { + "type": "response.create", + "event_id": "rejected-create", + "response": {"instructions": 123}, + }, + ) + else: + session.create_response(instructions=123) + request_id = json.loads(ws.sent[-1])["event_id"] + rejected = { + "type": "error", + "event_id": "server-error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "instructions must be a string", + "event_id": request_id, + }, + } + ws.inbox.append(rejected) + with pytest.raises(BadRequestError) as caught: + session.recv_event() + assert caught.value.body == rejected + assert ws.close_calls == 0 + assert session.last_response_id is None + + session.commit() + session.create_response() + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + assert session.recv_event()["type"] == "response.created" + if cancelled: + session.cancel_response() + done = { + "type": "response.done", + "response": { + "id": "r1", + "status": "cancelled" if cancelled else "completed", + }, + } + ws.inbox.append(done) + assert session.recv_event() == done + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "error_request", + [None, "update", "cancel", "consumed-create"], +) +def test_unrelated_error_cannot_consume_pending_create( + model_opened, + error_request, +): + session, ws = model_opened + session.send_event( + {"type": "response.create", "event_id": "consumed-create"}, + ) + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + {"type": "response.done", "response": {"id": "r1"}}, + ], + ) + session.recv_event() + session.recv_event() + session.send_event({"type": "session.update", "event_id": "update"}) + session.send_event({"type": "response.create", "event_id": "pending"}) + session.send_event({"type": "response.cancel", "event_id": "cancel"}) + error = { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "unrelated rejection", + } + if error_request is not None: + error["event_id"] = error_request + # Even a matching outer ID identifies a server event, not a client request. + ws.inbox.append({"type": "error", "event_id": "pending", "error": error}) + with pytest.raises(BadRequestError, match="unrelated rejection"): + session.recv_event() + assert ws.close_calls == 0 + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("order", [(0, 1, 2), (2, 0, 1)]) +def test_multiple_rejected_creates_are_correlated_individually( + model_opened, + order, +): + session, ws = model_opened + for index in range(3): + session.send_event( + { + "type": "response.create", + "event_id": f"create-{index}", + "response": {"instructions": 123}, + }, + ) + for index in order: + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "event_id": f"create-{index}", + }, + }, + ) + with pytest.raises(BadRequestError): + session.recv_event() + assert ws.close_calls == 0 + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize( + "second_outcome", + ["missing", "rejected", "completed"], +) +def test_created_consumes_only_one_pending_request( + model_opened, + second_outcome, +): + session, ws = model_opened + session.send_event({"type": "response.create", "event_id": "first"}) + session.send_event({"type": "response.create", "event_id": "second"}) + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r1"}}, + {"type": "response.done", "response": {"id": "r1"}}, + ], + ) + session.recv_event() + session.recv_event() + if second_outcome == "rejected": + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "event_id": "second", + }, + }, + ) + with pytest.raises(BadRequestError): + session.recv_event() + elif second_outcome == "completed": + ws.inbox.extend( + [ + {"type": "response.created", "response": {"id": "r2"}}, + {"type": "response.done", "response": {"id": "r2"}}, + ], + ) + session.recv_event() + session.recv_event() + if second_outcome == "missing": + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + else: + assert session.recv_event() is None + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("stage", ["before-created", "before-done", "done"]) +def test_cancel_before_created_does_not_discard_pending_request( + model_opened, + stage, +): + session, ws = model_opened + session.create_response() + session.cancel_response() + if stage != "before-created": + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + ws.inbox.append({"type": "response.done", "response": {"id": "stale"}}) + session.recv_event() + if stage == "done": + ws.inbox.append( + { + "type": "response.done", + "response": {"id": "r1", "status": "cancelled"}, + }, + ) + session.recv_event() + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("request_finish", [False, True]) +def test_omni38_optional_ack_cannot_hide_pending_create( + client, + request_finish, +): + ws = FakeWebSocket([{"type": "session.finished"}]) + with client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: ws, + ) as session: + session.create_response() + if request_finish: + session.end_session() + with pytest.raises(APIConnectionError, match="before response.done"): + list(session.events()) + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 + + +@pytest.mark.parametrize("send_failure", [False, True]) +def test_reconnect_clears_pending_create_state(client, send_failure): + first, fresh = FakeWebSocket(), FakeWebSocket() + sockets = iter([first, fresh]) + session = client.audio.realtime.connect( + model=_OMNI38_MODELS[0], + connector=lambda url, headers: next(sockets), + ) + with session: + if send_failure: + first.send_error = ConnectionResetError("create failed") + with pytest.raises(ConnectionResetError, match="create failed"): + session.create_response() + assert session.recv_event() is None + else: + session.create_response() + with pytest.raises( + APIConnectionError, + match="before response.done", + ): + session.recv_event() + with session: + assert session.last_response_id is None + assert session.last_message is None + assert session.recv_event() is None + assert first.close_calls == fresh.close_calls == 1 + + +@pytest.mark.parametrize("model", _AUDIT_MODELS) +@pytest.mark.parametrize("completed", [False, True]) +def test_rejected_create_does_not_complete_an_active_response( + model_opened, + completed, +): + session, ws = model_opened + session.create_response() + ws.inbox.append({"type": "response.created", "response": {"id": "r1"}}) + session.recv_event() + session.send_event({"type": "response.create", "event_id": "second"}) + ws.inbox.append( + { + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "InvalidParameter", + "message": "response already in progress", + "event_id": "second", + }, + }, + ) + with pytest.raises(BadRequestError, match="already in progress"): + session.recv_event() + assert ws.close_calls == 0 + if completed: + ws.inbox.append({"type": "response.done", "response": {"id": "r1"}}) + session.recv_event() + assert session.recv_event() is None + else: + with pytest.raises(APIConnectionError, match="before response.done"): + session.recv_event() + assert ws.close_calls == 1 diff --git a/tests/v2/test_audio_samples.py b/tests/v2/test_audio_samples.py new file mode 100644 index 00000000..d4d30f21 --- /dev/null +++ b/tests/v2/test_audio_samples.py @@ -0,0 +1,1579 @@ +# -*- coding: utf-8 -*- +"""Offline wire and output checks for the protocol-specific audio examples.""" + +import base64 +import importlib +import json +import wave +from contextlib import contextmanager, nullcontext +from pathlib import Path +from queue import Queue +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import httpx +import pytest + +from dashscope import Client +from dashscope.v2.errors import DashScopeError + + +def _sample(name): + # ``01_atomic_capabilities`` is not a valid identifier in source-level + # imports, so the samples are loaded through importlib instead. + return importlib.import_module( + f"examples.01_atomic_capabilities.audio.{name}", + ) + + +asr_file = _sample("asr_file") +asr_http_stream = _sample("asr_http_stream") +asr_microphone = _sample("asr_microphone") +connection_reuse = _sample("connection_reuse") +realtime_omni = _sample("realtime_omni") +realtime_qwen_audio = _sample("realtime_qwen_audio") +realtime_translation = _sample("realtime_translation") +tts_http = _sample("tts_http") +tts_http_stream = _sample("tts_http_stream") +tts_websocket = _sample("tts_websocket") +voice_clone = _sample("voice_clone") +asr_file_direct_call = _sample("direct_call.asr_file") +asr_http_stream_direct_call = _sample("direct_call.asr_http_stream") +connection_reuse_direct_call = _sample("direct_call.connection_reuse") +realtime_omni_direct_call = _sample("direct_call.realtime_omni") +realtime_qwen_audio_direct_call = _sample("direct_call.realtime_qwen_audio") +realtime_translation_direct_call = _sample("direct_call.realtime_translation") +tts_http_direct_call = _sample("direct_call.tts_http") +tts_http_stream_direct_call = _sample("direct_call.tts_http_stream") +tts_websocket_direct_call = _sample("direct_call.tts_websocket") +voice_clone_direct_call = _sample("direct_call.voice_clone") + + +BASE_URL = "https://api.test.local/api/v1" +HTTP_CLIENT = httpx.Client +PCM = b"\x01\x00\xff\x7f" * 64 + + +def attach_client(monkeypatch, module, handler): + responses = [] + + def respond(request): + response = handler(request) + responses.append(response) + return response + + transport = httpx.MockTransport(respond) + http_client = HTTP_CLIENT(transport=transport, base_url=BASE_URL) + client = Client( + api_key="sk-test", + workspace="ws-test", + base_url=BASE_URL, + http_client=http_client, + max_retries=0, + ) + monkeypatch.setattr(module, "Client", lambda **kwargs: client) + return responses + + +def assert_wav(path, pcm=PCM): + with wave.open(str(path), "rb") as audio: + assert audio.getnchannels() == 1 + assert audio.getsampwidth() == 2 + assert audio.getframerate() == 24000 + assert audio.readframes(audio.getnframes()) == pcm + + +def sse(parts): + return "".join("data: " + json.dumps(part) + "\n\n" for part in parts) + + +def audio_part(pcm): + return {"output": {"audio": {"data": base64.b64encode(pcm).decode()}}} + + +def assert_tts_request(request, streaming): + assert request.method == "POST" + assert request.url.path == "/api/v1/services/audio/tts/SpeechSynthesizer" + assert request.headers.get("x-dashscope-sse") == ( + "enable" if streaming else None + ) + body = json.loads(request.content) + assert body == { + "model": "qwen-audio-3.1-tts-flash", + "input": { + "text": "你好", + "voice": "longanhuan_v3.1", + "format": "pcm", + "sample_rate": 24000, + }, + } + + +@pytest.mark.parametrize("module", [tts_http, tts_http_direct_call]) +def test_http_tts_download_has_no_sdk_credentials( + monkeypatch, + tmp_path, + module, +): + def handler(request): + assert_tts_request(request, False) + return httpx.Response( + 200, + json={ + "request_id": "tts-http-test", + "output": { + "audio": {"url": "http://cdn.test/a.pcm?signature=test"}, + }, + }, + ) + + responses = attach_client(monkeypatch, module, handler) + downloads = [] + + def download(request): + assert str(request.url) == "https://cdn.test/a.pcm?signature=test" + assert "authorization" not in request.headers + assert "x-dashscope-workspace" not in request.headers + downloads.append(request) + return httpx.Response(200, content=PCM) + + downloader = HTTP_CLIENT(transport=httpx.MockTransport(download)) + monkeypatch.setattr(module.httpx, "Client", lambda **kwargs: downloader) + path = tmp_path / "http.wav" + module.main(["--text", "你好", "--output", str(path), "--no-play"]) + assert_wav(path) + assert len(downloads) == 1 + assert downloader.is_closed + assert all(response.is_closed for response in responses) + + +@pytest.mark.parametrize( + "module", + [tts_http_stream, tts_http_stream_direct_call], +) +def test_http_tts_sse_appends_pcm_and_closes(monkeypatch, tmp_path, module): + def handler(request): + assert_tts_request(request, True) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse( + [ + audio_part(PCM[:1]), + audio_part(PCM[1:]), + {"output": {"finish_reason": "stop"}}, + ], + ), + ) + + responses = attach_client(monkeypatch, module, handler) + path = tmp_path / "sse.wav" + module.main(["--text", "你好", "--output", str(path), "--no-play"]) + assert_wav(path) + assert len(responses) == 1 + assert responses[0].is_closed + + +@pytest.mark.parametrize( + "module", + [tts_http_stream, tts_http_stream_direct_call], +) +@pytest.mark.parametrize( + "parts", + [ + [audio_part(PCM)], + [{"output": {"finish_reason": "stop"}}], + [audio_part(b"\x01"), {"output": {"finish_reason": "stop"}}], + [ + audio_part(b"RIFF\x00\x00\x00\x00WAVEdata"), + {"output": {"finish_reason": "stop"}}, + ], + ], +) +def test_http_tts_sse_rejects_incomplete_audio( + monkeypatch, + tmp_path, + parts, + module, +): + responses = attach_client( + monkeypatch, + module, + lambda request: httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse(parts), + ), + ) + with pytest.raises(module.SampleError): + module.main( + [ + "--text", + "你好", + "--output", + str(tmp_path / "bad.wav"), + "--no-play", + ], + ) + assert responses[0].is_closed + + +@pytest.mark.parametrize( + "module", + [ + tts_http, + tts_http_direct_call, + tts_http_stream, + tts_http_stream_direct_call, + ], +) +def test_http_tts_output_exists_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created before output preflight") + + monkeypatch.setattr(module, "Client", forbidden) + path = tmp_path / "existing.wav" + path.write_bytes(b"keep") + with pytest.raises(FileExistsError): + module.main(["--text", "你好", "--output", str(path), "--no-play"]) + assert path.read_bytes() == b"keep" + + +@pytest.mark.parametrize( + "module", + [ + tts_http, + tts_http_direct_call, + tts_http_stream, + tts_http_stream_direct_call, + ], +) +def test_http_tts_empty_input_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created with empty text") + + monkeypatch.setattr(module, "Client", forbidden) + path = tmp_path / "unused.wav" + with pytest.raises(SystemExit) as caught: + module.main(["--text", " \n", "--output", str(path), "--no-play"]) + assert caught.value.code == 2 + assert not path.exists() + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +@pytest.mark.parametrize("resume", [False, True]) +def test_asr_file_http_submit_or_resume(monkeypatch, tmp_path, resume, module): + calls = [] + + def handler(request): + calls.append(request) + if request.method == "POST": + assert ( + request.url.path == "/api/v1/services/audio/asr/transcription" + ) + assert request.headers["x-dashscope-async"] == "enable" + assert json.loads(request.content) == { + "model": "qwen-audio-3.1-asr-flash-filetrans", + "input": {"file_urls": ["https://cdn.test/input.wav"]}, + "parameters": {}, + } + return httpx.Response( + 200, + json={ + "output": { + "task_id": "file-task", + "task_status": "PENDING", + }, + }, + ) + assert request.method == "GET" + assert request.url.path == "/api/v1/tasks/file-task" + return httpx.Response( + 200, + json={ + "request_id": "file-request", + "output": { + "task_id": "file-task", + "task_status": "SUCCEEDED", + "results": [ + { + "subtask_status": "SUCCEEDED", + "transcription_url": "https://cdn.test/transcript.json", + }, + ], + }, + }, + ) + + responses = attach_client(monkeypatch, module, handler) + client = module.Client() + close = Mock(wraps=client.close) + monkeypatch.setattr(client, "close", close) + + def download(request): + assert "authorization" not in request.headers + assert "x-dashscope-workspace" not in request.headers + return httpx.Response(200, json={"transcripts": [{"text": "识别结果。"}]}) + + downloader = HTTP_CLIENT(transport=httpx.MockTransport(download)) + monkeypatch.setattr(module.httpx, "Client", lambda **kwargs: downloader) + folder = tmp_path / "result" + source = ( + ["--task-id", "file-task"] + if resume + else [ + "--url", + "https://cdn.test/input.wav", + ] + ) + assert module.main([*source, "--output-dir", str(folder)]) == 0 + metadata = json.loads((folder / "task.json").read_text()) + assert metadata["task_id"] == "file-task" + assert metadata["status"] == "SUCCEEDED" + assert (folder / "001.txt").read_text() == "识别结果。\n" + assert sum(request.method == "POST" for request in calls) == ( + 0 if resume else 1 + ) + assert all(response.is_closed for response in responses) + assert downloader.is_closed + close.assert_called_once_with() + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +@pytest.mark.parametrize("error_type", [DashScopeError, KeyboardInterrupt]) +def test_asr_file_wait_failure_preserves_id_and_closes( + monkeypatch, + tmp_path, + error_type, + module, +): + def handler(request): + assert request.method == "POST" + return httpx.Response( + 200, + json={ + "output": {"task_id": "saved-task", "task_status": "PENDING"}, + }, + ) + + responses = attach_client(monkeypatch, module, handler) + client = module.Client() + close = Mock(wraps=client.close) + monkeypatch.setattr(client, "close", close) + outputs = [] + checkpoint = module.checkpoint + + def save(output, metadata): + outputs.append(output) + checkpoint(output, metadata) + + monkeypatch.setattr(module, "checkpoint", save) + folder = tmp_path / "interrupted" + + def wait(job, *, timeout): + assert job.task_id == "saved-task" + assert timeout == 600.0 + assert not outputs[0].closed + metadata = json.loads((folder / "task.json").read_text()) + assert metadata["task_id"] == "saved-task" + assert metadata["status"] == "PENDING" + raise error_type("wait interrupted") + + monkeypatch.setattr(client.audio.asr.jobs, "wait", wait) + with pytest.raises(error_type): + module.main( + [ + "--url", + "https://cdn.test/input.wav", + "--output-dir", + str(folder), + ], + ) + assert len(responses) == 1 + assert responses[0].is_closed + assert len(outputs) == 1 + assert outputs[0].closed + assert ( + json.loads((folder / "task.json").read_text())["task_id"] + == "saved-task" + ) + close.assert_called_once_with() + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +def test_asr_file_output_exists_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created before output preflight") + + monkeypatch.setattr(module, "Client", forbidden) + folder = tmp_path / "existing" + folder.mkdir() + checkpoint = folder / "task.json" + checkpoint.write_text("keep", encoding="utf-8") + with pytest.raises(FileExistsError): + module.main( + [ + "--task-id", + "saved-task", + "--output-dir", + str(folder), + ], + ) + assert checkpoint.read_text() == "keep" + + +@pytest.mark.parametrize("module", [asr_file, asr_file_direct_call]) +def test_asr_file_single_url_before_request(monkeypatch, tmp_path, module): + def forbidden(**kwargs): + pytest.fail("Client created before single-URL validation") + + monkeypatch.setattr(module, "Client", forbidden) + folder = tmp_path / "unused" + with pytest.raises(SystemExit) as caught: + module.main( + [ + "--url", + "https://cdn.test/one.wav", + "--url", + "https://cdn.test/two.wav", + "--output-dir", + str(folder), + ], + ) + assert caught.value.code == 2 + assert not folder.exists() + + +@pytest.mark.parametrize( + "module", + [asr_http_stream, asr_http_stream_direct_call], +) +@pytest.mark.parametrize("local_file", [False, True]) +def test_rest_asr_wire_and_cumulative_revisions( + monkeypatch, + tmp_path, + local_file, + module, +): + fixture = tmp_path / "input.wav" + with wave.open(str(fixture), "wb") as audio: + audio.setnchannels(1) + audio.setsampwidth(2) + audio.setframerate(16000) + audio.writeframes(PCM) + + def handler(request): + assert request.method == "POST" + assert request.url.path == ( + "/api/v1/services/aigc/multimodal-generation/generation" + ) + assert request.headers["x-dashscope-sse"] == "enable" + body = json.loads(request.content) + assert body["model"] == "qwen-audio-3.1-asr-flash" + assert body["parameters"] == {"format": "wav", "sample_rate": "16000"} + part = body["input"]["messages"][0]["content"][0] + assert part["type"] == "input_audio" + data = part["input_audio"]["data"] + if local_file: + assert data.startswith("data:audio/wav;base64,") + assert ( + base64.b64decode(data.split(",", 1)[1]) == fixture.read_bytes() + ) + else: + assert data == "https://cdn.test/input.wav" + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse( + [ + {"output": {"text": "可能被修订的长文本"}}, + { + "output": { + "text": "你好", + "sentence": { + "sentence_end": True, + }, + }, + }, + { + "output": { + "text": "你好。再见。", + "sentence": { + "sentence_end": True, + }, + }, + }, + ], + ), + ) + + responses = attach_client(monkeypatch, module, handler) + transcript = tmp_path / "rest.txt" + events = tmp_path / "rest.jsonl" + source = ( + ["--file", str(fixture)] + if local_file + else [ + "--url", + "https://cdn.test/input.wav", + ] + ) + assert ( + module.main( + [ + *source, + "--transcript", + str(transcript), + "--events", + str(events), + ], + ) + == 0 + ) + assert transcript.read_text() == "你好。再见。" + assert len(events.read_text().splitlines()) == 3 + assert responses[0].is_closed + + +@pytest.mark.parametrize( + "module", + [asr_http_stream, asr_http_stream_direct_call], +) +@pytest.mark.parametrize( + "parts,error", + [ + ([{"code": "InvalidParameter", "message": "bad"}], DashScopeError), + ([{"output": {}}], RuntimeError), + ([{"output": {"text": 42}}], ValueError), + ([], RuntimeError), + ], +) +def test_rest_asr_errors_close_response( + monkeypatch, + tmp_path, + parts, + error, + module, +): + responses = attach_client( + monkeypatch, + module, + lambda request: httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + text=sse(parts), + ), + ) + with pytest.raises(error): + module.main( + [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + str(tmp_path / "rest.txt"), + ], + ) + assert responses[0].is_closed + + +@pytest.mark.parametrize("save_events", [False, True]) +@pytest.mark.parametrize("source", ["text", "file", "prompt"]) +def test_websocket_tts_always_uses_duplex( + monkeypatch, + tmp_path, + source, + save_events, +): + parts = ["你好", " ", "世界"] + stopped = Event() + session = Mock( + session=SimpleNamespace(request_id="duplex-request"), + first_package_delay=1.0, + usage={"characters": 5}, + ) + final = {"header": {"event": "task-finished"}} + + def frames(): + yield PCM[:128] + assert stopped.wait(2) + yield PCM[128:] + yield final + + session.events.side_effect = frames + session.stop.side_effect = stopped.set + session.close.side_effect = stopped.set + connect = Mock(return_value=nullcontext(session)) + llm_stream = SimpleNamespace(text_stream=iter(parts), close=Mock()) + create = Mock(return_value=llm_stream) + client = SimpleNamespace( + audio=SimpleNamespace(tts=SimpleNamespace(connect=connect)), + chat=SimpleNamespace(completions=SimpleNamespace(create=create)), + ) + monkeypatch.setattr( + tts_websocket, + "Client", + lambda **kwargs: nullcontext(client), + ) + if source == "text": + source_args = [ + "--text", + parts[0], + "--text", + parts[1], + "--text", + parts[2], + ] + elif source == "file": + parts = ["你好\n", " \n", "世界\n"] + text_file = tmp_path / "source.txt" + text_file.write_text("".join(parts), encoding="utf-8") + source_args = ["--text-file", str(text_file)] + else: + source_args = ["--prompt", "请打个招呼"] + path = tmp_path / "duplex.wav" + events = tmp_path / "events.jsonl" + event_args = ["--events", str(events)] if save_events else [] + tts_websocket.main( + [ + *source_args, + "--output", + str(path), + *event_args, + "--no-play", + ], + ) + connect.assert_called_once_with( + model="qwen-audio-3.1-tts-flash", + voice="longanhuan_v3.1", + format="pcm", + sample_rate=24000, + timeout=30.0, + enable_ssml=False, + ) + assert [call.args[0] for call in session.send_text.call_args_list] == parts + session.stop.assert_called_once() + session.close.assert_called_once() + assert_wav(path) + if save_events: + assert json.loads(events.read_text()) == final + else: + assert not events.exists() + if source == "prompt": + create.assert_called_once() + llm_stream.close.assert_called_once() + else: + create.assert_not_called() + + +def test_websocket_tts_send_error_closes_before_join(): + closed = Event() + reader_saw_close = [] + session = Mock() + + def frames(): + reader_saw_close.append(closed.wait(2)) + yield from () + + session.events.side_effect = frames + session.send_text.side_effect = RuntimeError("send failed") + session.close.side_effect = closed.set + client = SimpleNamespace( + audio=SimpleNamespace( + tts=SimpleNamespace( + connect=Mock(return_value=nullcontext(session)), + ), + ), + ) + args = tts_websocket.make_parser().parse_args( + [ + "--text", + "你好", + "--output", + "unused.wav", + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match="send failed"): + tts_websocket.synthesize(client, args, ["你好"], Mock(), None) + assert reader_saw_close == [True] + session.close.assert_called_once() + + +@pytest.mark.parametrize("module", [tts_websocket, tts_websocket_direct_call]) +def test_websocket_tts_has_no_mode_option(module): + parser = module.make_parser() + with pytest.raises(SystemExit) as caught: + parser.parse_args( + [ + "--mode", + "duplex", + "--text", + "你好", + "--output", + "unused.wav", + ], + ) + assert caught.value.code == 2 + + +@pytest.mark.parametrize( + "module", + [asr_http_stream, asr_http_stream_direct_call], +) +def test_rest_asr_existing_output_prevents_request( + monkeypatch, + tmp_path, + module, +): + def forbidden(**kwargs): + pytest.fail("Client created before output preflight") + + monkeypatch.setattr(module, "Client", forbidden) + transcript = tmp_path / "existing.txt" + transcript.write_text("keep") + with pytest.raises(FileExistsError): + module.main( + [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + str(transcript), + ], + ) + assert transcript.read_text() == "keep" + + +@pytest.fixture +def opened_files(monkeypatch): + files = [] + open_file = Path.open + + def tracked_open(path, *args, **kwargs): + file = open_file(path, *args, **kwargs) + files.append(file) + return file + + monkeypatch.setattr(Path, "open", tracked_open) + yield files + assert files + assert all(file.closed for file in files) + + +@pytest.mark.parametrize( + "module,arguments", + [ + (module, arguments) + for modules, arguments in [ + ( + (asr_http_stream, asr_http_stream_direct_call), + [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + "rest.txt", + "--events", + "events.jsonl", + ], + ), + ( + (tts_websocket, tts_websocket_direct_call), + [ + "--text-file", + "input.txt", + "--output", + "tts.wav", + "--events", + "events.jsonl", + "--no-play", + ], + ), + ( + (realtime_omni, realtime_omni_direct_call), + [ + "--wav", + "input.wav", + "--output", + "reply.wav", + "--no-play", + ], + ), + ( + (realtime_qwen_audio, realtime_qwen_audio_direct_call), + [ + "--wav", + "input.wav", + "--output", + "reply.wav", + "--no-play", + ], + ), + ( + (realtime_translation, realtime_translation_direct_call), + [ + "--wav", + "input.wav", + "--output", + "reply.wav", + "--no-play", + ], + ), + ( + (connection_reuse, connection_reuse_direct_call), + [ + "asr", + "input.wav", + "--output-dir", + "reuse", + ], + ), + ( + (connection_reuse, connection_reuse_direct_call), + [ + "tts", + "--output-dir", + "reuse", + ], + ), + ( + (voice_clone, voice_clone_direct_call), + ["list", "--output", "voices.json"], + ), + ] + for module in modules + ], +) +def test_audio_client_failure_closes_files( + monkeypatch, + tmp_path, + opened_files, + module, + arguments, +): + with wave.open(str(tmp_path / "input.wav"), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM * 16) + (tmp_path / "input.txt").write_text("你好\n", encoding="utf-8") + source_open = wave.open + readers = [] + + def track_source(path, mode=None): + audio = source_open(path, mode) + if mode == "rb": + readers.append(audio) + return audio + + monkeypatch.setattr(wave, "open", track_source) + client = Mock(side_effect=RuntimeError("client failed")) + monkeypatch.setattr(module, "Client", client) + filenames = { + "input.wav", + "input.txt", + "rest.txt", + "events.jsonl", + "tts.wav", + "reply.wav", + "reuse", + "voices.json", + } + argv = [ + str(tmp_path / arg) if arg in filenames else arg for arg in arguments + ] + with pytest.raises(RuntimeError, match="client failed"): + module.main(argv) + client.assert_called_once() + assert all(reader._file is None for reader in readers) + assert all(file.closed for file in opened_files) + + +@pytest.mark.parametrize( + "module", + [ + asr_http_stream, + asr_http_stream_direct_call, + tts_websocket, + tts_websocket_direct_call, + ], +) +def test_audio_existing_events_prevents_request( + monkeypatch, + tmp_path, + opened_files, + module, +): + events = tmp_path / "events.jsonl" + events.write_text("keep", encoding="utf-8") + client = Mock(side_effect=AssertionError("unexpected client")) + monkeypatch.setattr(module, "Client", client) + if module in (asr_http_stream, asr_http_stream_direct_call): + arguments = [ + "--url", + "https://cdn.test/input.wav", + "--transcript", + str(tmp_path / "rest.txt"), + ] + else: + arguments = [ + "--text", + "你好", + "--output", + str(tmp_path / "tts.wav"), + "--no-play", + ] + with pytest.raises(FileExistsError): + module.main([*arguments, "--events", str(events)]) + client.assert_not_called() + assert events.read_text() == "keep" + + +def test_websocket_llm_failure_closes_stream( + monkeypatch, + tmp_path, + opened_files, +): + stream = SimpleNamespace(text_stream=iter(["你好"]), close=Mock()) + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace( + create=Mock(return_value=stream), + ), + ), + ) + closed = [] + + @contextmanager + def client_context(**kwargs): + try: + yield client + finally: + closed.append(True) + + monkeypatch.setattr(tts_websocket, "Client", client_context) + monkeypatch.setattr( + tts_websocket, + "synthesize", + Mock( + side_effect=RuntimeError("synthesis failed"), + ), + ) + with pytest.raises(RuntimeError, match="synthesis failed"): + tts_websocket.main( + [ + "--prompt", + "你好", + "--output", + str(tmp_path / "tts.wav"), + "--events", + str(tmp_path / "events.jsonl"), + "--no-play", + ], + ) + stream.close.assert_called_once() + assert closed == [True] + + +@pytest.mark.parametrize("record", [False, True]) +@pytest.mark.parametrize("fail", [False, True]) +def test_microphone_optional_recording_cleanup( + monkeypatch, + tmp_path, + opened_files, + record, + fail, +): + pcm = b"\x01\x00" * 1600 + stream = Mock() + stream.read.return_value = (pcm, False) + closed = [] + + @contextmanager + def microphone(*args, **kwargs): + try: + yield stream + finally: + closed.append(True) + + monkeypatch.setattr(asr_microphone, "load_sounddevice", Mock()) + monkeypatch.setattr(asr_microphone, "microphone", microphone) + session = Mock() + receiver = Mock() + receiver.done.return_value = False + + def recognize(args, sample_rate, transcript, send_audio): + assert sample_rate == 16000 + if fail: + raise RuntimeError("recognition failed") + send_audio(session, receiver) + transcript.write("你好\n") + + monkeypatch.setattr(asr_microphone, "run_recognition", recognize) + recording = tmp_path / "recording.wav" + transcript = tmp_path / "microphone.txt" + record_args = ( + ["--recording", str(recording)] if record else ["--no-recording"] + ) + result = asr_microphone.main( + [ + "--seconds", + "0.1", + "--transcript", + str(transcript), + *record_args, + ], + ) + assert result == (1 if fail else 0) + assert closed == [True] + assert recording.exists() == record + if not fail: + session.send_audio_frame.assert_called_once_with(pcm) + assert transcript.read_text() == "你好\n" + if record: + with wave.open(str(recording), "rb") as audio: + assert audio.readframes(audio.getnframes()) == ( + b"" if fail else pcm + ) + + +@pytest.mark.parametrize("save", [False, True]) +@pytest.mark.parametrize("status", ["OK", "DEPLOYING"]) +def test_voice_get_optional_output_cleanup( + monkeypatch, + tmp_path, + save, + status, +): + voice = SimpleNamespace( + status=status, + model_dump=Mock( + return_value={"voice_id": "voice-test", "status": status}, + ), + ) + get = Mock(return_value=voice) + client = SimpleNamespace( + config=SimpleNamespace(region="cn-beijing", workspace="ws-test"), + audio=SimpleNamespace(voices=SimpleNamespace(get=get)), + ) + closed = [] + + @contextmanager + def client_context(**kwargs): + try: + yield client + finally: + closed.append(True) + + monkeypatch.setattr(voice_clone, "Client", client_context) + path = tmp_path / "voice.json" + opened = [] + checkpoint = voice_clone.checkpoint + + def save_result(output, data, response): + opened.append(output) + checkpoint(output, data, response) + + monkeypatch.setattr(voice_clone, "checkpoint", save_result) + output_args = ["--output", str(path)] if save else [] + result = voice_clone.main( + ["get", "--voice-id", "voice-test", *output_args], + ) + assert result == (0 if status == "OK" else 1) + get.assert_called_once_with("voice-test") + assert closed == [True] + assert path.exists() == save + if save: + assert opened[0].closed + assert json.loads(path.read_text())["result"]["status"] == status + else: + assert opened == [None] + + +@pytest.mark.parametrize( + "module,model", + [ + (realtime_omni, "qwen3.8-omni-flash-realtime"), + (realtime_qwen_audio, "qwen-audio-3.0-realtime-plus"), + (realtime_translation, "qwen3.8-livetranslate-flash-realtime"), + ], +) +@pytest.mark.parametrize("use_wav", [False, True]) +def test_realtime_source_and_context_cleanup( + monkeypatch, + tmp_path, + opened_files, + use_wav, + module, + model, +): + session = Mock() + session.recv_event.side_effect = [ + {"type": "session.updated"}, + { + "type": "response.audio.delta", + "delta": base64.b64encode(PCM).decode(), + }, + {"type": "response.done", "response": {"status": "completed"}}, + {"type": "session.finished"}, + ] + closed = [] + + @contextmanager + def connect(**kwargs): + assert kwargs["model"] == model + try: + yield session + finally: + closed.append("session") + + @contextmanager + def client_context(**kwargs): + try: + yield SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=connect, + ), + ), + ) + finally: + closed.append("client") + + monkeypatch.setattr(module, "Client", client_context) + sounddevice = Mock() + monkeypatch.setattr(module, "load_sounddevice", lambda: sounddevice) + send = Mock() + monkeypatch.setattr(module, "send_input", send) + path = tmp_path / "reply.wav" + if use_wav: + source = tmp_path / "input.wav" + with wave.open(str(source), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM * 16) + source_args = ["--wav", str(source)] + else: + source_args = ["--microphone"] + module.main([*source_args, "--output", str(path), "--no-play"]) + assert_wav(path) + if module is realtime_translation: + session.commit.assert_not_called() + session.create_response.assert_not_called() + session.end_session.assert_called_once() + session.update_session.assert_called_once_with( + output_modalities=["text", "audio"], + translation_params={"language": "en"}, + ) + else: + session.commit.assert_called_once() + session.create_response.assert_called_once() + session.end_session.assert_not_called() + send.assert_called_once() + input_audio = send.call_args.args[3] + if use_wav: + assert input_audio._file is None + sounddevice.check_input_settings.assert_not_called() + else: + assert input_audio is None + sounddevice.check_input_settings.assert_called_once() + assert closed == ["session", "client"] + session.close.assert_called_once() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +@pytest.mark.parametrize("fail", [False, True]) +def test_reuse_source_and_context_cleanup( + monkeypatch, + tmp_path, + opened_files, + kind, + fail, +): + connection = SimpleNamespace(closed=False) + connection.close = Mock( + side_effect=lambda: setattr(connection, "closed", True), + ) + connect = Mock(return_value=connection) + closed = [] + + @contextmanager + def client_context(**kwargs): + try: + yield SimpleNamespace( + ws_connection=connect, + config=SimpleNamespace( + region="cn-beijing", + workspace="ws-test", + ), + ) + finally: + closed.append("client") + + monkeypatch.setattr(connection_reuse, "Client", client_context) + sources = [] + + def run_one(client, shared, args, *extra): + assert shared is connection + assert not shared.closed + index = extra[-1] + if kind == "asr": + sources.append(extra[0]) + assert extra[0].getframerate() == 16000 + if fail and index == 2: + raise RuntimeError("task failed") + return {"task_id": f"task-{index}"} + + monkeypatch.setattr(connection_reuse, "recognize_one", run_one) + monkeypatch.setattr(connection_reuse, "synthesize_one", run_one) + source_args = [] + if kind == "asr": + source = tmp_path / "input.wav" + with wave.open(str(source), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM) + source_args = [str(source)] + folder = tmp_path / "reuse" + argv = [kind, *source_args, "--output-dir", str(folder)] + if fail: + with pytest.raises(RuntimeError, match="task failed"): + connection_reuse.main(argv) + else: + assert connection_reuse.main(argv) == 0 + result = json.loads((folder / "summary.json").read_text()) + assert result["status"] == ("failed" if fail else "completed") + assert len(result["runs"]) == (1 if fail else 5) + assert result["connection_closed"] is True + assert result["connection_count"] == 1 + connect.assert_called_once() + assert connection.closed + assert closed == ["client"] + if kind == "asr": + assert all(source is sources[0] for source in sources) + assert sources[0]._file is None + + +@pytest.mark.parametrize( + "module,model,settings", + [ + (module, model, settings) + for modules, model, settings in [ + ( + (realtime_omni, realtime_omni_direct_call), + "qwen3.8-omni-flash-realtime", + { + "modalities": ["text", "audio"], + "audio": { + "input": { + "format": {"type": "pcm", "sample_rate": 16000}, + }, + "output": { + "format": {"type": "pcm", "sample_rate": 24000}, + "voice": "longanlingxin", + }, + }, + "input_audio_transcription": None, + "turn_detection": None, + "instructions": "请用简洁自然的中文回答。", + }, + ), + ( + (realtime_qwen_audio, realtime_qwen_audio_direct_call), + "qwen-audio-3.0-realtime-plus", + { + "modalities": ["text", "audio"], + "voice": "longanqian", + "input_audio_format": "pcm", + "output_audio_format": "pcm", + "turn_detection": None, + "instructions": "请用简洁自然的中文回答。", + }, + ), + ( + (realtime_translation, realtime_translation_direct_call), + "qwen3.8-livetranslate-flash-realtime", + { + "output_modalities": ["text", "audio"], + "translation": {"language": "en"}, + }, + ), + ] + for module in modules + ], +) +def test_realtime_examples_sdk_wire( + monkeypatch, + tmp_path, + module, + model, + settings, +): + translating = module in ( + realtime_translation, + realtime_translation_direct_call, + ) + inbox = Queue() + inbox.put({"type": "session.created", "session": {"id": "test-session"}}) + sent = [] + urls = [] + + def response(identifier): + inbox.put({"type": "response.created", "response": {"id": identifier}}) + inbox.put( + {"type": "response.audio_transcript.delta", "delta": "Hello"}, + ) + inbox.put( + { + "type": "response.audio.delta", + "delta": base64.b64encode(PCM).decode(), + }, + ) + inbox.put( + {"type": "response.audio_transcript.done", "transcript": "Hello"}, + ) + inbox.put( + { + "type": "response.done", + "response": { + "id": identifier, + "status": "completed", + "usage": {}, + }, + }, + ) + + def send(payload): + event = json.loads(payload) + sent.append(event) + if event["type"] == "session.update": + inbox.put( + {"type": "session.updated", "session": {"id": "test-session"}}, + ) + elif event["type"] == "input_audio_buffer.append": + if ( + translating + and sum( + item["type"] == "input_audio_buffer.append" + for item in sent + ) + == 1 + ): + response("translation-1") + elif event["type"] == "response.create": + response("reply-1") + elif event["type"] == "session.finish": + response("translation-2") + inbox.put( + { + "type": "session.finished", + "session": {"id": "test-session"}, + }, + ) + + def recv(): + event = inbox.get(timeout=2) + return json.dumps(event) if event is not None else None + + ws = SimpleNamespace( + send=send, + recv=recv, + settimeout=Mock(), + close=Mock(side_effect=lambda: inbox.put(None)), + ) + client = Client(api_key="sk-test", workspace="ws-test", max_retries=0) + connect = client.audio.realtime.connect + + def connector(url, headers): + urls.append(url) + return ws + + monkeypatch.setattr( + client.audio.realtime, + "connect", + lambda **kwargs: connect( + **kwargs, + connector=connector, + ), + ) + monkeypatch.setattr(module, "Client", lambda **kwargs: client) + source = tmp_path / "input.wav" + with wave.open(str(source), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM * 16) + output = tmp_path / "output.wav" + module.main(["--wav", str(source), "--output", str(output), "--no-play"]) + assert len(urls) == 1 + assert f"model={model}" in urls[0] + assert sent[0]["type"] == "session.update" + assert sent[0]["session"] == settings + audio_events = [ + event for event in sent if event["type"] == "input_audio_buffer.append" + ] + sent_audio = b"".join( + base64.b64decode(event["audio"]) for event in audio_events + ) + assert sent_audio == PCM * 16 + kinds = [event["type"] for event in sent] + if translating: + assert kinds[-1] == "session.finish" + assert "response.create" not in kinds + assert "input_audio_buffer.commit" not in kinds + else: + assert kinds[-2:] == ["input_audio_buffer.commit", "response.create"] + assert "session.finish" not in kinds + if module in (realtime_omni, realtime_omni_direct_call): + assert "response" not in sent[-1] + else: + assert sent[-1]["response"] == {} + assert_wav(output, PCM * (2 if translating else 1)) + ws.close.assert_called_once() + + +@pytest.mark.parametrize( + "module", + [ + realtime_omni, + realtime_qwen_audio, + realtime_translation, + ], +) +def test_realtime_send_failure_closes_before_join( + monkeypatch, + tmp_path, + module, +): + closed = Event() + receiving = Event() + session = Mock() + + def recv(): + if not receiving.is_set(): + receiving.set() + return {"type": "session.updated"} + assert closed.wait(2) + return None + + session.recv_event.side_effect = recv + session.close.side_effect = closed.set + client = SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=Mock(return_value=nullcontext(session)), + ), + ), + ) + monkeypatch.setattr(module, "Client", lambda **kwargs: nullcontext(client)) + monkeypatch.setattr( + module, + "send_input", + Mock( + side_effect=RuntimeError("send failed"), + ), + ) + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(tmp_path / "failed.wav"), + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match="send failed"): + module.run_turn(args) + session.close.assert_called_once() + session.commit.assert_not_called() + session.create_response.assert_not_called() + session.end_session.assert_not_called() + + +@pytest.mark.parametrize( + "module", + [ + realtime_translation, + realtime_translation_direct_call, + ], +) +@pytest.mark.parametrize( + "events,error", + [ + ( + [ + {"type": "response.audio.delta", "delta": "AAE="}, + {"type": "response.done", "response": {"status": "completed"}}, + None, + ], + RuntimeError, + ), + ( + [{"type": "response.done", "response": {"status": "failed"}}], + RuntimeError, + ), + ([{"type": "response.audio.delta", "delta": "!invalid!"}], ValueError), + ([{"type": "session.finished"}], RuntimeError), + ], +) +def test_translation_rejects_incomplete_response(events, error, module): + session = Mock() + session.recv_event.side_effect = events + with pytest.raises(error): + module.receive_until( + session, + "session.finished", + module.time.monotonic() + 2, + Mock(), + ) + + +@pytest.mark.parametrize( + "module", + [ + realtime_omni, + realtime_omni_direct_call, + realtime_qwen_audio, + realtime_qwen_audio_direct_call, + realtime_translation, + realtime_translation_direct_call, + ], +) +def test_realtime_existing_output_prevents_connection( + monkeypatch, + tmp_path, + module, +): + client = Mock(side_effect=AssertionError("unexpected client")) + monkeypatch.setattr(module, "Client", client) + path = tmp_path / "keep.wav" + path.write_bytes(b"keep") + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(path), + "--no-play", + ], + ) + with pytest.raises(FileExistsError): + module.run_turn(args) + assert path.read_bytes() == b"keep" + client.assert_not_called() + + +@pytest.mark.parametrize( + "module", + [ + realtime_omni, + realtime_omni_direct_call, + realtime_qwen_audio, + realtime_qwen_audio_direct_call, + realtime_translation, + realtime_translation_direct_call, + ], +) +def test_realtime_examples_do_not_switch_model_families(module): + with pytest.raises(SystemExit) as caught: + module.make_parser().parse_args( + [ + "--microphone", + "--output", + "unused.wav", + "--model", + "other-family", + ], + ) + assert caught.value.code == 2 diff --git a/tests/v2/test_audio_samples_direct_call.py b/tests/v2/test_audio_samples_direct_call.py new file mode 100644 index 00000000..88485eb9 --- /dev/null +++ b/tests/v2/test_audio_samples_direct_call.py @@ -0,0 +1,685 @@ +# -*- coding: utf-8 -*- +"""Explicit audio sample startup, ownership, and shutdown contracts.""" + +import ast +import importlib +import json +import wave +from pathlib import Path +from threading import Event +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import dashscope + + +def _direct(name): + # ``01_atomic_capabilities`` is not a valid identifier in source-level + # imports, so the samples are loaded through importlib instead. + return importlib.import_module( + f"examples.01_atomic_capabilities.audio.direct_call.{name}", + ) + + +_common = _direct("_common") +asr_microphone = _direct("asr_microphone") +asr_websocket = _direct("asr_websocket") +connection_reuse = _direct("connection_reuse") +realtime_omni = _direct("realtime_omni") +realtime_qwen_audio = _direct("realtime_qwen_audio") +realtime_translation = _direct("realtime_translation") +tts_websocket = _direct("tts_websocket") +voice_clone = _direct("voice_clone") + + +SAMPLES = ( + Path(__file__).resolve().parents[2] + / "examples" + / "01_atomic_capabilities" + / "audio" +) +NAMES = sorted( + path.stem + for path in SAMPLES.glob("*.py") + # tingwu is a standalone example without a direct_call copy. + if not path.name.startswith("_") and path.stem != "tingwu" +) +REALTIME = [realtime_omni, realtime_qwen_audio, realtime_translation] +PCM = b"\x01\x00" * 1600 + + +@pytest.fixture +def wav_input(tmp_path): + path = tmp_path / "input.wav" + with wave.open(str(path), "wb") as audio: + audio.setparams((1, 2, 16000, 0, "NONE", "not compressed")) + audio.writeframes(PCM) + return path + + +@pytest.mark.parametrize("name", ["_common", *NAMES]) +def test_copies_do_not_use_context_managers_or_original_helpers(name): + path = SAMPLES / "direct_call" / f"{name}.py" + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + assert not isinstance(node, (ast.With, ast.AsyncWith)) + if isinstance(node, ast.Attribute): + assert node.attr not in ("__enter__", "__exit__") + if isinstance(node, ast.ImportFrom): + assert node.module != "contextlib" + # Samples import their own package relatively; a level-2 import + # (``from ..``) would reach the original context-manager helpers. + assert node.level <= 1 + assert len(NAMES) == 12 + + +@pytest.mark.parametrize("name", NAMES) +def test_direct_cli_help_has_no_side_effects(monkeypatch, capsys, name): + module = _direct(name) + forbidden = Mock(side_effect=AssertionError("help opened a resource")) + monkeypatch.setattr(dashscope, "Client", forbidden) + if hasattr(module, "Client"): + monkeypatch.setattr(module, "Client", forbidden) + monkeypatch.setattr(_common, "load_sounddevice", forbidden) + with pytest.raises(SystemExit) as error: + module.main(["--help"]) + assert error.value.code == 0 + assert ( + "examples.01_atomic_capabilities.audio.direct_call." + in capsys.readouterr().out + ) + forbidden.assert_not_called() + + +@pytest.mark.parametrize( + "name,arguments", + [ + ( + "asr_file", + ["--url", "https://cdn.test/input.wav", "--output-dir", "result"], + ), + ( + "asr_http_stream", + ["--file", "input.wav", "--transcript", "asr.txt"], + ), + ("asr_websocket", ["input.wav", "--transcript", "asr.txt"]), + ( + "asr_microphone", + [ + "--seconds", + "0.1", + "--transcript", + "asr.txt", + "--recording", + "recording.wav", + ], + ), + ("tts_http", ["--text", "你好", "--output", "output.wav", "--no-play"]), + ( + "tts_http_stream", + ["--text", "你好", "--output", "output.wav", "--no-play"], + ), + ( + "tts_websocket", + ["--text", "你好", "--output", "output.wav", "--no-play"], + ), + ("voice_clone", ["list", "--output", "voices.json"]), + ("connection_reuse", ["asr", "input.wav", "--output-dir", "result"]), + ("connection_reuse", ["tts", "--output-dir", "result"]), + *[ + ( + module.__name__.rsplit(".", 1)[1], + [ + "--wav", + "input.wav", + "--output", + "output.wav", + "--no-play", + ], + ) + for module in REALTIME + ], + ], +) +def test_client_creation_failure_closes_earlier_files( + monkeypatch, + tmp_path, + wav_input, + name, + arguments, +): + module = _direct(name) + files, readers = [], [] + original_open, original_wave = Path.open, wave.open + + def open_file(path, *args, **kwargs): + file = original_open(path, *args, **kwargs) + files.append(file) + return file + + def open_wave(path, mode=None): + audio = original_wave(path, mode) + if mode == "rb": + readers.append(audio) + return audio + + monkeypatch.setattr(Path, "open", open_file) + monkeypatch.setattr(wave, "open", open_wave) + factory = Mock(side_effect=RuntimeError("client failed")) + monkeypatch.setattr(dashscope, "Client", factory) + if hasattr(module, "Client"): + monkeypatch.setattr(module, "Client", factory) + stream = Mock() + if module is asr_microphone: + monkeypatch.setattr(module, "load_sounddevice", Mock()) + monkeypatch.setattr(module, "microphone", Mock(return_value=stream)) + filenames = { + "input.wav", + "asr.txt", + "recording.wav", + "output.wav", + "voices.json", + "result", + } + argv = [ + str(tmp_path / arg) if arg in filenames else arg for arg in arguments + ] + if module in (asr_microphone, asr_websocket): + assert module.main(argv) == 1 + else: + with pytest.raises(RuntimeError, match="client failed"): + module.main(argv) + factory.assert_called_once() + assert files and all(file.closed for file in files) + assert all(reader._file is None for reader in readers) + if module is asr_microphone: + stream.close.assert_called_once() + stream.start.assert_not_called() + + +@pytest.mark.parametrize("module", REALTIME) +@pytest.mark.parametrize("failure", ["connect", "executor", "send", "close"]) +def test_realtime_cleanup_on_partial_start_and_send_failure( + monkeypatch, + tmp_path, + module, + failure, +): + session = Mock() + closed = Event() + order = [] + session.recv_event.side_effect = [{"type": "session.updated"}] + + def connect(): + if failure == "connect": + raise RuntimeError("connect failed") + + def close(): + order.append("session.close") + closed.set() + if failure == "close": + raise RuntimeError("close failed") + + session.connect.side_effect = connect + session.close.side_effect = close + client = SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=Mock(return_value=session), + ), + ), + close=Mock(side_effect=lambda: order.append("client.close")), + ) + monkeypatch.setattr(module, "Client", Mock(return_value=client)) + executor = Mock() + executor.submit.return_value = Mock() + + def shutdown(*, wait): + assert wait and closed.is_set() + order.append("executor.shutdown") + + executor.shutdown.side_effect = shutdown + factory = Mock(return_value=executor) + if failure == "executor": + factory.side_effect = RuntimeError("executor failed") + monkeypatch.setattr(module, "ThreadPoolExecutor", factory) + monkeypatch.setattr( + module, + "send_input", + Mock( + side_effect=RuntimeError("send failed"), + ), + ) + raw, output = Mock(), Mock() + monkeypatch.setattr(module, "wav_output", Mock(return_value=(raw, output))) + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(tmp_path / "reply.wav"), + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match=f"{failure} failed"): + module.run_turn(args) + session.connect.assert_called_once() + session.close.assert_called_once() + client.close.assert_called_once() + output.close.assert_called_once() + raw.close.assert_called_once() + if failure in ("send", "close"): + assert order == ["session.close", "executor.shutdown", "client.close"] + else: + executor.shutdown.assert_not_called() + + +@pytest.mark.parametrize("module", REALTIME) +def test_realtime_close_unblocks_real_receiver(monkeypatch, tmp_path, module): + closed = Event() + calls = 0 + + def receive(): + nonlocal calls + calls += 1 + if calls == 1: + return {"type": "session.updated"} + assert closed.wait(2) + return None + + session = Mock() + session.recv_event.side_effect = receive + session.close.side_effect = closed.set + client = SimpleNamespace( + audio=SimpleNamespace( + realtime=SimpleNamespace( + connect=Mock(return_value=session), + ), + ), + close=Mock(), + ) + monkeypatch.setattr(module, "Client", Mock(return_value=client)) + monkeypatch.setattr( + module, + "send_input", + Mock( + side_effect=RuntimeError("send failed"), + ), + ) + args = module.make_parser().parse_args( + [ + "--microphone", + "--output", + str(tmp_path / "reply.wav"), + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match="send failed"): + module.run_turn(args) + session.connect.assert_called_once() + session.close.assert_called_once() + client.close.assert_called_once() + + +@pytest.mark.parametrize("module", REALTIME) +@pytest.mark.parametrize("overflow", [False, True]) +def test_realtime_microphone_explicit_lifecycle(monkeypatch, module, overflow): + stream = Mock() + stream.read.return_value = (PCM, overflow) + monkeypatch.setattr(module, "microphone", Mock(return_value=stream)) + receiver = Mock() + receiver.done.return_value = False + session = Mock() + args = SimpleNamespace(device=None, seconds=0.1) + if overflow: + with pytest.raises(RuntimeError, match="溢出"): + module.send_input( + session, + receiver, + args, + None, + module.time.monotonic() + 2, + ) + session.append_audio.assert_not_called() + else: + module.send_input( + session, + receiver, + args, + None, + module.time.monotonic() + 2, + ) + session.append_audio.assert_called_once_with(PCM) + stream.start.assert_called_once() + stream.stop.assert_called_once() + stream.close.assert_called_once() + + +class TaskSession: + def __init__(self, identifier): + self.session = SimpleNamespace( + task_id=identifier, + request_id=identifier, + ) + self.request_id = identifier + self.first_package_delay = 1 + self.last_package_delay = 2 + self.usage = {} + self.last_response = None + self.stopped = Event() + self.started = False + self.closed = False + self.sent = [] + + def start(self): + assert not self.started + self.started = True + + def send_audio_frame(self, data): + assert self.started and not self.closed + self.sent.append(data) + + def send_text(self, text): + assert self.started and not self.closed + self.sent.append(text) + + def stop(self): + assert self.started and self.sent + self.stopped.set() + + def finish(self): + assert self.stopped.wait(2) + self.last_response = { + "header": { + "event": "task-finished", + "task_id": self.session.task_id, + }, + } + + def results(self): + self.finish() + yield SimpleNamespace( + get_sentence=lambda: {"text": "你好"}, + is_sentence_end=lambda: True, + ) + + def events(self): + self.finish() + yield PCM + yield self.last_response + + def close(self): + self.closed = True + self.stopped.set() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +def test_websocket_samples_start_and_drain( + monkeypatch, + tmp_path, + wav_input, + kind, +): + session = TaskSession("single-task") + client = SimpleNamespace( + audio=SimpleNamespace( + asr=SimpleNamespace(recognize=Mock(return_value=session)), + tts=SimpleNamespace(connect=Mock(return_value=session)), + ), + close=Mock(), + ) + monkeypatch.setattr(dashscope, "Client", Mock(return_value=client)) + if kind == "asr": + transcript = tmp_path / "transcript.txt" + assert ( + asr_websocket.main( + [ + str(wav_input), + "--transcript", + str(transcript), + ], + ) + == 0 + ) + assert transcript.read_text() == "你好\n" + assert b"".join(session.sent) == PCM + else: + monkeypatch.setattr(tts_websocket, "Client", Mock(return_value=client)) + output = tmp_path / "output.wav" + tts_websocket.main( + [ + "--text", + "你好", + "--output", + str(output), + "--no-play", + ], + ) + with wave.open(str(output), "rb") as audio: + assert audio.readframes(audio.getnframes()) == PCM + assert session.sent == ["你好"] + assert session.started and session.closed and session.last_response + client.close.assert_called_once() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +@pytest.mark.parametrize("failure", ["start", "executor"]) +def test_websocket_partial_start_cleanup(monkeypatch, kind, failure): + session = Mock() + if failure == "start": + session.start.side_effect = RuntimeError("start failed") + client = SimpleNamespace( + audio=SimpleNamespace( + asr=SimpleNamespace(recognize=Mock(return_value=session)), + tts=SimpleNamespace(connect=Mock(return_value=session)), + ), + close=Mock(), + ) + executor = Mock() + factory = Mock(return_value=executor) + if failure == "executor": + factory.side_effect = RuntimeError("executor failed") + module = asr_websocket if kind == "asr" else tts_websocket + monkeypatch.setattr(module, "ThreadPoolExecutor", factory) + if kind == "asr": + monkeypatch.setattr(dashscope, "Client", Mock(return_value=client)) + args = SimpleNamespace( + timeout=1, + region=None, + workspace=None, + model="qwen-audio-3.1-asr-flash-streaming", + vocabulary_id=None, + partials=False, + ) + with pytest.raises(asr_websocket.SampleError): + module.run_recognition(args, 16000, Mock(), Mock()) + client.close.assert_called_once() + else: + args = module.make_parser().parse_args( + [ + "--text", + "你好", + "--output", + "unused.wav", + "--no-play", + ], + ) + with pytest.raises(RuntimeError, match=f"{failure} failed"): + module.synthesize(client, args, ["你好"], Mock(), None) + session.close.assert_called_once() + session.stop.assert_not_called() + if failure == "start" and kind == "asr": + executor.shutdown.assert_called_once_with(wait=True) + + +def test_llm_stream_closes_when_tts_fails(monkeypatch, tmp_path): + stream = SimpleNamespace(text_stream=iter(["你好"]), close=Mock()) + client = SimpleNamespace( + chat=SimpleNamespace( + completions=SimpleNamespace( + create=Mock(return_value=stream), + ), + ), + close=Mock(), + ) + monkeypatch.setattr(tts_websocket, "Client", Mock(return_value=client)) + monkeypatch.setattr( + tts_websocket, + "synthesize", + Mock( + side_effect=RuntimeError("synthesis failed"), + ), + ) + with pytest.raises(RuntimeError, match="synthesis failed"): + tts_websocket.main( + [ + "--prompt", + "你好", + "--output", + str(tmp_path / "reply.wav"), + "--no-play", + ], + ) + stream.close.assert_called_once() + client.close.assert_called_once() + + +@pytest.mark.parametrize("kind", ["asr", "tts"]) +def test_five_tasks_keep_shared_connection_open( + monkeypatch, + tmp_path, + wav_input, + kind, +): + sessions = [] + connection = SimpleNamespace(closed=False) + connection.close = Mock( + side_effect=lambda: setattr(connection, "closed", True), + ) + + def task(**kwargs): + assert kwargs["connection"] is connection and not connection.closed + assert all(session.closed for session in sessions) + session = TaskSession(f"task-{len(sessions) + 1}") + sessions.append(session) + return session + + client = SimpleNamespace( + config=SimpleNamespace(region="cn-beijing", workspace="ws-test"), + audio=SimpleNamespace( + asr=SimpleNamespace(recognize=task), + tts=SimpleNamespace(connect=task), + ), + ws_connection=Mock(return_value=connection), + close=Mock(), + ) + monkeypatch.setattr(connection_reuse, "Client", Mock(return_value=client)) + folder = tmp_path / "reuse" + source_args = [str(wav_input)] if kind == "asr" else [] + assert ( + connection_reuse.main( + [ + kind, + *source_args, + "--output-dir", + str(folder), + ], + ) + == 0 + ) + summary = json.loads((folder / "summary.json").read_text()) + assert summary["status"] == "completed" + assert len(summary["runs"]) == len(sessions) == 5 + assert all(session.started and session.closed for session in sessions) + assert summary["connection_count"] == 1 and summary["connection_closed"] + connection.close.assert_called_once() + client.ws_connection.assert_called_once() + client.close.assert_called_once() + + +@pytest.mark.parametrize("record", [False, True]) +def test_asr_microphone_direct_start_stop(monkeypatch, tmp_path, record): + stream = Mock() + stream.read.return_value = (PCM, False) + monkeypatch.setattr(asr_microphone, "load_sounddevice", Mock()) + monkeypatch.setattr( + asr_microphone, + "microphone", + Mock(return_value=stream), + ) + session = TaskSession("microphone-task") + client = SimpleNamespace( + audio=SimpleNamespace( + asr=SimpleNamespace( + recognize=Mock(return_value=session), + ), + ), + close=Mock(), + ) + monkeypatch.setattr(dashscope, "Client", Mock(return_value=client)) + recording = tmp_path / "recording.wav" + options = ["--recording", str(recording)] if record else ["--no-recording"] + assert ( + asr_microphone.main( + [ + "--seconds", + "0.1", + "--transcript", + str(tmp_path / "asr.txt"), + *options, + ], + ) + == 0 + ) + stream.start.assert_called_once() + stream.stop.assert_called_once() + stream.close.assert_called_once() + assert session.started and session.closed and session.sent == [PCM] + assert recording.exists() == record + if record: + with wave.open(str(recording), "rb") as audio: + assert audio.readframes(audio.getnframes()) == PCM + + +@pytest.mark.parametrize("save", [False, True]) +def test_voice_query_closes_client_and_optional_output( + monkeypatch, + tmp_path, + save, +): + voice = SimpleNamespace( + status="OK", + model_dump=Mock(return_value={"voice_id": "v-test"}), + ) + client = SimpleNamespace( + config=SimpleNamespace(region="cn-beijing", workspace="ws-test"), + audio=SimpleNamespace( + voices=SimpleNamespace(get=Mock(return_value=voice)), + ), + close=Mock(), + ) + monkeypatch.setattr(voice_clone, "Client", Mock(return_value=client)) + output = tmp_path / "voice.json" + options = ["--output", str(output)] if save else [] + assert voice_clone.main(["get", "--voice-id", "v-test", *options]) == 0 + client.close.assert_called_once() + assert output.exists() == save + + +def test_wav_helpers_own_resources_explicitly(monkeypatch, tmp_path): + raw = Mock() + writer = Mock() + monkeypatch.setattr(Path, "open", Mock(return_value=raw)) + monkeypatch.setattr(wave, "open", Mock(return_value=writer)) + writer.setframerate.side_effect = RuntimeError("invalid format") + with pytest.raises(RuntimeError, match="invalid format"): + _common.wav_output(tmp_path / "invalid.wav", 24000) + writer.close.assert_called_once() + raw.close.assert_called_once() + reader = Mock() + reader.getnchannels.return_value = 2 + monkeypatch.setattr(wave, "open", Mock(return_value=reader)) + with pytest.raises(ValueError): + _common.open_wav(tmp_path / "stereo.wav") + reader.close.assert_called_once() diff --git a/tests/v2/test_audio_translation.py b/tests/v2/test_audio_translation.py deleted file mode 100644 index 2c78305f..00000000 --- a/tests/v2/test_audio_translation.py +++ /dev/null @@ -1,366 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Tests for client.audio.translation (Gummy realtime translation).""" - -# Fake connectors only inspect the headers/url they care about. -# pylint: disable=unused-argument - -from __future__ import annotations - -import json - -import pytest - -from dashscope.v2 import Client -from dashscope.v2.errors import DashScopeError -from dashscope.v2.resources.audio.asr import FILE_CHUNK_SIZE - - -def _make_client(**kwargs): - # Realtime sessions talk over the websocket transport, but the Client - # still resolves an HTTP config; pin a workspace so the derived URLs - # stay deterministic (without one the SDK falls back to the legacy - # shared gateway with a warning). - kwargs.setdefault("workspace", "ws-test") - return Client(api_key="sk-test", **kwargs) - - -class FakeWebSocket: - """Scripted websocket stub: records sends, replays a queued inbox.""" - - def __init__(self, inbox=None): - self.sent = [] - self.inbox = list(inbox or []) - self.closed = False - - def send(self, payload, opcode=1): - assert opcode == (2 if isinstance(payload, bytes) else 1) - self.sent.append(payload) - - def recv(self): - if not self.inbox: - return None - return self.inbox.pop(0) - - def close(self): - self.closed = True - - def settimeout(self, seconds): - pass - - -def _event(name, payload=None): - return json.dumps( - { - "header": {"event": name, "task_id": "t", "request_id": "r"}, - "payload": payload or {}, - }, - ) - - -def _sent_frames(ws): - return [json.loads(item) for item in ws.sent if isinstance(item, str)] - - -def _bilingual_output(**overrides): - output = { - "transcription": { - "sentence_id": 1, - "text": "你好", - "begin_time": 0, - "current_time": 100, - "words": [], - "sentence_end": False, - }, - "translations": [ - { - "sentence_id": 1, - "lang": "en", - "text": "Hello", - "begin_time": 0, - "current_time": 100, - "words": [], - "sentence_end": False, - }, - ], - } - output.update(overrides) - return output - - -def test_translation_recognize_merges_named_parameters(): - ws = FakeWebSocket( - [ - _event("task-started"), - _event("task-finished"), - ], - ) - client = _make_client() - with client.audio.translation.recognize( - model="gummy-realtime-v1", - source_language="zh", - transcription_enabled=True, - translation_enabled=True, - translation_target_languages=["en"], - max_end_silence=800, - parameters={"source_language": "ja", "future_option": False}, - connector=lambda url, headers: ws, - ) as session: - session.send_audio_frame(b"AUD") - session.stop() - events = list(session.events()) - assert events[-1]["header"]["event"] == "task-finished" - - frames = _sent_frames(ws) - assert frames[0]["header"]["action"] == "run-task" - assert frames[0]["payload"]["task_group"] == "audio" - assert frames[0]["payload"]["task"] == "asr" - assert frames[0]["payload"]["function"] == "recognition" - assert frames[0]["payload"]["model"] == "gummy-realtime-v1" - params = frames[0]["payload"]["parameters"] - assert params["format"] == "pcm" - assert params["sample_rate"] == 16000 - # Named kwargs win over the parameters= escape hatch. - assert params["source_language"] == "zh" - assert params["transcription_enabled"] is True - assert params["translation_enabled"] is True - assert params["translation_target_languages"] == ["en"] - assert params["max_end_silence"] == 800 - # The parameters= pass-through itself survives. - assert params["future_option"] is False - assert ws.sent[1] == b"AUD" - assert frames[1]["header"]["action"] == "finish-task" - assert ws.closed is True - - -def test_translation_results_yield_typed_bilingual(): - ws = FakeWebSocket( - [ - _event("task-started"), - _event( - "result-generated", - { - "output": { - "transcription": { - "sentence_id": 0, - "text": "", - "heartbeat": True, - }, - }, - }, - ), - _event( - "result-generated", - {"output": _bilingual_output(), "usage": {"duration": 1}}, - ), - _event( - "result-generated", - { - "output": _bilingual_output( - transcription=None, - translations=[ - { - "sentence_id": 2, - "lang": "en", - "text": "Done.", - "sentence_end": True, - }, - ], - ), - }, - ), - _event("task-finished"), - ], - ) - client = _make_client() - with client.audio.translation.recognize( - model="gummy-realtime-v1", - translation_enabled=True, - translation_target_languages=["en"], - connector=lambda url, headers: ws, - ) as session: - session.send_audio_frame(b"AUD") - session.stop() - results = list(session.results()) - - # Heartbeat frame and task-finished are filtered out. - assert len(results) == 2 - first = results[0] - assert first.get_transcription()["text"] == "你好" - assert first.get_translations()[0]["lang"] == "en" - assert first.is_sentence_end() is False - assert first.request_id == "r" - assert first.usage == {"duration": 1} - second = results[1] - assert second.get_transcription() is None - assert second.get_translations()[0]["text"] == "Done." - assert second.is_sentence_end() is True - # Gummy reports text under ``output.transcription`` (not ``output. - # sentence``); the shared first-package-delay trigger must still fire. - assert session.first_package_delay is not None - assert session.first_package_delay >= 0 - - -def test_translation_recognize_file_chunks_and_yields_typed_results( - tmp_path, -): - audio_file = tmp_path / "sample.wav" - audio_file.write_bytes(b"\x00" * (FILE_CHUNK_SIZE + 100)) - ws = FakeWebSocket( - [ - _event("task-started"), - _event( - "result-generated", - {"output": _bilingual_output(), "usage": {"duration": 1}}, - ), - _event("task-finished"), - ], - ) - client = _make_client() - results = list( - client.audio.translation.recognize_file( - str(audio_file), - model="gummy-realtime-v1", - translation_enabled=True, - translation_target_languages=["en"], - connector=lambda url, headers: ws, - ), - ) - - assert len(results) == 1 - assert results[0].get_transcription()["text"] == "你好" - assert results[0].get_translations()[0]["lang"] == "en" - assert ws.sent[1] == b"\x00" * FILE_CHUNK_SIZE - assert ws.sent[2] == b"\x00" * 100 - frames = _sent_frames(ws) - assert frames[0]["header"]["action"] == "run-task" - assert frames[-1]["header"]["action"] == "finish-task" - assert ws.closed is True - - -def test_translation_recognize_file_validates_path(tmp_path): - client = _make_client() - with pytest.raises(ValueError, match="not found"): - client.audio.translation.recognize_file( - "/nonexistent/audio.wav", - model="gummy-realtime-v1", - ) - empty = tmp_path / "empty.wav" - empty.write_bytes(b"") - with pytest.raises(ValueError, match="empty"): - client.audio.translation.recognize_file( - str(empty), - model="gummy-realtime-v1", - ) - - -def test_translation_recognize_file_forwards_named_parameters(tmp_path): - # recognize_file re-assembles the run-task payload separately from - # recognize; assert every named param reaches the wire so the duplicate - # forwarding block cannot silently drop one (M3 wire parity). - audio_file = tmp_path / "sample.wav" - audio_file.write_bytes(b"\x00" * FILE_CHUNK_SIZE) - ws = FakeWebSocket( - [ - _event("task-started"), - _event( - "result-generated", - {"output": _bilingual_output(), "usage": {"duration": 1}}, - ), - _event("task-finished"), - ], - ) - client = _make_client() - list( - client.audio.translation.recognize_file( - str(audio_file), - model="gummy-realtime-v1", - format="opus", - sample_rate=8000, - source_language="zh", - transcription_enabled=False, - translation_enabled=True, - translation_target_languages=["en", "ja"], - max_end_silence=800, - phrase_id="ph-1", - connector=lambda url, headers: ws, - ), - ) - - frames = _sent_frames(ws) - run = frames[0] - assert run["header"]["action"] == "run-task" - assert run["payload"]["task_group"] == "audio" - assert run["payload"]["task"] == "asr" - assert run["payload"]["function"] == "recognition" - assert run["payload"]["model"] == "gummy-realtime-v1" - assert run["payload"]["input"] == {} - params = run["payload"]["parameters"] - assert params["format"] == "opus" - assert params["sample_rate"] == 8000 - assert params["source_language"] == "zh" - # Falsey named values survive forwarding (not dropped by an ``if value``). - assert params["transcription_enabled"] is False - assert params["translation_enabled"] is True - assert params["translation_target_languages"] == ["en", "ja"] - assert params["max_end_silence"] == 800 - assert run["payload"]["resources"] == [ - {"resource_id": "ph-1", "resource_type": "asr_phrase"}, - ] - assert frames[-1]["header"]["action"] == "finish-task" - assert ws.closed is True - - -def test_translation_raises_on_task_failed(): - ws = FakeWebSocket( - [ - json.dumps( - { - "header": { - "event": "task-failed", - "task_id": "t", - "error_code": "InvalidParameter", - "error_message": "bad source_language", - }, - "payload": {}, - }, - ), - ], - ) - client = _make_client() - with pytest.raises(DashScopeError, match="bad source_language"): - with client.audio.translation.recognize( - model="gummy-realtime-v1", - source_language="xx", - connector=lambda url, headers: ws, - ): - pass - assert ws.closed is True - - -def test_translation_threads_per_call_auth(): - # Per-call api_key/workspace must drive the gateway host AND the auth - # headers together, so the socket and its credentials never disagree. - captured = {} - - def connector(url, headers): - captured["url"] = url - captured["headers"] = headers - return FakeWebSocket( - [_event("task-started"), _event("task-finished")], - ) - - client = _make_client() - with client.audio.translation.recognize( - model="gummy-realtime-v1", - api_key="sk-override", - workspace="ws-override", - connector=connector, - ) as session: - assert captured["url"].startswith( - "wss://ws-override.cn-beijing.maas.aliyuncs.com/", - ) - assert captured["headers"]["Authorization"] == "Bearer sk-override" - assert captured["headers"]["X-DashScope-WorkSpace"] == "ws-override" - session.stop() - list(session.events()) diff --git a/tests/v2/test_audio_tts_http.py b/tests/v2/test_audio_tts_http.py index f43ac986..8737a3ea 100644 --- a/tests/v2/test_audio_tts_http.py +++ b/tests/v2/test_audio_tts_http.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""Tests for client.audio.tts.http (M4): sambert/cosyvoice + qwen-tts.""" +"""Tests for HTTP speech synthesis across Qwen-Audio, CosyVoice and Qwen-TTS.""" # Mock handlers intentionally ignore the request argument. # pylint: disable=unused-argument @@ -14,7 +14,15 @@ import pytest from dashscope.v2 import Client -from dashscope.v2.errors import BadRequestError +from dashscope.v2.errors import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + DashScopeError, + InternalServerError, +) BASE_URL = "https://api.test.local/api/v1" @@ -56,18 +64,18 @@ def handler(request): client = _make_client(handler) result = client.audio.tts.http.synthesize( "你好世界", - model="sambert-zhichu-v1", - voice="zhichu", + model="qwen-audio-3.1-tts-flash", + voice="longanfengyue_v3.1", format="wav", sample_rate=16000, ) assert captured["path"] == "/api/v1/services/audio/tts/SpeechSynthesizer" assert captured["body"] == { - "model": "sambert-zhichu-v1", + "model": "qwen-audio-3.1-tts-flash", "input": { "text": "你好世界", - "voice": "zhichu", + "voice": "longanfengyue_v3.1", "format": "wav", "sample_rate": 16000, }, @@ -147,7 +155,27 @@ def handler(request): assert captured["body"]["parameters"] == {"seed": 42} -def test_http_stream_yields_chunks_and_final_reference(): +@pytest.mark.parametrize( + "model, voice, path", + [ + ( + "qwen-audio-3.1-tts-flash", + "longanfengyue_v3.1", + "/api/v1/services/audio/tts/SpeechSynthesizer", + ), + ( + "cosyvoice-v3-flash", + "longanfengyu", + "/api/v1/services/audio/tts/SpeechSynthesizer", + ), + ( + "qwen3-tts-flash", + "Cherry", + "/api/v1/services/aigc/multimodal-generation/generation", + ), + ], +) +def test_http_stream_yields_chunks_and_final_reference(model, voice, path): sentence = {"id": 1, "text": "你好"} sse = ( "id: 1\ndata: " @@ -183,6 +211,8 @@ def test_http_stream_yields_chunks_and_final_reference(): captured = {} def handler(request): + captured["path"] = request.url.path + captured["body"] = json.loads(request.content.decode()) captured["sse"] = request.headers.get("x-dashscope-sse") return httpx.Response( 200, @@ -197,13 +227,16 @@ def handler(request): chunks = list( client.audio.tts.http.stream( "你好", - model="cosyvoice-v3-flash", - voice="longanfengyu", + model=model, + voice=voice, format="pcm", sample_rate=24000, ), ) + assert captured["path"] == path + assert captured["body"]["model"] == model + assert captured["body"]["input"]["voice"] == voice assert captured["sse"] == "enable" assert len(chunks) == 3 @@ -240,7 +273,7 @@ def handler(request): client = _make_client(handler) stream = client.audio.tts.http.stream( "你好", - model="sambert-zhichu-v1", + model="qwen-audio-3.1-tts-flash", voice="nobody", ) assert next(stream).output["type"] == "sentence-begin" @@ -259,8 +292,8 @@ def handler(request): with pytest.raises(BadRequestError, match="text is required"): client.audio.tts.http.synthesize( "", - model="sambert-zhichu-v1", - voice="zhichu", + model="qwen-audio-3.1-tts-flash", + voice="longanfengyue_v3.1", ) @@ -326,3 +359,748 @@ def handler(request): assert len(chunks) == 1 assert chunks[0].is_final assert chunks[0].audio_url == "https://cdn.test/f.wav" + + +_TTS_ARGS = { + "model": "qwen-audio-3.1-tts-flash", + "voice": "longanfengyue_v3.1", +} + + +class _TrackedStream(httpx.SyncByteStream): + """Unbuffered body: Response(content=...) can hide missing close calls.""" + + def __init__(self, *parts, error=None): + self.parts = parts + self.error = error + self.closed = False + + def __iter__(self): + yield from self.parts + if self.error is not None: + raise self.error + + def close(self): + self.closed = True + + +def _stream_client(wire, *, status=200, headers=None, **kwargs): + response = httpx.Response( + status, + stream=wire, + headers={"content-type": "text/event-stream", **(headers or {})}, + ) + return ( + _make_client(lambda request: response, max_retries=0, **kwargs), + response, + ) + + +def _frame(body): + return ("data: " + json.dumps(body) + "\n\n").encode() + + +@pytest.mark.parametrize("status", [200, 400, 401, 500]) +def test_http_error_envelope_and_request_id(status): + body = { + "code": "InvalidApiKey" if status == 401 else "InvalidParameter", + "message": "synthesis rejected", + } + client = _make_client( + lambda request: httpx.Response( + status, + json=body, + headers={"x-request-id": "header-id"}, + ), + max_retries=0, + ) + error_type = AuthenticationError if status == 401 else BadRequestError + with pytest.raises(error_type, match="synthesis rejected") as caught: + client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + assert caught.value.code == body["code"] + assert caught.value.status_code == status + assert caught.value.request_id == "header-id" + assert caught.value.body == body + + +@pytest.mark.parametrize("status", [200, 400]) +def test_http_nonraising_failure_preserves_error_envelope(status): + # Compatibility must retain failure information, not return an empty success. + body = { + "code": "InvalidParameter", + "message": "bad voice", + "request_id": "err-1", + } + client = _make_client( + lambda request: httpx.Response(status, json=body), + raise_on_error=False, + max_retries=0, + ) + result = client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + assert result.code == "InvalidParameter" + assert result.message == "bad voice" + assert result.is_error + assert result.request_id == "err-1" + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +def test_http_redirect_is_not_success(method): + client = _make_client( + lambda request: httpx.Response( + 302, + json={}, + headers={"location": "https://other.test/"}, + ), + max_retries=0, + ) + with pytest.raises(APIStatusError) as caught: + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + list(result) + assert caught.value.status_code == 302 + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_malformed_json_is_not_suppressed(raise_on_error): + client = _make_client( + lambda request: httpx.Response(200, content=b'{"output":'), + raise_on_error=raise_on_error, + ) + with pytest.raises(ValueError): + client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize( + "transport_error, sdk_error", + [ + (httpx.ConnectError, APIConnectionError), + (httpx.ReadTimeout, APITimeoutError), + ], +) +def test_http_transport_error_not_suppressed( + method, + transport_error, + sdk_error, +): + def handler(request): + raise transport_error("offline", request=request) + + client = _make_client(handler, raise_on_error=False, max_retries=0) + with pytest.raises(sdk_error) as caught: + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + list(result) + assert isinstance(caught.value.__cause__, transport_error) + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize("status", [400, 500]) +def test_http_stream_initial_http_error_closes_response( + raise_on_error, + status, +): + wire = _TrackedStream(b"gateway rejected") + client, response = _stream_client( + wire, + status=status, + headers={"x-request-id": "gateway-id"}, + raise_on_error=raise_on_error, + ) + error_type = BadRequestError if status == 400 else InternalServerError + with pytest.raises(error_type, match="gateway rejected") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.status_code == status + assert caught.value.request_id == "gateway-id" + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize( + "status_line, expected_status", + [("", 200), ("status: 400\n", 400)], +) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_error_always_raises_and_closes( + status_line, + expected_status, + raise_on_error, +): + body = { + "code": "InvalidParameter", + "message": "bad voice", + "request_id": "error-id", + } + wire = _TrackedStream( + _frame({"output": {"type": "sentence-begin"}}), + b"event:error\n" + status_line.encode() + _frame(body), + _frame({"output": {"finish_reason": "stop"}}), + ) + client, response = _stream_client(wire, raise_on_error=raise_on_error) + stream = client.audio.tts.http.stream("你好", **_TTS_ARGS) + assert next(stream).output["type"] == "sentence-begin" + assert not wire.closed + with pytest.raises(BadRequestError, match="bad voice") as caught: + next(stream) + assert caught.value.request_id == "error-id" + assert caught.value.status_code == expected_status + assert wire.closed and response.is_closed + assert list(stream) == [] + + +def test_http_stream_error_uses_header_request_id_fallback(): + wire = _TrackedStream( + b"event:error\n" + + _frame({"code": "InvalidParameter", "message": "bad voice"}), + ) + client, response = _stream_client( + wire, + headers={"x-request-id": "stream-id"}, + ) + with pytest.raises(BadRequestError) as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + assert caught.value.request_id == "stream-id" + + +@pytest.mark.parametrize("event_line", [b"event: error\n", b""]) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_recognizes_standard_and_data_only_business_errors( + event_line, + raise_on_error, +): + wire = _TrackedStream( + event_line + + _frame({"code": "InvalidParameter", "message": "bad voice"}), + ) + client, response = _stream_client( + wire, + headers={"x-request-id": "stream-id"}, + raise_on_error=raise_on_error, + ) + try: + with pytest.raises(BadRequestError, match="bad voice") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.request_id == "stream-id" + finally: + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_http200_json_business_error_is_not_empty_success( + raise_on_error, +): + wire = _TrackedStream(b'{"code":"InvalidParameter","message":"bad voice"}') + client, response = _stream_client( + wire, + headers={ + "content-type": "application/json", + "x-request-id": "json-id", + }, + raise_on_error=raise_on_error, + ) + try: + with pytest.raises(BadRequestError, match="bad voice") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.request_id == "json-id" + finally: + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("data", [b"{broken", b"null", b"[]", b'"text"']) +@pytest.mark.parametrize("raise_on_error", [True, False]) +def test_http_stream_malformed_data_raises_and_closes(data, raise_on_error): + wire = _TrackedStream(b"data: " + data + b"\n\n") + client, response = _stream_client(wire, raise_on_error=raise_on_error) + with pytest.raises(DashScopeError, match="invalid synthesis stream data"): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize("data", [b'{"output":', b"null", b"[]", b'"text"']) +def test_http_stream_malformed_json_response_raises_and_closes( + raise_on_error, + data, +): + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + headers={"content-type": "application/json"}, + raise_on_error=raise_on_error, + ) + with pytest.raises(ValueError): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("payload", ["%%%", "QUJD$", "a"]) +def test_http_stream_invalid_base64_is_rejected_and_closed(payload): + wire = _TrackedStream(_frame({"output": {"audio": {"data": payload}}})) + client, response = _stream_client(wire) + try: + # Either an SDK error or a decoding ValueError is acceptable; silently + # dropping invalid bytes and returning apparently valid audio is not. + with pytest.raises((DashScopeError, ValueError)): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + finally: + assert wire.closed and response.is_closed + + +def test_http_stream_multiline_sse_data_is_one_event(): + wire = _TrackedStream( + b': keepalive\n\nid: 1\ndata: {\ndata: "output": ' + b'{"audio": {"data": "QUJD"}, "finish_reason": "stop"}\ndata: }\n\n', + ) + client, response = _stream_client(wire) + try: + chunks = list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert len(chunks) == 1 + assert chunks[0].audio == b"ABC" + assert chunks[0].is_final + finally: + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("early_close", [False, True]) +def test_http_stream_open_and_response_lifetime(early_close): + requests = [] + wire = _TrackedStream( + _frame( + {"output": {"audio": {"data": "QUJD"}}, "request_id": "body-id"}, + ), + _frame( + {"output": {"finish_reason": "stop"}, "usage": {"characters": 1}}, + ), + ) + response = httpx.Response( + 200, + stream=wire, + headers={"x-request-id": "header-id"}, + ) + + def handler(request): + requests.append(request) + return response + + client = _make_client(handler) + stream = client.audio.tts.http.stream("你好", **_TTS_ARGS) + assert len(requests) == 1 + assert stream.headers["x-request-id"] == "header-id" + assert iter(stream) is stream + first = next(stream) + assert first.audio == b"ABC" + assert first.request_id == "body-id" + assert not wire.closed + if early_close: + stream.close() + else: + (final,) = list(stream) + assert final.is_final + assert final.audio is None + assert final.request_id == "header-id" + assert final.usage == {"characters": 1} + assert wire.closed and response.is_closed + assert len(requests) == 1 + + +@pytest.mark.parametrize("raise_on_error", [False, True]) +def test_http_stream_close_before_first_chunk_releases_response( + raise_on_error, +): + wire = _TrackedStream( + error=AssertionError("closed stream must not be read"), + ) + client, response = _stream_client( + wire, + raise_on_error=raise_on_error, + headers={"x-request-id": "header-id"}, + ) + try: + stream = client.audio.tts.http.stream("你好", **_TTS_ARGS) + assert stream.headers["x-request-id"] == "header-id" + assert not response.is_closed + stream.close() + stream.close() + assert wire.closed and response.is_closed + assert list(stream) == [] + assert stream.headers["x-request-id"] == "header-id" + finally: + response.close() + client.close() + client.transport.http_client.close() + + +def test_http_stream_close_failure_still_releases_response(): + from dashscope.v2.resources.audio.http_tts import ( + HttpSynthesisChunk, + HttpSynthesisStream, + ) + + def events(): + try: + yield HttpSynthesisChunk() + finally: + raise RuntimeError("generator cleanup failed") + + wire = _TrackedStream() + response = httpx.Response(200, stream=wire) + stream = HttpSynthesisStream(events(), response) + next(stream) + try: + with pytest.raises(RuntimeError, match="generator cleanup failed"): + stream.close() + assert wire.closed and response.is_closed + finally: + stream.close() + + +@pytest.mark.parametrize("status", [199, 200, 302, 500]) +def test_http_stream_read_failure_closes_unbuffered_response(status): + wire = _TrackedStream(error=httpx.ReadError("connection lost")) + client, response = _stream_client(wire, status=status) + try: + with pytest.raises((APIConnectionError, httpx.ReadError)): + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert wire.closed and response.is_closed + finally: + response.close() # Clean up even when the regression assertion fails. + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize("explicit", [False, True]) +def test_http_optional_values_and_call_auth(method, explicit): + requests = [] + + def handler(request): + requests.append(request) + if method == "stream": + return httpx.Response( + 200, + stream=_TrackedStream(_frame({"output": {}})), + ) + return httpx.Response( + 200, + json={"output": {}}, + headers={"x-request-id": "header-id"}, + ) + + client = _make_client(handler) + http_client = client.transport.http_client + http_client.headers.update( + { + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) + extra = { + "text": "wrong", + "voice": "wrong", + "format": "wav", + "sample_rate": 24000, + "language_type": "Chinese", + "enable_ssml": False, + } + original = dict(extra) + parameters = {} if explicit else None + call = getattr(client.audio.tts.http, method) + result = call( + "", + **_TTS_ARGS, + format="" if explicit else None, + sample_rate=0 if explicit else None, + language_type="" if explicit else None, + extra_input=extra, + parameters=parameters, + api_key="call-key", + workspace="call-ws", + timeout=0, + headers={"X-Test": "yes"}, + ) + if method == "stream": + list(result) + else: + assert result.request_id == "header-id" + inherited = call( + "你好", + **_TTS_ARGS, + api_key=None, + workspace=None, + timeout=None, + ) + if method == "stream": + list(inherited) + body = json.loads(requests[0].content) + assert body["input"] == { + "text": "", + "voice": _TTS_ARGS["voice"], + "format": "" if explicit else "wav", + "sample_rate": 0 if explicit else 24000, + "language_type": "" if explicit else "Chinese", + "enable_ssml": False, + } + if explicit: + assert body["parameters"] == {} + else: + assert "parameters" not in body + assert extra == original + assert requests[0].headers.get_list("authorization") == ["Bearer call-key"] + assert requests[0].headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert requests[0].headers["x-test"] == "yes" + assert requests[0].extensions["timeout"]["read"] == 0 + assert requests[1].headers["authorization"] == "Bearer pool-key" + assert requests[1].headers["x-dashscope-workspace"] == "pool-ws" + assert ( + requests[1].extensions["timeout"]["read"] == http_client.timeout.read + ) + assert all(request.url.host == "api.test.local" for request in requests) + expected_sse = "enable" if method == "stream" else None + assert requests[0].headers.get("x-dashscope-sse") == expected_sse + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize("header", ["authorization", "x-dashscope-workspace"]) +def test_http_call_auth_overrides_case_insensitive_headers(method, header): + requests = [] + + def handler(request): + requests.append(request) + if method == "stream": + return httpx.Response( + 200, + stream=_TrackedStream(_frame({"output": {}})), + ) + return httpx.Response(200, json={"output": {}}) + + client = _make_client(handler) + headers = {header: "old"} + result = getattr(client.audio.tts.http, method)( + "你好", + **_TTS_ARGS, + api_key="call-key", + workspace="call-ws", + headers=headers, + ) + if method == "stream": + list(result) + assert requests[0].headers.get_list("authorization") == ["Bearer call-key"] + assert requests[0].headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert headers == {header: "old"} + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize("request_id", [None, "body-id"]) +def test_http_results_preserve_full_body_and_empty_request_id( + method, + request_id, +): + body = { + "output": {"audio": {}, "future_output": False}, + "usage": {"characters": 0, "future_usage": []}, + "request_id": request_id, + "future_field": {"enabled": False}, + } + + def handler(request): + if method == "stream": + return httpx.Response(200, stream=_TrackedStream(_frame(body))) + return httpx.Response(200, json=body) + + client = _make_client(handler) + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + (result,) = list(result) + assert result.output == body["output"] + assert result.usage == body["usage"] + assert result.model_dump()["future_field"] == body["future_field"] + assert result.request_id == (request_id or "") + assert not result.is_error + + +@pytest.mark.parametrize("status", [302, 500]) +@pytest.mark.parametrize("body_kind", ["json", "text"]) +def test_http_nonraising_failure_without_code_keeps_http_metadata( + status, + body_kind, +): + body = { + "message": "unavailable", + "status_code": 200, + "future_field": False, + } + kwargs = {"json": body} if body_kind == "json" else {"text": "unavailable"} + client = _make_client( + lambda request: httpx.Response( + status, + headers={"x-request-id": "header-id"}, + **kwargs, + ), + raise_on_error=False, + max_retries=0, + ) + result = client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + assert result.status_code == status + assert result.request_id == "header-id" + assert result.message == "unavailable" + assert result.code is None + assert result.is_error + if body_kind == "json": + assert result.future_field is False + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize("status", [199, 300, 302, 307]) +@pytest.mark.parametrize("body_kind", ["json", "malformed-json", "sse"]) +def test_http_stream_non2xx_rejected_before_parsing_and_closed( + raise_on_error, + status, + body_kind, +): + body = {"output": {"request_id": "inner-id"}, "status_code": 200} + if body_kind == "json": + data = json.dumps(body).encode() + elif body_kind == "malformed-json": + data = b'{"output":' + else: + data = _frame({"output": {"audio": {"data": "QUJD"}}}) + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + status=status, + raise_on_error=raise_on_error, + headers={ + "content-type": ( + "text/event-stream" + if body_kind == "sse" + else "application/json" + ), + "x-request-id": "header-id", + "location": "https://other.test/", + }, + ) + with pytest.raises(APIStatusError) as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.status_code == status + assert caught.value.code is None + expected_id = "inner-id" if body_kind == "json" else "header-id" + assert caught.value.request_id == expected_id + assert caught.value.headers == response.headers + if body_kind == "json": + assert caught.value.body == body + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [True, False]) +@pytest.mark.parametrize( + "kind, status", + [ + ("sse-business", 200), + ("sse-error", 200), + ("sse-status", 200), + ("json", 200), + ("json", 400), + ("json", 500), + ], +) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_http_stream_error_request_id_precedence_is_audio_local( + raise_on_error, + kind, + status, + outer_id, + inner_id, + expected_id, +): + body = { + "code": "InvalidParameter", + "message": "bad voice", + "request_id": outer_id, + "output": {"request_id": inner_id}, + } + if kind == "json": + data = json.dumps(body).encode() + else: + prefix = { + "sse-business": b"", + "sse-error": b"event: error\n", + "sse-status": b"event: error\nstatus: 400\n", + }[kind] + data = prefix + _frame(body) + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + status=status, + raise_on_error=raise_on_error, + headers={ + "content-type": ( + "application/json" if kind == "json" else "text/event-stream" + ), + "x-request-id": "header-id", + }, + ) + with pytest.raises(BadRequestError, match="bad voice") as caught: + list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.request_id == expected_id + assert caught.value.status_code == ( + 400 if kind == "sse-status" else status + ) + assert caught.value.body == body + assert caught.value.headers == response.headers + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("method", ["synthesize", "stream"]) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_http_success_request_id_precedence( + method, + outer_id, + inner_id, + expected_id, +): + body = { + "request_id": outer_id, + "output": {"request_id": inner_id, "audio": {}}, + } + data = _frame(body) if method == "stream" else json.dumps(body).encode() + wire = _TrackedStream(data) + client, response = _stream_client( + wire, + headers={"x-request-id": "header-id"}, + ) + result = getattr(client.audio.tts.http, method)("你好", **_TTS_ARGS) + if method == "stream": + (result,) = list(result) + expected_id = outer_id or "header-id" + assert result.request_id == expected_id + assert result.output == body["output"] + assert not result.is_error + assert wire.closed and response.is_closed + + +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize("status", [None, 0, 200, 302, 503]) +def test_audio_chunk_keeps_wire_status_flag_local(raise_on_error, status): + wire = _TrackedStream(_frame({"output": {}, "status_code": status})) + client, response = _stream_client(wire, raise_on_error=raise_on_error) + try: + (chunk,) = list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert chunk.status_code == status + assert chunk.is_error == ( + status is not None and not 200 <= status < 300 + ) + assert response.is_closed and wire.closed + finally: + client.transport.http_client.close() diff --git a/tests/v2/test_audio_tts_realtime.py b/tests/v2/test_audio_tts_realtime.py index 9505846d..fa29637a 100644 --- a/tests/v2/test_audio_tts_realtime.py +++ b/tests/v2/test_audio_tts_realtime.py @@ -50,7 +50,16 @@ def recv(self): raise self.exc if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + event = json.loads(raw) + header = event.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True diff --git a/tests/v2/test_audio_tts_session.py b/tests/v2/test_audio_tts_session.py index 804bc693..cafdebd3 100644 --- a/tests/v2/test_audio_tts_session.py +++ b/tests/v2/test_audio_tts_session.py @@ -19,9 +19,9 @@ from dashscope.v2.resources.audio.tts import SynthesisSession -def _event(name, payload=None): +def _event(name, payload=None, task_id="t"): return { - "header": {"event": name, "task_id": "t", "request_id": "r"}, + "header": {"event": name, "task_id": task_id, "request_id": "r"}, "payload": payload or {}, } @@ -46,7 +46,12 @@ def recv(self): frame = self.inbox.pop(0) if self.inbox else None if isinstance(frame, Exception): raise frame # pylint: disable=raising-bad-type - return json.dumps(frame) if isinstance(frame, dict) else frame + if isinstance(frame, dict): + header = frame.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = self.sent[0]["header"]["task_id"] + return json.dumps(frame) + return frame def settimeout(self, timeout): if self.timeout_error: @@ -260,7 +265,9 @@ def test_cancel_once_before_terminal_including_after_stop( sent_count = len(ws.sent) session.cancel() assert len(ws.sent) == sent_count - assert list(session.events()) == [_event("task-finished")] + expected = _event("task-finished") + expected["header"]["task_id"] = ws.sent[0]["header"]["task_id"] + assert list(session.events()) == [expected] assert ws.close_count == 1 @@ -451,23 +458,52 @@ def test_stream_close_and_context_exception_clean_up(client): _assert_unusable(session) +@pytest.mark.parametrize("error_type", [KeyboardInterrupt, SystemExit]) +def test_interrupted_start_closes_session(client, monkeypatch, error_type): + ws = FakeWebSocket() + session = _session(client, ws) + error = error_type("interrupted handshake") + + def interrupted_run(*args, **kwargs): + raise error + + monkeypatch.setattr( + "dashscope.v2.resources.audio.tts.WsSession.run_task", + interrupted_run, + ) + try: + with pytest.raises(error_type) as caught: + session.start() + assert caught.value is error + assert ws.close_count == 1 + _assert_unusable(session) + finally: + session.close() + + def test_caller_thread_receives_while_another_sends(client): class DuplexWebSocket(FakeWebSocket): def __init__(self): super().__init__() self.frames = queue.Queue() - self.frames.put(json.dumps(_event("task-started"))) self.waiting_for_more = threading.Event() def send(self, payload): super().send(payload) frame = self.sent[-1] action = frame["header"]["action"] + task_id = frame["header"]["task_id"] data = frame["payload"]["input"] - if action == "continue-task" and "text" in data: + if action == "run-task": + self.frames.put( + json.dumps(_event("task-started", task_id=task_id)), + ) + elif action == "continue-task" and "text" in data: self.frames.put(data["text"].encode("utf-8")) elif action == "finish-task": - self.frames.put(json.dumps(_event("task-finished"))) + self.frames.put( + json.dumps(_event("task-finished", task_id=task_id)), + ) def recv(self): if self.frames.empty(): @@ -490,6 +526,9 @@ def close(self): assert received.result(timeout=3) == [ b"first", b"second", - _event("task-finished"), + _event( + "task-finished", + task_id=ws.sent[0]["header"]["task_id"], + ), ] assert ws.close_count == 1 diff --git a/tests/v2/test_audio_ws_integration.py b/tests/v2/test_audio_ws_integration.py index d90468b6..fcdf9fda 100644 --- a/tests/v2/test_audio_ws_integration.py +++ b/tests/v2/test_audio_ws_integration.py @@ -23,6 +23,7 @@ BadRequestError, RateLimitError, ) +from dashscope.v2.transport.ws import WsEventError TIMEOUT = 2 @@ -496,11 +497,16 @@ async def scenario(ws, _request): assert list(events) == [_task_event("task-finished", task_id)] +@pytest.mark.parametrize( + "model", + ["qwen-omni-turbo-realtime", "qwen3.8-omni-flash-realtime"], +) def test_omni_manual_commit_two_responses_and_session_finish( client, local_ws_server, + model, ): - model = "qwen-omni-turbo-realtime" + omni38 = model == "qwen3.8-omni-flash-realtime" session_id = "local-omni-session" audio_inputs = [b"\x00\xffround-one", b"\x80\x01round-two"] settings = { @@ -508,6 +514,15 @@ def test_omni_manual_commit_two_responses_and_session_finish( "input_audio_format": "pcm16", "turn_detection": None, } + if omni38: + settings.pop("input_audio_format") + settings["audio"] = { + "input": {"format": {"type": "pcm", "sample_rate": 16000}}, + "output": { + "format": {"type": "pcm", "sample_rate": 24000}, + "voice": "longanlingxin", + }, + } created = {"type": "session.created", "session": {"id": session_id}} updated = { "type": "session.updated", @@ -579,7 +594,10 @@ async def scenario(ws, request): "type": "input_audio_buffer.commit", } response = await receive_event(ws, "response.create") - assert response["response"]["modalities"] == ["text", "audio"] + if omni38: + assert response == {"type": "response.create"} + else: + assert response["response"]["modalities"] == ["text", "audio"] for event in rounds[index]: await ws.send_json(event) assert await receive_event(ws, "session.finish") == { @@ -587,7 +605,10 @@ async def scenario(ws, request): } assert response_read[-1].done() await _wait_for_return(end_returned) - await ws.send_json(finished) + if omni38: + await ws.close() + else: + await ws.send_json(finished) with local_ws_server(scenario) as server: with client.audio.realtime.connect( @@ -596,15 +617,27 @@ async def scenario(ws, request): timeout=TIMEOUT, ) as realtime: assert realtime.recv_event() == created - realtime.send_event( - {"type": "session.update", "session": settings}, - ) + if omni38: + realtime.update_session( + ["text", "audio"], + input_audio_config={"type": "pcm", "sample_rate": 16000}, + output_audio_config={"type": "pcm", "sample_rate": 24000}, + voice="longanlingxin", + enable_turn_detection=False, + ) + else: + realtime.send_event( + {"type": "session.update", "session": settings}, + ) events = realtime.events() assert next(events) == updated for index, audio in enumerate(audio_inputs): realtime.append_audio(audio) realtime.commit() - realtime.create_response(modalities=["text", "audio"]) + if omni38: + realtime.create_response() + else: + realtime.create_response(modalities=["text", "audio"]) received = [next(events) for _ in rounds[index]] assert received == rounds[index] assert base64.b64decode( @@ -614,8 +647,12 @@ async def scenario(ws, request): response_read[index].set_result(None) realtime.end_session() end_returned.set_result(None) - assert list(events) == [finished] - assert realtime.last_message == finished + if omni38: + assert list(events) == [] + assert realtime.last_message == rounds[-1][-1] + else: + assert list(events) == [finished] + assert realtime.last_message == finished def test_qwen_audio_manual_turns_recover_error_and_close( @@ -1233,3 +1270,309 @@ async def scenario(ws, request): ): list(realtime.events()) assert realtime.last_message["type"] == "response.done" + + +@pytest.mark.parametrize("task", ["asr", "tts"]) +@pytest.mark.parametrize( + "failure, expected_error", + [ + ("invalid-json", json.JSONDecodeError), + ("receive-timeout", websocket.WebSocketTimeoutException), + ("tcp-abort", APIConnectionError), + ("close-frame", APIConnectionError), + ], +) +def test_run_task_faults_after_partial_output( + client, + local_ws_server, + task, + failure, + expected_error, +): + model = ( + "qwen-audio-3.1-asr-flash-streaming" + if task == "asr" + else "qwen-audio-3.0-tts-flash" + ) + partial = {"output": {"sentence": {"text": "partial", "end_time": None}}} + + async def scenario(ws, request): + run = await _receive_run(ws, task=task, model=model) + task_id = run["header"]["task_id"] + await ws.send_json(_task_event("task-started", task_id)) + if task == "asr": + await _receive_binary(ws, b"audio") + else: + await _receive_action(ws, task_id, "continue-task", {"text": "hi"}) + await _receive_action(ws, task_id, "finish-task", {}) + if task == "asr": + await ws.send_json( + _task_event("result-generated", task_id, partial), + ) + else: + await ws.send_bytes(b"partial") + if failure == "invalid-json": + await ws.send_str("{broken") + elif failure == "tcp-abort": + request.transport.abort() + elif failure == "close-frame": + await ws.close(code=1000) + + connect = ( + client.audio.asr.recognize + if task == "asr" + else client.audio.tts.connect + ) + with local_ws_server(scenario) as server: + with connect(model=model, ws_url=server.url, timeout=0.5) as session: + if task == "asr": + session.send_audio_frame(b"audio") + else: + session.send_text("hi") + session.stop() + reader = session.events() + try: + frame = next(reader) + if task == "asr": + assert frame["payload"] == partial + else: + assert frame == b"partial" + with pytest.raises(expected_error): + next(reader) + finally: + reader.close() + + +@pytest.mark.parametrize( + "model", + [ + "qwen3.5-omni-flash-realtime", + "qwen3.8-omni-flash-realtime", + "qwen-audio-3.0-realtime-flash", + "qwen3.5-livetranslate-flash-realtime", + "qwen3.8-livetranslate-flash-realtime", + ], +) +@pytest.mark.parametrize( + "failure, expected_error", + [ + ("invalid-json", json.JSONDecodeError), + ("receive-timeout", websocket.WebSocketTimeoutException), + ("tcp-abort", APIConnectionError), + ], +) +def test_realtime_faults_during_active_response( + client, + local_ws_server, + model, + failure, + expected_error, +): + created = {"type": "response.created", "response": {"id": "r-active"}} + + async def scenario(ws, request): + await ws.send_json(created) + if failure == "invalid-json": + await ws.send_str("{broken") + elif failure == "tcp-abort": + request.transport.abort() + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + timeout=0.5, + ) as session: + assert session.recv_event() == created + with pytest.raises(expected_error): + session.recv_event() + + +@pytest.mark.parametrize("task", ["asr", "tts"]) +@pytest.mark.parametrize("during_start", [False, True]) +def test_run_task_rejects_mismatched_response_id( + client, + local_ws_server, + task, + during_start, +): + model = "fun-asr-realtime" if task == "asr" else "cosyvoice-v1" + + async def scenario(ws, _request): + run = await _receive_run(ws, task=task, model=model) + task_id = run["header"]["task_id"] + await ws.send_json( + _task_event( + "task-started", + "another-task" if during_start else task_id, + ), + ) + if not during_start: + await _receive_action(ws, task_id, "finish-task", {}) + await ws.send_json(_task_event("task-finished", "another-task")) + + connect = ( + client.audio.asr.recognize + if task == "asr" + else client.audio.tts.connect + ) + with local_ws_server(scenario) as server: + with pytest.raises(WsEventError, match="different task"): + with connect(model=model, ws_url=server.url) as session: + assert not during_start + session.stop() + list(session.events()) + + +@pytest.mark.parametrize( + "failure", + [None, "asr-failed", "close-frame", "tcp-abort", "invalid-json"], +) +def test_shared_audio_tasks_route_output_and_isolate_failures( + client, + local_ws_server, + failure, +): + partial = {"output": {"sentence": {"text": "partial"}}} + + async def scenario(ws, request): + run_asr = await _receive_run(ws, task="asr", model="fun-asr-realtime") + asr_id = run_asr["header"]["task_id"] + await ws.send_json(_task_event("task-started", asr_id)) + run_tts = await _receive_run(ws, task="tts", model="cosyvoice-v1") + tts_id = run_tts["header"]["task_id"] + assert asr_id != tts_id + await ws.send_json(_task_event("task-started", tts_id)) + await _receive_binary(ws, b"input-audio") + await _receive_action(ws, tts_id, "continue-task", {"text": "hello"}) + await _receive_action(ws, asr_id, "finish-task", {}) + await _receive_action(ws, tts_id, "finish-task", {}) + await ws.send_bytes(b"output-audio") + await ws.send_json(_task_event("result-generated", asr_id, partial)) + if failure == "close-frame": + await ws.close(code=1000) + elif failure == "tcp-abort": + request.transport.abort() + elif failure == "invalid-json": + await ws.send_str("{broken") + else: + if failure == "asr-failed": + failed = _task_event("task-failed", asr_id) + failed["header"].update( + error_code="InvalidParameter", + error_message="bad audio", + ) + await ws.send_json(failed) + else: + await ws.send_json(_task_event("task-finished", asr_id)) + await ws.send_json(_task_event("task-finished", tts_id)) + + with local_ws_server(scenario) as server: + connection = client.ws_connection(url=server.url) + try: + with client.audio.asr.recognize( + model="fun-asr-realtime", + connection=connection, + ) as asr, client.audio.tts.connect( + model="cosyvoice-v1", + connection=connection, + ) as tts: + asr.send_audio_frame(b"input-audio") + tts.send_text("hello") + asr.stop() + tts.stop() + asr_events = asr.events() + tts_audio = tts.audio_chunks() + try: + assert next(asr_events)["payload"] == partial + assert next(tts_audio) == b"output-audio" + if failure in {"close-frame", "tcp-abort", "invalid-json"}: + expected = ( + json.JSONDecodeError + if failure == "invalid-json" + else APIConnectionError + ) + for stream in (asr_events, tts_audio): + with pytest.raises(expected): + list(stream) + assert connection.closed + else: + if failure == "asr-failed": + with pytest.raises( + BadRequestError, + match="bad audio", + ): + list(asr_events) + else: + assert list(asr_events)[-1]["header"]["event"] == ( + "task-finished" + ) + assert list(tts_audio) == [] + assert not connection.closed + finally: + asr_events.close() + tts_audio.close() + finally: + connection.close() + connection._reader.join(timeout=TIMEOUT) + assert not connection._reader.is_alive() + + +@pytest.mark.parametrize( + "model", + [ + "qwen3.5-omni-flash-realtime", + "qwen3.8-omni-flash-realtime", + "qwen-audio-3.0-realtime-flash", + ], +) +@pytest.mark.parametrize("disconnect", ["close-frame", "tcp-abort"]) +def test_realtime_requested_response_cannot_end_before_created( + client, + local_ws_server, + model, + disconnect, +): + async def scenario(ws, request): + await _receive_realtime_event(ws, {"type": "response.create"}, set()) + if disconnect == "close-frame": + await ws.close(code=1000) + else: + request.transport.abort() + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model=model, + ws_url=server.url, + ) as session: + session.send_event({"type": "response.create"}) + with pytest.raises( + APIConnectionError, + match="before response.done", + ): + list(session.events()) + + +def test_omni38_finish_ack_does_not_complete_active_response( + client, + local_ws_server, +): + async def scenario(ws, request): + await ws.send_json( + {"type": "response.created", "response": {"id": "r1"}}, + ) + await _receive_realtime_event(ws, {"type": "session.finish"}, set()) + await ws.send_json({"type": "session.finished"}) + + with local_ws_server(scenario) as server: + with client.audio.realtime.connect( + model="qwen3.8-omni-flash-realtime", + ws_url=server.url, + ) as session: + assert session.recv_event()["type"] == "response.created" + session.end_session() + with pytest.raises( + APIConnectionError, + match="before response.done", + ): + list(session.events()) diff --git a/tests/v2/test_chat.py b/tests/v2/test_chat.py index 949bd3b5..f9ce1c93 100644 --- a/tests/v2/test_chat.py +++ b/tests/v2/test_chat.py @@ -44,6 +44,67 @@ def _make_async_client(handler, **kwargs): return AsyncClient(api_key="sk-test", http_client=http_client, **kwargs) +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("status", [199, 200, 302, 307, 308]) +async def test_chat_preserves_non_error_http_statuses( + asynchronous, + streaming, + status, +): + body = { + "request_id": "body-id", + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": "unchanged"}}, + ], + }, + } + response = httpx.Response( + status, + content=_sse([body]) if streaming else json.dumps(body), + headers={ + "content-type": "text/event-stream" + if streaming + else "application/json", + "location": f"{BASE_URL}/redirect-target", + }, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + client = (_make_async_client if asynchronous else _make_client)(handler) + try: + result = client.chat.completions.create( + model="qwen-plus", + messages="hi", + stream=streaming, + ) + if asynchronous: + result = await result + if streaming: + texts = ( + [text async for text in result.text_stream] + if asynchronous + else list(result.text_stream) + ) + assert texts == ["unchanged"] + else: + assert result.output_text == "unchanged" + assert not result.is_error + assert len(requests) == 1 + assert response.is_closed + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + def test_create_non_streaming(): captured = {} @@ -997,7 +1058,7 @@ def handler(request): parameters={"incremental_output": False}, ) # Raw iteration stays server-cumulative (1.x passthrough behavior). - # pylint: disable-next=not-an-iterable # stream=True overload returns ChatStream + # pylint: disable-next=not-an-iterable assert [c.delta.content for c in stream] == ["你", "你好。"] client2 = _make_client(handler) diff --git a/tests/v2/test_errors.py b/tests/v2/test_errors.py index 617abffd..f22b3ca5 100644 --- a/tests/v2/test_errors.py +++ b/tests/v2/test_errors.py @@ -13,9 +13,12 @@ from __future__ import annotations +import json + import httpx import pytest +from dashscope.v2 import AsyncClient, Client from dashscope.v2 import _error_registry as registry from dashscope.v2 import errors from dashscope.v2.errors import ( @@ -44,7 +47,7 @@ _STATUS_MAP, _WIRE_TO_PUBLIC, ) -from dashscope.v2.types.common import JobStatus, ResponseBase +from dashscope.v2.types.common import JobStatus, ResponseBase, response_body def _resp(status, body=None, headers=None): @@ -227,6 +230,45 @@ def test_response_base_subclasses_inherit_the_flag(): assert not JobStatus(status="SUCCEEDED").is_error +@pytest.mark.parametrize("model", [ResponseBase, JobStatus]) +@pytest.mark.parametrize("status", [None, 0, 199, 200, 302, 400, 503]) +def test_response_base_does_not_infer_http_failure_from_body_status( + model, + status, +): + assert not model(status_code=status).is_error + assert model(status_code=status, code="Throttling").is_error + assert model(status_code=status, is_error=True).is_error + + +@pytest.mark.parametrize("status", [200, 400]) +@pytest.mark.parametrize( + "outer_id, header_id, expected_id", + [ + ("outer-id", "header-id", "outer-id"), + (None, "header-id", "header-id"), + (None, None, None), + ], +) +def test_shared_error_request_id_ignores_nested_output( + status, + outer_id, + header_id, + expected_id, +): + body = { + "code": "InvalidParameter", + "message": "rejected", + "request_id": outer_id, + "output": {"request_id": "inner-id"}, + } + headers = {"x-request-id": header_id} if header_id is not None else {} + with pytest.raises(BadRequestError) as caught: + check_response(_resp(status, body, headers)) + assert caught.value.request_id == expected_id + assert caught.value.body == body + + def test_non_json_body(): resp = httpx.Response( 502, @@ -306,6 +348,284 @@ def test_sse_body_without_decodable_frame_keeps_raw_text(): assert ei.value.message == "event:error\ndata:oops" +_SSE_ERROR_PAYLOAD = { + "code": "DataInspectionFailed", + "message": "blocked\n请稍后", + "request_id": "sse-body-id", + "output": {"request_id": "ignored-inner-id"}, +} + + +def _multiline_sse_error(newline="\n", terminated=False): + lines = [": keepalive", "event: error", "id: ignored-event-id"] + lines.extend( + f"data: {line}" + for line in json.dumps( + _SSE_ERROR_PAYLOAD, + indent=2, + ensure_ascii=False, + ).splitlines() + ) + return newline.join(lines) + (newline * 2 if terminated else "") + + +@pytest.mark.parametrize("newline", ["\n", "\r\n", "\r"]) +@pytest.mark.parametrize("terminated", [False, True]) +def test_sse_error_body_accumulates_event_data(newline, terminated): + text = _multiline_sse_error(newline, terminated) + if terminated: + text += 'data: {"code":"InvalidParameter","message":"later"}' + response = httpx.Response( + 400, + text=text, + headers={ + "content-type": "application/json", + "x-request-id": "header-id", + }, + ) + with pytest.raises(ContentFilterError) as caught: + check_response(response) + assert caught.value.body == _SSE_ERROR_PAYLOAD + assert caught.value.code == "DataInspectionFailed" + assert caught.value.message == _SSE_ERROR_PAYLOAD["message"] + assert caught.value.request_id == "sse-body-id" + assert caught.value.status_code == 400 + assert caught.value.headers["x-request-id"] == "header-id" + + +@pytest.mark.parametrize("separator", ["\n", "\n\n", "\r\n\r\n"]) +@pytest.mark.parametrize( + "prefix", + [ + "data: {}", + 'event: ping\ndata: {"type":"ping"}', + 'event: ping\ndata: {"message":"keepalive"}', + 'data: {"output":{"text":"ok"}}', + 'data: {"type":"message_start","message":{"id":"msg"}}', + "data: not-json", + "data: [DONE]", + ], +) +def test_sse_error_body_skips_non_error_frames(prefix, separator): + text = ( + prefix + + separator + + "event: error\ndata: " + + json.dumps( + _SSE_ERROR_PAYLOAD, + ) + ) + with pytest.raises(ContentFilterError) as caught: + check_response(httpx.Response(400, text=text)) + assert caught.value.body == _SSE_ERROR_PAYLOAD + assert caught.value.message == _SSE_ERROR_PAYLOAD["message"] + assert caught.value.request_id == "sse-body-id" + + +@pytest.mark.parametrize( + "payload, expected_code", + [ + ({"message": "rejected"}, None), + ({"error": {"message": "rejected", "code": "bad_key"}}, "bad_key"), + ( + { + "type": "error", + "error": {"message": "rejected", "type": "bad_key"}, + }, + "bad_key", + ), + ], +) +def test_sse_error_body_preserves_message_and_nested_envelopes( + payload, + expected_code, +): + text = "data: {}\n\nevent: error\n" + "\n".join( + f"data: {line}" for line in json.dumps(payload, indent=2).splitlines() + ) + with pytest.raises(AuthenticationError) as caught: + check_response(httpx.Response(401, text=text)) + assert caught.value.body == payload + assert caught.value.message == "rejected" + assert caught.value.code == expected_code + + +@pytest.mark.parametrize("status", [200, 400]) +@pytest.mark.parametrize( + "text", + [ + "event: error\ndata: not-json\n\n", + 'event: error\ndata: {"code":\ndata: invalid}\n\n', + "data: {}\n\ndata: [DONE]\n\n", + 'event: ping\ndata: {"type":"ping"}\n\n', + 'data: {"output":{"text":"ok"}}', + 'data: {"message":{"id":"msg"},"error":{}}', + 'data: [\ndata: {"code":"InvalidParameter"}\ndata: ]\n\n', + "data:\n\ndata\n\n", + ], +) +def test_sse_body_without_business_error_keeps_raw_fallback(status, text): + response = httpx.Response(status, text=text) + error = errors.response_error(response) + if status == 200: + assert error is None + else: + assert type(error) is BadRequestError + assert error.body is None + assert error.code is None + assert error.message == text + assert error.status_code == 400 + + +def test_normal_json_error_body_is_not_reinterpreted_as_sse(): + payload = { + "code": "InvalidParameter", + "message": 'event: error\ndata: {"code":"DataInspectionFailed"}', + "request_id": "json-body-id", + } + with pytest.raises(BadRequestError) as caught: + check_response(_resp(400, payload)) + assert type(caught.value) is BadRequestError + assert caught.value.body == payload + assert caught.value.message == payload["message"] + assert caught.value.request_id == "json-body-id" + + +class _ErrorBodyStream(httpx.SyncByteStream, httpx.AsyncByteStream): + def __init__(self, text): + self.content = text.encode("utf-8") + self.closed = False + + def __iter__(self): + for offset in range(0, len(self.content), 7): + yield self.content[offset : offset + 7] + + async def __aiter__(self): + for chunk in self: + yield chunk + + def close(self): + self.closed = True + + async def aclose(self): + self.closed = True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize( + "text, payload", + [ + (_multiline_sse_error("\r\n"), _SSE_ERROR_PAYLOAD), + (_multiline_sse_error("\n", True), _SSE_ERROR_PAYLOAD), + ("data: {}\n\n" + _multiline_sse_error(), _SSE_ERROR_PAYLOAD), + ( + "data: {}\ndata: " + json.dumps(_SSE_ERROR_PAYLOAD), + _SSE_ERROR_PAYLOAD, + ), + ("event: error\ndata: broken-json", None), + ('data: {"type":"ping"}\n\n', None), + (json.dumps(_SSE_ERROR_PAYLOAD), _SSE_ERROR_PAYLOAD), + ], +) +async def test_public_http_error_propagates_and_closes( + monkeypatch, + asynchronous, + streaming, + text, + payload, +): + monkeypatch.setattr("dashscope.v2._config._load_config_file", lambda _: {}) + monkeypatch.setattr( + "dashscope.v2._config._api_key_from_file", + lambda: None, + ) + wire = _ErrorBodyStream(text) + response = httpx.Response( + 400, + stream=wire, + headers={ + "content-type": "text/event-stream", + "x-request-id": "header-id", + }, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + base_url = "https://errors.test.local" + http_client = (httpx.AsyncClient if asynchronous else httpx.Client)( + transport=httpx.MockTransport(handler), + base_url=base_url, + ) + client = (AsyncClient if asynchronous else Client)( + api_key="sk-test-only", + base_url=base_url, + protocol="native", + max_retries=0, + http_client=http_client, + ) + expected_class = ContentFilterError if payload else BadRequestError + try: + assert not wire.closed and not response.is_closed + with pytest.raises(expected_class) as caught: + result = client.chat.completions.create( + model="test-model", + messages="hi", + stream=streaming, + ) + if asynchronous: + await result + error = caught.value + assert type(error) is expected_class + assert error.body == payload + assert error.status_code == 400 + assert error.code == (payload["code"] if payload else None) + assert error.message == (payload["message"] if payload else text) + assert error.request_id == ( + payload["request_id"] if payload else "header-id" + ) + assert error.headers["x-request-id"] == "header-id" + assert len(requests) == 1 + assert requests[0].url.host == "errors.test.local" + assert wire.closed and response.is_closed + finally: + if asynchronous: + await client.aclose() + await http_client.aclose() + else: + client.close() + http_client.close() + + +@pytest.mark.parametrize("raw", [None, [], "gateway failed", 0]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +def test_strict_http_envelope_retains_non_object_failures(raw, raise_on_error): + response = _resp(503, raw, {"x-request-id": "header-id"}) + if raise_on_error: + with pytest.raises(ServiceUnavailableError) as caught: + response_body(response) + assert caught.value.body == raw + assert caught.value.request_id == "header-id" + else: + result = ResponseBase(**response_body(response, raise_on_error=False)) + assert result.is_error + assert result.status_code == 503 + assert result.code is None + assert result.message == response.text + assert result.request_id == "header-id" + + +@pytest.mark.parametrize("raw", [None, [], "not an envelope", 0]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +def test_strict_http_envelope_rejects_non_object_success(raw, raise_on_error): + with pytest.raises(ValueError): + response_body(_resp(200, raw), raise_on_error=raise_on_error) + + def test_error_code_enum(): assert ErrorCode.INVALID_API_KEY.value == "InvalidApiKey" assert ErrorCode.DATA_INSPECTION_FAILED.value == "DataInspectionFailed" diff --git a/tests/v2/test_finetune_deployments.py b/tests/v2/test_finetune_deployments.py index ba9f8719..afc3238f 100644 --- a/tests/v2/test_finetune_deployments.py +++ b/tests/v2/test_finetune_deployments.py @@ -793,3 +793,283 @@ def handler(request): deployments = await client.deployments.list() assert deployments.is_error and deployments.code == "Throttling" await client.aclose() + + +_EVENT_CLIENT_CASES = [ + pytest.param( + resource, + is_async, + raise_on_error, + id=f"{resource}-{'async' if is_async else 'sync'}-raise-{raise_on_error}", + ) + for resource in ("fine_tunes", "deployments") + for is_async in (False, True) + for raise_on_error in (False, True) +] + + +class _EventStream(httpx.SyncByteStream, httpx.AsyncByteStream): + """Unbuffered mock shared by both APIs, with a read-ahead tripwire.""" + + def __init__(self, *parts, require_first_consumed=False, error=None): + self.parts = parts + self.require_first_consumed = require_first_consumed + self.first_consumed = False + self.error = error + self.reads = 0 + self.closed = False + + def __iter__(self): + for index, part in enumerate(self.parts): + if index and self.require_first_consumed: + assert ( + self.first_consumed + ), "read ahead before first data was consumed" + self.reads += 1 + yield part + if self.error is not None: + raise self.error + + async def __aiter__(self): + for part in self: + yield part + + def close(self): + self.closed = True + + async def aclose(self): + self.close() + + +def _event_client(wire, resource, is_async, raise_on_error, content_type): + response = httpx.Response( + 200, + stream=wire, + headers={"content-type": content_type, "x-request-id": "header-id"}, + ) + + def handler(request): + assert ( + request.url.path + == f"/api/v1/{resource.replace('_', '-')}/job-1/stream" + ) + return response + + factory = _make_async_client if is_async else _make_client + client = factory(handler, raise_on_error=raise_on_error, max_retries=0) + return client, response, getattr(client, resource).stream_events("job-1") + + +async def _close_event_client(client, is_async): + # The injected HTTP client is caller-owned, so close that pool explicitly. + if is_async: + await client.transport.http_client.aclose() + else: + client.transport.http_client.close() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +@pytest.mark.parametrize( + "content_type", + ["application/json", "application/problem+json"], +) +@pytest.mark.parametrize( + "body", + [ + b'{"code":"Throttling","message":"slow down"}', + b'{"output":{"status":"SUCCEEDED"}}', + b'{"output":', + ], +) +async def test_job_events_http200_json_remains_empty( + resource, + is_async, + raise_on_error, + content_type, + body, +): + wire = _EventStream(body) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + content_type, + ) + try: + events = ( + [event async for event in stream] if is_async else list(stream) + ) + assert events == [] + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +async def test_job_events_data_lines_are_immediate_and_code_is_not_error( + resource, + is_async, + raise_on_error, +): + first_body = {"code": "Throttling", "message": "ordinary event"} + wire = _EventStream( + ("data: " + json.dumps(first_body) + "\n").encode(), + b'data: {"status":"SUCCEEDED"}\n', + require_first_consumed=True, + ) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + "text/event-stream", + ) + try: + first = await stream.__anext__() if is_async else next(stream) + assert first.output == first_body + assert not first.is_error + assert wire.reads == 1 and not wire.closed + wire.first_consumed = True + rest = [event async for event in stream] if is_async else list(stream) + assert len(rest) == 1 + assert rest[0].output == {"status": "SUCCEEDED"} + assert not rest[0].is_error + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +@pytest.mark.parametrize( + "status, code, error_type", + [ + (None, "Throttling", RateLimitError), + (400, "InvalidParameter", BadRequestError), + (429, "Throttling", RateLimitError), + ], +) +async def test_job_events_legacy_error_mapping_and_nonraising_event( + resource, + is_async, + raise_on_error, + status, + code, + error_type, +): + body = { + "code": code, + "message": "rejected", + "output": {"request_id": "inner-id"}, + } + status_line = "" if status is None else f"status: {status}\n" + wire = _EventStream( + b'data: {"status":"RUNNING"}\n\n', + ( + "event:error\n" + + status_line + + "data: " + + json.dumps(body) + + "\n\n" + ).encode(), + b'data: {"status":"SHOULD_NOT_BE_READ"}\n\n', + ) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + "text/event-stream", + ) + try: + first = await stream.__anext__() if is_async else next(stream) + assert first.output == {"status": "RUNNING"} and not first.is_error + if raise_on_error: + with pytest.raises(error_type, match="rejected") as caught: + if is_async: + await stream.__anext__() + else: + next(stream) + assert caught.value.status_code == (status or 200) + assert caught.value.code == code + assert caught.value.body == body + # Job events use only the top-level body request ID. + assert caught.value.request_id is None + assert caught.value.headers == {} + else: + rest = ( + [event async for event in stream] if is_async else list(stream) + ) + assert len(rest) == 1 and rest[0].is_error + assert rest[0].output == body + assert wire.reads == 2 + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "resource, is_async, raise_on_error", + _EVENT_CLIENT_CASES, +) +@pytest.mark.parametrize( + "failure", + ["invalid-status", "read-error", "early-close"], +) +async def test_job_events_close_on_original_exceptions_and_early_exit( + resource, + is_async, + raise_on_error, + failure, +): + # Disabling error-event exceptions does not suppress parse or read failures. + wire = _EventStream( + b'data: {"status":"RUNNING"}\n', + b"status: invalid\ndata: value\n" + if failure == "invalid-status" + else b"", + error=httpx.ReadError("connection lost") + if failure == "read-error" + else None, + ) + client, response, stream = _event_client( + wire, + resource, + is_async, + raise_on_error, + "text/event-stream", + ) + try: + first = await stream.__anext__() if is_async else next(stream) + assert first.output == {"status": "RUNNING"} + assert not wire.closed + if failure == "early-close": + if is_async: + await stream.aclose() + else: + stream.close() + else: + error_type = ( + ValueError if failure == "invalid-status" else httpx.ReadError + ) + with pytest.raises(error_type): + if is_async: + await stream.__anext__() + else: + next(stream) + assert wire.closed and response.is_closed + finally: + await _close_event_client(client, is_async) diff --git a/tests/v2/test_jobs.py b/tests/v2/test_jobs.py index 192abe42..bdf2c536 100644 --- a/tests/v2/test_jobs.py +++ b/tests/v2/test_jobs.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +from types import SimpleNamespace import httpx import pytest @@ -39,6 +40,27 @@ def _job_body(status="PENDING", task_id="task-1", **extra): return {"request_id": "req-1", "output": output} +@pytest.fixture +def wait_clock(): + now = [10.0] + sleeps = [] + + def sleep(seconds): + sleeps.append(seconds) + now[0] += seconds + + async def async_sleep(seconds): + sleep(seconds) + + # Replace only the resource's time binding, not asyncio's global clock. + return SimpleNamespace( + monotonic=lambda: now[0], + sleep=sleep, + async_sleep=async_sleep, + sleeps=sleeps, + ) + + def test_job_from_payload(): job = Job.from_payload( { @@ -93,6 +115,73 @@ def handler(request): assert job.finished is False +@pytest.mark.parametrize( + "parameter_kwargs, expected_fields", + [ + pytest.param({}, {}, id="omitted"), + pytest.param({"parameters": None}, {}, id="none"), + pytest.param({"parameters": {}}, {"parameters": {}}, id="empty"), + ], +) +def test_create_distinguishes_none_and_empty_parameters( + parameter_kwargs, + expected_fields, +): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_job_body()) + + with _make_client(handler) as client: + Jobs(client, "aigc").create( + model="wanx2.1-t2i", + input={"prompt": "a cat"}, + **parameter_kwargs, + ) + assert bodies == [ + { + "model": "wanx2.1-t2i", + "input": {"prompt": "a cat"}, + **expected_fields, + }, + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "parameter_kwargs, expected_fields", + [ + pytest.param({}, {}, id="omitted"), + pytest.param({"parameters": None}, {}, id="none"), + pytest.param({"parameters": {}}, {"parameters": {}}, id="empty"), + ], +) +async def test_async_create_distinguishes_none_and_empty_parameters( + parameter_kwargs, + expected_fields, +): + bodies = [] + + def handler(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json=_job_body()) + + async with _make_async_client(handler) as client: + await AsyncJobs(client, "aigc").create( + model="wanx2.1-t2i", + input={"prompt": "a cat"}, + **parameter_kwargs, + ) + assert bodies == [ + { + "model": "wanx2.1-t2i", + "input": {"prompt": "a cat"}, + **expected_fields, + }, + ] + + def test_wait_polls_with_exponential_backoff(): statuses = iter(["RUNNING", "RUNNING", "SUCCEEDED"]) calls = [] @@ -143,14 +232,15 @@ def handler(request): assert delays == [1.0, 2.0, 2.0] -def test_wait_times_out(): +def test_wait_times_out(monkeypatch, wait_clock): def handler(request): return httpx.Response(200, json=_job_body("RUNNING")) + monkeypatch.setattr("dashscope.v2.resources._jobs.time", wait_clock) client = _make_client(handler) jobs = Jobs(client, "aigc") with pytest.raises(DashScopeError, match="timed out"): - jobs.wait("task-1", timeout=0.001, _sleep=lambda seconds: None) + jobs.wait("task-1", timeout=0.5, _sleep=wait_clock.sleep) def test_fetch_failed_raises_mapped_error(): @@ -389,7 +479,7 @@ def handler(request): job = jobs.wait( "task-1", timeout=60.0, - _sleep=sleeps.append, + _sleep=lambda seconds: sleeps.append(seconds), ) # The error envelope has no task status; wait must return it # instead of polling until the timeout. @@ -503,3 +593,385 @@ def handler(request): assert job.succeeded is True assert len(calls) == 3 assert delays == [1.0, 2.0] + + +@pytest.mark.parametrize("modality", ["images", "videos", "embeddings"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize( + "status", + ["SUCCEEDED", "FAILED", "CANCELED", "UNKNOWN"], +) +async def test_non_asr_wait_terminal_snapshot_contract( + modality, + asynchronous, + raise_on_error, + status, +): + def handler(request): + pytest.fail("terminal snapshots must not trigger another request") + + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + job = Job.from_payload( + _job_body( + status, + **( + {"code": "InvalidParameter", "message": "invalid input"} + if status == "FAILED" + else {} + ), + ), + ) + try: + jobs = getattr(client, modality).jobs + if raise_on_error and status == "CANCELED": + with pytest.raises(DashScopeError, match="canceled") as caught: + result = jobs.wait(job) + if asynchronous: + await result + assert type(caught.value) is DashScopeError + assert caught.value.request_id == "req-1" + else: + result = jobs.wait(job) + if asynchronous: + result = await result + assert result is job + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("modality", ["images", "videos", "embeddings"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +async def test_non_asr_wait_allows_final_fetch_at_deadline( + monkeypatch, + wait_clock, + modality, + asynchronous, + raise_on_error, +): + requests = [] + + def handler(request): + requests.append(request) + assert request.method == "GET" + assert request.url.path == "/api/v1/tasks/task-1" + assert wait_clock.monotonic() == 10.5 + return httpx.Response(200, json=_job_body("SUCCEEDED")) + + monkeypatch.setattr("dashscope.v2.resources._jobs.time", wait_clock) + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + try: + result = getattr(client, modality).jobs.wait( + Job(task_id="task-1", status="RUNNING"), + timeout=0.5, + poll_interval=1, + _sleep=wait_clock.async_sleep + if asynchronous + else wait_clock.sleep, + ) + if asynchronous: + result = await result + assert result.succeeded is True + assert len(requests) == 1 + assert wait_clock.sleeps == [0.5] + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("modality", ["images", "videos", "embeddings", "asr"]) +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize("status_code", [None, 0]) +async def test_public_wait_polls_running_until_succeeded( + monkeypatch, + wait_clock, + modality, + asynchronous, + raise_on_error, + status_code, +): + statuses = iter(["RUNNING", "RUNNING", "RUNNING", "SUCCEEDED"]) + requests = [] + output_extra = {} if status_code is None else {"status_code": status_code} + + def handler(request): + requests.append(request) + assert request.method == "GET" + assert request.url.path == "/api/v1/tasks/task-1" + assert request.headers["Authorization"] == "Bearer sk-override" + assert request.headers["X-DashScope-WorkSpace"] == "ws-9" + return httpx.Response( + 200, + json=_job_body(next(statuses), **output_extra), + ) + + clock_module = "audio.asr" if modality == "asr" else "_jobs" + monkeypatch.setattr( + f"dashscope.v2.resources.{clock_module}.time", + wait_clock, + ) + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + try: + resource = ( + client.audio.asr + if modality == "asr" + else getattr(client, modality) + ) + result = resource.jobs.wait( + "task-1", + timeout=60, + poll_interval=1, + max_interval=2, + api_key="sk-override", + workspace="ws-9", + _sleep=wait_clock.async_sleep + if asynchronous + else wait_clock.sleep, + ) + if asynchronous: + result = await result + assert result.succeeded is True + assert result.is_error is False + assert result.request_id == "req-1" + if status_code is not None: + assert result.output["status_code"] == status_code + assert len(requests) == 4 + assert wait_clock.sleeps == [1, 2, 2] + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +async def test_asr_wait_does_not_fetch_after_budget_expires( + monkeypatch, + wait_clock, + asynchronous, + raise_on_error, +): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json=_job_body("SUCCEEDED")) + + monkeypatch.setattr("dashscope.v2.resources.audio.asr.time", wait_clock) + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + try: + with pytest.raises(DashScopeError, match="timed out"): + result = client.audio.asr.jobs.wait( + Job(task_id="task-1", status="RUNNING"), + timeout=0.5, + poll_interval=1, + _sleep=( + wait_clock.async_sleep + if asynchronous + else wait_clock.sleep + ), + ) + if asynchronous: + await result + assert requests == [] + assert wait_clock.sleeps == [0.5] + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("raise_on_error", [False, True]) +@pytest.mark.parametrize( + "status", + ["SUCCEEDED", "FAILED", "CANCELED", "UNKNOWN"], +) +async def test_asr_wait_terminal_snapshot_contract( + asynchronous, + raise_on_error, + status, +): + def handler(request): + pytest.fail("terminal snapshots must not trigger another request") + + client = ( + _make_async_client(handler, raise_on_error=raise_on_error) + if asynchronous + else _make_client(handler, raise_on_error=raise_on_error) + ) + job = Job.from_payload( + _job_body( + status, + **( + {"code": "InvalidParameter", "message": "invalid audio"} + if status == "FAILED" + else {} + ), + ), + ) + job.headers = {"x-request-id": "header-id", "x-gateway": "test"} + try: + if raise_on_error and status in ("FAILED", "CANCELED"): + with pytest.raises(DashScopeError) as caught: + result = client.audio.asr.jobs.wait(job) + if asynchronous: + await result + assert caught.value.request_id == "req-1" + assert dict(caught.value.headers) == job.headers + if status == "FAILED": + assert type(caught.value).__name__ == "BadRequestError" + assert caught.value.code == "InvalidParameter" + assert caught.value.message == "invalid audio" + else: + result = client.audio.asr.jobs.wait(job) + if asynchronous: + result = await result + assert result is job + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +async def test_async_asr_wait_cancellation_stops_polling(): + import asyncio + + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response(200, json=_job_body("RUNNING")) + + async def cancel_sleep(seconds): + raise asyncio.CancelledError() + + async with _make_async_client(handler) as client: + with pytest.raises(asyncio.CancelledError): + await client.audio.asr.jobs.wait( + "task-1", + _sleep=cancel_sleep, + ) + assert len(requests) == 1 + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("entry", ["transcribe", "fetch"]) +@pytest.mark.parametrize( + "status, code, expected_error", + [ + (400, "InvalidParameter", "BadRequestError"), + (401, "InvalidApiKey", "AuthenticationError"), + (403, "AccessDenied", "PermissionDeniedError"), + (404, "NotFound", "NotFoundError"), + (429, "Throttling", "RateLimitError"), + (500, "InternalError", "InternalServerError"), + (200, "InvalidParameter", "BadRequestError"), + ], +) +async def test_asr_http_errors_preserve_request_id( + asynchronous, + entry, + status, + code, + expected_error, +): + requests = [] + + def handler(request): + requests.append(request) + return httpx.Response( + status, + headers={"x-request-id": "header-request"}, + json={"code": code, "message": "audio request rejected"}, + ) + + client = ( + _make_async_client(handler, max_retries=0) + if asynchronous + else _make_client(handler, max_retries=0) + ) + try: + with pytest.raises(DashScopeError) as caught: + if entry == "transcribe": + result = client.audio.asr.transcribe( + ["https://example.com/a.wav"], + ) + else: + result = client.audio.asr.jobs.fetch("task-1") + if asynchronous: + await result + assert type(caught.value).__name__ == expected_error + assert caught.value.code == code + assert caught.value.request_id == "header-request" + assert caught.value.status_code == status + assert len(requests) == 1 + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize( + "transport_error, expected_error", + [ + (httpx.ReadTimeout, "APITimeoutError"), + (httpx.ConnectError, "APIConnectionError"), + ], +) +async def test_asr_transport_errors_are_not_suppressed( + asynchronous, + transport_error, + expected_error, +): + calls = [] + + def handler(request): + calls.append(request) + raise transport_error("network fault", request=request) + + client = ( + _make_async_client(handler, raise_on_error=False, max_retries=0) + if asynchronous + else _make_client(handler, raise_on_error=False, max_retries=0) + ) + try: + with pytest.raises(DashScopeError) as caught: + result = client.audio.asr.transcribe(["https://example.com/a.wav"]) + if asynchronous: + await result + assert type(caught.value).__name__ == expected_error + assert len(calls) == 1 + finally: + if asynchronous: + await client.aclose() + else: + client.close() diff --git a/tests/v2/test_sse.py b/tests/v2/test_sse.py index 377955fe..b57fdc41 100644 --- a/tests/v2/test_sse.py +++ b/tests/v2/test_sse.py @@ -18,6 +18,11 @@ DashScopeError, RateLimitError, ) +from dashscope.v2.resources._sse import _aiter_sse, _iter_sse, _stream_error +from dashscope.v2.resources.audio.http_tts import ( + _audio_stream_error, + _iter_audio_sse, +) from dashscope.v2.transport.sse import ( parse_event_data, stream_error, @@ -397,3 +402,231 @@ def handler(request): pass assert exc_info.value.request_id == "req-async-1" await client.aclose() + + +_AUDIO_SSE_CASES = [ + pytest.param(": keepalive\n\nid: 1\n\n", [], id="no-data"), + pytest.param( + 'status: 201\r\ndata: {\r\ndata: "status": "RUNNING"\r\n' + 'data: }\r\n\r\ndata: {"status": "SUCCEEDED"}\r\n\r\n', + [ + (False, 201, '{\n"status": "RUNNING"\n}'), + (False, None, '{"status": "SUCCEEDED"}'), + ], + id="multiline-and-status-reset", + ), + pytest.param( + 'event: errorish\ndata: {"status": "RUNNING"} \n\n', + [(False, None, ' {"status": "RUNNING"} ')], + id="exact-event-and-preserved-data-spaces", + ), + pytest.param( + "data:\n\ndata: second\n\n", + [(False, None, ""), (False, None, "second")], + id="empty-data", + ), + pytest.param( + "event: error\nstatus: 400\n\ndata: ok\n\n", + [(False, None, "ok")], + id="empty-frame-resets-metadata", + ), + pytest.param( + "event: error\nstatus: 400\ndata: rejected\n\ndata: ignored\n\n", + [(True, 400, "rejected")], + id="standard-error-stops-stream", + ), + pytest.param( + 'data: {"code": "Throttling"}\n\ndata: ignored\n\n', + [(True, None, '{"code": "Throttling"}')], + id="data-only-business-error", + ), + pytest.param( + 'data: {\ndata: "code": "Throttling"\ndata: }\n\n', + [(True, None, '{\n"code": "Throttling"\n}')], + id="multiline-business-error", + ), + pytest.param( + 'event:error\nstatus: 429\ndata: {"code": "Throttling"}', + [(True, 429, '{"code": "Throttling"}')], + id="legacy-error-without-final-newline", + ), + pytest.param( + 'data: {"status": "SUCCEEDED"}\n', + [(False, None, '{"status": "SUCCEEDED"}')], + id="legacy-eof-without-blank-line", + ), +] + + +@pytest.mark.parametrize("wire, expected", _AUDIO_SSE_CASES) +def test_audio_sse_sync_framing(wire, expected): + response = httpx.Response(200, text=wire) + assert list(_iter_audio_sse(response)) == expected + + +_JOB_SSE_CASES = [ + pytest.param(": keepalive\n\nid: 1\n\n", [], id="no-data"), + pytest.param( + "status: 201\ndata: first\ndata: second\n\ndata: third", + [(False, 201, "first"), (False, 201, "second"), (False, 201, "third")], + id="per-line-with-sticky-status", + ), + pytest.param( + "event: error\ndata: payload \n\n", + [(False, None, "payload")], + id="legacy-event-spelling-and-stripped-data", + ), + pytest.param( + "event:errorish\nstatus: 400\n\ndata: rejected\ndata: ignored\n", + [(True, 400, "rejected")], + id="legacy-prefix-error-and-no-blank-reset", + ), + pytest.param( + 'data: {"code": "Throttling"}\ndata: next\n', + [(False, None, '{"code": "Throttling"}'), (False, None, "next")], + id="code-is-ordinary-data", + ), + pytest.param( + "event:error\ndata: rejected", + [(True, None, "rejected")], + id="error-at-eof", + ), + pytest.param( + "data:\n\ndata: next\n", + [(False, None, ""), (False, None, "next")], + id="empty-data", + ), +] + + +@pytest.mark.parametrize("wire, expected", _JOB_SSE_CASES) +def test_resource_sse_sync_legacy_framing(wire, expected): + assert list(_iter_sse(httpx.Response(200, text=wire))) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("wire, expected", _JOB_SSE_CASES) +async def test_resource_sse_async_legacy_framing(wire, expected): + response = httpx.Response(200, text=wire) + assert [frame async for frame in _aiter_sse(response)] == expected + + +def test_resource_sse_invalid_status_still_raises(): + # Malformed status was always an exception, unlike malformed JSON data. + response = httpx.Response(200, text="status: invalid\ndata: value\n") + with pytest.raises(ValueError): + list(_iter_sse(response)) + + +@pytest.mark.asyncio +async def test_resource_sse_async_invalid_status_still_raises(): + response = httpx.Response(200, text="status: invalid\ndata: value\n") + with pytest.raises(ValueError): + async for _ in _aiter_sse(response): + pytest.fail("malformed status must raise before yielding data") + + +@pytest.mark.parametrize("status", [None, 429]) +@pytest.mark.parametrize( + "outer_id, inner_id, expected_id", + [ + ("outer-id", "inner-id", "outer-id"), + (None, "inner-id", "inner-id"), + (None, None, "header-id"), + ], +) +def test_audio_stream_error_keeps_headers_and_request_id( + status, + outer_id, + inner_id, + expected_id, +): + body = { + "code": "Throttling", + "message": "slow down", + "request_id": outer_id, + "output": {"request_id": inner_id}, + } + headers = {"x-request-id": "header-id"} + error = _audio_stream_error(status, json.dumps(body), headers=headers) + assert isinstance(error, RateLimitError) + assert error.status_code == (status or 200) + assert error.request_id == expected_id + assert error.body == body + assert error.headers == headers + + +@pytest.mark.parametrize( + "content_type", + [ + "application/json", + "Application/JSON; charset=utf-8", + "application/problem+json", + ], +) +def test_audio_sse_sync_json_business_error(content_type): + response = httpx.Response( + 200, + json={"code": "Throttling", "message": "slow down"}, + headers={"content-type": content_type, "x-request-id": "json-id"}, + ) + with pytest.raises(RateLimitError, match="slow down") as caught: + list(_iter_audio_sse(response)) + assert caught.value.status_code == 200 + assert caught.value.request_id == "json-id" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "content_type", + [ + "application/json", + "Application/JSON; charset=utf-8", + "application/problem+json", + ], +) +async def test_resource_sse_async_json_business_error_is_empty(content_type): + response = httpx.Response( + 200, + json={"code": "Throttling", "message": "slow down"}, + headers={"content-type": content_type, "x-request-id": "json-id"}, + ) + assert [frame async for frame in _aiter_sse(response)] == [] + + +def test_audio_sse_sync_malformed_json_response(): + response = httpx.Response( + 200, + content=b'{"output":', + headers={"content-type": "application/json"}, + ) + with pytest.raises(ValueError): + list(_iter_audio_sse(response)) + + +@pytest.mark.asyncio +async def test_resource_sse_async_malformed_json_response_is_empty(): + # JSON errors belong to audio's decoder; job streams never parse JSON MIME. + response = httpx.Response( + 200, + content=b'{"output":', + headers={"content-type": "application/json"}, + ) + assert [frame async for frame in _aiter_sse(response)] == [] + + +@pytest.mark.parametrize("status", [None, 429]) +@pytest.mark.parametrize("outer_id", [None, "outer-id"]) +def test_resource_stream_error_keeps_legacy_request_id(status, outer_id): + body = { + "code": "Throttling", + "message": "slow down", + "request_id": outer_id, + "output": {"request_id": "inner-id"}, + } + error = _stream_error(status, json.dumps(body)) + assert isinstance(error, RateLimitError) + assert error.status_code == (status or 200) + assert error.request_id == outer_id + assert error.body == body + assert error.headers == {} diff --git a/tests/v2/test_tingwu.py b/tests/v2/test_tingwu.py index d0853129..8d6c2e14 100644 --- a/tests/v2/test_tingwu.py +++ b/tests/v2/test_tingwu.py @@ -57,7 +57,16 @@ def send(self, payload, opcode=1): def recv(self): if not self.inbox: return None - return self.inbox.pop(0) + raw = self.inbox.pop(0) + if isinstance(raw, str): + event = json.loads(raw) + header = event.get("header") + if isinstance(header, dict) and header.get("task_id") == "t": + header["task_id"] = json.loads(self.sent[0])["header"][ + "task_id" + ] + return json.dumps(event) + return raw def close(self): self.closed = True diff --git a/tests/v2/test_transport.py b/tests/v2/test_transport.py index 88eff8bf..faf964f1 100644 --- a/tests/v2/test_transport.py +++ b/tests/v2/test_transport.py @@ -12,9 +12,10 @@ from dashscope.v2.errors import ( APIConnectionError, + APIStatusError, APITimeoutError, BadRequestError, - RateLimitError, + response_error, ) from dashscope.v2.transport import AsyncTransport, SyncTransport from dashscope.v2.transport import http as transport_http @@ -159,38 +160,6 @@ def test_open_stream_transport_error_mapped(no_sleep): assert rec.calls == 1 -def test_open_stream_retryable_status_not_retried(no_sleep): - # Contract relied on by examples/04_real_world_scenarios/ - # production_ready_rag_harness.py: a retryable status on a streaming - # call raises after a single attempt -- the application, not the - # SDK, owns stream backoff and failover. - rec = _Recorder() - responses = [(429, {"retry-after": "3"}), (200, {})] - t = _make_sync(rec, responses, max_retries=3) - with pytest.raises(RateLimitError): - t.open_stream("POST", "/x", json={"a": 1}) - assert rec.calls == 1 - assert no_sleep == [] - - -@pytest.mark.asyncio -async def test_open_stream_retryable_status_not_retried_async(no_sleep): - rec = _Recorder() - responses = [(429, {"retry-after": "3"}), (200, {})] - mock = httpx.MockTransport(rec.handler(responses)) - http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) - t = AsyncTransport( - base_url=BASE_URL, - http_client=http_client, - max_retries=3, - ) - with pytest.raises(RateLimitError): - await t.open_stream("POST", "/x", json={"a": 1}) - assert rec.calls == 1 - assert no_sleep == [] - await t.aclose() - - _SSE_ERROR_BODY = ( "event:error\n" 'data:{"code":"InvalidParameter","message":"bad combo",' @@ -583,3 +552,216 @@ def test_backoff_delay_bounds(): assert d10 <= 8.0 * 1.25 assert backoff_delay(0, {"retry-after": "2"}) == 2.0 assert backoff_delay(0, {"retry-after": "999"}) == 8.0 + + +@pytest.mark.parametrize("method", ["request", "open_stream"]) +@pytest.mark.parametrize("lowercase", [False, True]) +def test_sync_call_auth_wins_case_insensitively(method, lowercase): + rec = _Recorder() + transport = _make_sync(rec, []) + with transport.http_client as http_client: + http_client.headers.update( + { + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) + headers = { + "Authorization": "Bearer header-key", + "X-DashScope-WorkSpace": "header-ws", + } + if lowercase: + headers = {key.lower(): value for key, value in headers.items()} + original = dict(headers) + call = getattr(transport, method) + call( + "GET", + "/x", + headers=headers, + api_key="call-key", + workspace="call-ws", + ).close() + call("GET", "/x").close() + assert headers == original + first, second = rec.requests + assert first.headers.get_list("authorization") == ["Bearer call-key"] + assert first.headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert second.headers.get_list("authorization") == ["Bearer pool-key"] + assert second.headers.get_list("x-dashscope-workspace") == ["pool-ws"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("method", ["request", "open_stream"]) +@pytest.mark.parametrize("lowercase", [False, True]) +async def test_async_call_auth_wins_case_insensitively(method, lowercase): + rec = _Recorder() + async with httpx.AsyncClient( + transport=httpx.MockTransport(rec.handler([])), + base_url=BASE_URL, + headers={ + "Authorization": "Bearer pool-key", + "X-DashScope-WorkSpace": "pool-ws", + }, + ) as http_client: + transport = AsyncTransport(base_url=BASE_URL, http_client=http_client) + headers = { + "Authorization": "Bearer header-key", + "X-DashScope-WorkSpace": "header-ws", + } + if lowercase: + headers = {key.lower(): value for key, value in headers.items()} + original = dict(headers) + call = getattr(transport, method) + response = await call( + "GET", + "/x", + headers=headers, + api_key="call-key", + workspace="call-ws", + ) + await response.aclose() + response = await call("GET", "/x") + await response.aclose() + assert headers == original + first, second = rec.requests + assert first.headers.get_list("authorization") == ["Bearer call-key"] + assert first.headers.get_list("x-dashscope-workspace") == ["call-ws"] + assert second.headers.get_list("authorization") == ["Bearer pool-key"] + assert second.headers.get_list("x-dashscope-workspace") == ["pool-ws"] + + +class _TrackedSyncStream(httpx.SyncByteStream): + def __init__(self, read_error): + self.read_error = read_error + self.closed = False + + def __iter__(self): + yield b"gateway " + if self.read_error is not None: + raise self.read_error("connection lost") + yield b"rejected" + + def close(self): + self.closed = True + + +class _TrackedAsyncStream(httpx.AsyncByteStream): + def __init__(self, read_error): + self.read_error = read_error + self.closed = False + + async def __aiter__(self): + yield b"gateway " + if self.read_error is not None: + raise self.read_error("connection lost") + yield b"rejected" + + async def aclose(self): + self.closed = True + + +@pytest.mark.parametrize("status", [199, 302, 400, 500]) +@pytest.mark.parametrize( + "read_error", + [None, httpx.ReadError, httpx.ReadTimeout], +) +def test_sync_open_stream_status_and_response_ownership(status, read_error): + wire = _TrackedSyncStream(read_error) + response = httpx.Response( + status, + stream=wire, + headers={"x-request-id": "header-id"}, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + with httpx.Client( + transport=httpx.MockTransport(handler), + base_url=BASE_URL, + ) as http_client: + transport = SyncTransport(base_url=BASE_URL, http_client=http_client) + if status >= 400: + with pytest.raises(read_error or APIStatusError) as caught: + transport.open_stream("GET", "/x") + if read_error is None: + assert caught.value.status_code == status + assert caught.value.request_id == "header-id" + assert caught.value.message == "gateway rejected" + else: + opened = transport.open_stream("GET", "/x") + assert opened is response + assert not wire.closed and not response.is_stream_consumed + try: + if read_error is None: + assert opened.read() == b"gateway rejected" + else: + with pytest.raises(read_error): + opened.read() + finally: + opened.close() + assert wire.closed and response.is_closed + assert len(requests) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", [199, 302, 400, 500]) +@pytest.mark.parametrize( + "read_error", + [None, httpx.ReadError, httpx.ReadTimeout], +) +async def test_async_open_stream_status_and_response_ownership( + status, + read_error, +): + wire = _TrackedAsyncStream(read_error) + response = httpx.Response( + status, + stream=wire, + headers={"x-request-id": "header-id"}, + ) + requests = [] + + def handler(request): + requests.append(request) + return response + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handler), + base_url=BASE_URL, + ) as http_client: + transport = AsyncTransport(base_url=BASE_URL, http_client=http_client) + if status >= 400: + with pytest.raises(read_error or APIStatusError) as caught: + await transport.open_stream("GET", "/x") + if read_error is None: + assert caught.value.status_code == status + assert caught.value.request_id == "header-id" + assert caught.value.message == "gateway rejected" + else: + opened = await transport.open_stream("GET", "/x") + assert opened is response + assert not wire.closed and not response.is_stream_consumed + try: + if read_error is None: + assert await opened.aread() == b"gateway rejected" + else: + with pytest.raises(read_error): + await opened.aread() + finally: + await opened.aclose() + assert wire.closed and response.is_closed + assert len(requests) == 1 + + +@pytest.mark.parametrize("status", [199, 200, 204, 299, 302, 400, 500]) +def test_response_error_preserves_shared_http_status_contract(status): + response = httpx.Response(status, json={}) + error = response_error(response) + if status < 400: + assert error is None + else: + assert isinstance(error, APIStatusError) + assert error.status_code == status diff --git a/tests/v2/test_ws_connection.py b/tests/v2/test_ws_connection.py index 9a6c3b6f..5716cb4c 100644 --- a/tests/v2/test_ws_connection.py +++ b/tests/v2/test_ws_connection.py @@ -6,11 +6,12 @@ import json import queue +import threading import pytest from dashscope.v2.errors import APIConnectionError, DashScopeError -from dashscope.v2.transport.ws import WsEventError +from dashscope.v2.transport.ws import WsEventError, WsSession from dashscope.v2.transport.ws_connection import WsConnection URL = "wss://ws.test.local/api-ws/v1/inference" @@ -316,3 +317,601 @@ def test_close_broadcasts_and_closes_socket(): with pytest.raises(APIConnectionError, match="connection closed"): task.recv() conn.close() # idempotent + + +@pytest.fixture +def bounded_connection(): + """Always stop and join readers, including when a contract assertion fails.""" + connections = [] + + def make(ws=None, timeout=0.5): + ws = QueueWebSocket() if ws is None else ws + conn = WsConnection( + url=URL, + connector=lambda url, headers: ws, + timeout=timeout, + ) + connections.append(conn) + return conn + + yield make + for conn in connections: + conn.close() + conn._reader.join(timeout=1) + assert not conn._reader.is_alive(), "websocket reader leaked" + + +@pytest.mark.parametrize("transport", [WsSession, WsConnection]) +def test_initial_timeout_setup_failure_closes_socket(transport): + class BrokenTimeoutSocket(QueueWebSocket): + def settimeout(self, seconds): + raise OSError("timeout setup failed") + + ws = BrokenTimeoutSocket() + try: + with pytest.raises(OSError, match="timeout setup failed"): + transport(url=URL, connector=lambda url, headers: ws, timeout=0.5) + assert ws.closed + finally: + ws.close() + + +@pytest.mark.parametrize( + "error_type", + [OSError, KeyboardInterrupt, SystemExit], +) +def test_shared_blocking_mode_setup_failure_closes_socket(error_type): + error = error_type("blocking mode failed") + + class BrokenBlockingSocket(QueueWebSocket): + def settimeout(self, seconds): + super().settimeout(seconds) + if seconds is None: + raise error + + ws = BrokenBlockingSocket() + try: + with pytest.raises(error_type) as caught: + WsConnection( + url=URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + assert caught.value is error + assert ( + ws.closed + ), "post-handshake initialization must release the socket" + finally: + ws.close() + + +@pytest.mark.parametrize("transport", [WsSession, WsConnection]) +def test_connector_failure_creates_no_reader(transport): + before = set(threading.enumerate()) + + def connector(url, headers): + raise OSError("handshake failed") + + with pytest.raises(OSError, match="handshake failed"): + transport(url=URL, connector=connector, timeout=0.5) + assert not [ + thread + for thread in threading.enumerate() + if thread not in before and thread.name == "dashscope-ws-reader" + ] + + +@pytest.mark.parametrize("action", ["run", "binary", "continue", "finish"]) +def test_shared_send_failure_closes_and_wakes_other_tasks( + bounded_connection, + action, +): + class BrokenSendSocket(QueueWebSocket): + fail_send = False + + def send(self, payload, opcode=1): + if self.fail_send: + raise OSError("send failed") + super().send(payload, opcode=opcode) + + ws = BrokenSendSocket() + conn = bounded_connection(ws) + task, other = conn.open_task(), conn.open_task() + _started(conn, other) + if action != "run": + _started(conn, task) + ws.fail_send = True + with pytest.raises(OSError, match="send failed"): + if action == "run": + _started(conn, task) + elif action == "binary": + task.send_binary(b"audio") + elif action == "continue": + task.continue_task({"context": "next"}) + else: + task.finish_task() + assert conn.closed and ws.closed + with pytest.raises(APIConnectionError): + other.recv() + with pytest.raises(RuntimeError, match="closed"): + conn.open_task() + task.close() + other.close() + + +@pytest.mark.parametrize("raw", [None, "", OSError("receive failed")]) +def test_shared_premature_eof_and_recv_failure_stop_reader( + bounded_connection, + raw, +): + conn = bounded_connection() + tasks = [conn.open_task(), conn.open_task()] + for task in tasks: + _started(conn, task) + conn._ws.inbox.put(raw) + for task in tasks: + with pytest.raises( + OSError if isinstance(raw, OSError) else APIConnectionError, + ): + task.recv() + assert conn.closed and conn._ws.closed + + +@pytest.mark.parametrize( + "raw, error", + [ + pytest.param("{", json.JSONDecodeError, id="invalid-json"), + pytest.param("[]", WsEventError, id="non-object"), + pytest.param("null", WsEventError, id="null-event"), + pytest.param("{}", WsEventError, id="missing-header"), + pytest.param('{"header": []}', WsEventError, id="invalid-header"), + pytest.param('{"header": null}', WsEventError, id="null-header"), + pytest.param( + '{"header": {"event": "result-generated"}}', + WsEventError, + id="missing-task-id", + ), + *[ + pytest.param( + _event("result-generated", task_id), + WsEventError, + id=name, + ) + for task_id, name in [ + ([], "list-task-id"), + ({}, "object-task-id"), + (None, "null-task-id"), + (1, "numeric-task-id"), + (True, "boolean-task-id"), + ("", "empty-task-id"), + ] + ], + ], +) +def test_shared_invalid_control_frame_fails_all_tasks( + bounded_connection, + raw, + error, +): + conn = bounded_connection() + tasks = [conn.open_task(), conn.open_task()] + for task in tasks: + _started(conn, task) + conn._ws.inbox.put(raw) + for task in tasks: + for _ in range(2): + with pytest.raises(error): + task.recv() + assert conn.closed and conn._ws.closed + + +@pytest.mark.parametrize("terminal", ["task-finished", "task-failed", "close"]) +def test_both_binary_owners_release_for_next_task( + bounded_connection, + terminal, +): + conn = bounded_connection() + first, second = conn.open_task(), conn.open_task() + _started(conn, first) + _started(conn, second) + first.send_binary(b"first") + first.claim_binary_downlink() + if terminal == "close": + first.close() + else: + conn._ws.inbox.put(_event(terminal, first.task_id)) + if terminal == "task-failed": + with pytest.raises(DashScopeError): + first.recv() + else: + first.recv_event() + second.send_binary(b"second") + second.claim_binary_downlink() + conn._ws.inbox.put(b"second-output") + assert second.recv() == b"second-output" + assert not conn.closed + + +@pytest.mark.parametrize("terminal", ["task-finished", "close"]) +def test_inactive_task_cannot_reclaim_binary_downlink( + bounded_connection, + terminal, +): + conn = bounded_connection() + task, next_task = conn.open_task(), conn.open_task() + _started(conn, task) + _started(conn, next_task) + task.claim_binary_downlink() + if terminal == "close": + task.close() + else: + conn._ws.inbox.put(_event(terminal, task.task_id)) + task.recv_event() + with pytest.raises(RuntimeError): + task.claim_binary_downlink() + next_task.claim_binary_downlink() + conn._ws.inbox.put(b"next-output") + assert next_task.recv() == b"next-output" + + +@pytest.mark.parametrize("failure", ["close", "recv"]) +def test_connection_shutdown_releases_binary_owners( + bounded_connection, + failure, +): + conn = bounded_connection() + task = conn.open_task() + _started(conn, task) + task.send_binary(b"audio") + task.claim_binary_downlink() + if failure == "close": + conn.close() + else: + conn._ws.inbox.put(OSError("receive failed")) + with pytest.raises(APIConnectionError if failure == "close" else OSError): + task.recv() + assert conn._binary_uplink_owner is None + assert conn._binary_downlink_owner is None + with pytest.raises(RuntimeError, match="closed"): + task.claim_binary_downlink() + assert conn._binary_downlink_owner is None + + +@pytest.mark.parametrize("terminal", ["task-finished", "task-failed"]) +@pytest.mark.parametrize("claim", [False, True]) +def test_reader_releases_terminal_owners_before_consumer_finishes( + bounded_connection, + terminal, + claim, +): + conn = bounded_connection() + first, second = conn.open_task(), conn.open_task() + _started(conn, first) + _started(conn, second) + first.send_binary(b"first-input") + first.claim_binary_downlink() + conn._ws.inbox.put(_event("result-generated", first.task_id, {"n": 1})) + conn._ws.inbox.put(b"first-output") + conn._ws.inbox.put(_event(terminal, first.task_id)) + conn._ws.inbox.put( + _event("task-started", first.task_id), + ) # Late duplicate. + conn._ws.inbox.put(_event("result-generated", second.task_id)) + second.recv_event() # The reader has handled first's terminal event. + assert not first.finished # The consumer must still drain its frames. + assert conn._binary_uplink_owner is None + assert conn._binary_downlink_owner is None + with pytest.raises(RuntimeError, match="not active"): + first.claim_binary_downlink() + with pytest.raises(RuntimeError, match="not active"): + first.send_binary(b"too late") + second.send_binary(b"second-input") + if claim: + second.claim_binary_downlink() + conn._ws.inbox.put(b"second-output") + assert second.recv() == b"second-output" + assert first.recv_event()["payload"] == {"n": 1} + assert first.recv() == b"first-output" + if terminal == "task-failed": + with pytest.raises(DashScopeError): + first.recv() + else: + assert first.recv_event()["header"]["event"] == terminal + assert first.finished and first.recv() is None + # Consuming the old terminal event must not affect the new owners. + assert conn._binary_uplink_owner is second + assert conn._binary_downlink_owner is second + assert not conn.closed + + +@pytest.mark.parametrize("direction", ["uplink", "downlink"]) +def test_binary_owner_must_be_attached_to_this_connection( + bounded_connection, + direction, +): + conn, other = bounded_connection(), bounded_connection() + task, foreign = conn.open_task(), other.open_task() + foreign._task_id = ( + task.task_id + ) # Matching ids alone do not imply attachment. + with pytest.raises(RuntimeError, match="not active"): + getattr(conn, "_claim_binary_" + direction)(foreign) + assert getattr(conn, "_binary_" + direction + "_owner") is None + task.claim_binary_downlink() + + +@pytest.mark.parametrize("close_target", ["task", "connection"]) +def test_close_wakes_already_blocked_receiver( + bounded_connection, + close_target, +): + entered = threading.Event() + + class ObservedQueue(queue.Queue): + def get(self, *args, **kwargs): + entered.set() + return super().get(*args, **kwargs) + + conn = bounded_connection(timeout=None) + task = conn.open_task() + _started(conn, task) + task._queue = ObservedQueue() + errors = [] + + def receive(): + try: + task.recv() + except BaseException as exc: + errors.append(exc) + + receiver = threading.Thread( + target=receive, + name="test-ws-receiver", + daemon=True, + ) + receiver.start() + try: + assert entered.wait(timeout=1) + (task if close_target == "task" else conn).close() + receiver.join(timeout=1) + assert not receiver.is_alive(), "close did not wake the receiver" + expected = ( + RuntimeError if close_target == "task" else APIConnectionError + ) + assert len(errors) == 1 and isinstance(errors[0], expected), errors + finally: + # Test-only fallback also bounds cleanup if production close is broken. + task._queue.put(("error", RuntimeError("test cleanup"))) + receiver.join(timeout=1) + task.close() + assert not receiver.is_alive() + + +def test_close_uses_bounded_join_if_socket_does_not_wake_reader( + bounded_connection, + monkeypatch, +): + entered, release = threading.Event(), threading.Event() + + class SlowClosingSocket(QueueWebSocket): + def recv(self): + entered.set() + assert release.wait(timeout=5) + return None + + conn = bounded_connection(SlowClosingSocket()) + task = conn.open_task() + original_join = conn._reader.join + timeouts = [] + + def observe_join(timeout=None): + timeouts.append(timeout) + assert timeout is not None and 0 < timeout <= 2 + original_join(timeout=timeout) + + monkeypatch.setattr(conn._reader, "join", observe_join) + try: + assert entered.wait(timeout=1) + conn.close() + assert timeouts == [2] + assert ( + conn._reader.is_alive() + ) # close() returned without an unbounded wait. + with pytest.raises(APIConnectionError): + task.recv() + finally: + release.set() + original_join(timeout=1) + assert not conn._reader.is_alive() + + +@pytest.mark.parametrize( + "shutdown", + ["task", "connection", "recv", "invalid-json"], +) +def test_error_is_persistent_but_preserves_earlier_frames( + bounded_connection, + shutdown, +): + conn = bounded_connection() + task, barrier = conn.open_task(), conn.open_task() + _started(conn, task) + conn._ws.inbox.put(_event("result-generated", task.task_id, {"n": 1})) + conn._ws.inbox.put(b"queued-audio") + conn._ws.inbox.put(_event("result-generated", barrier.task_id)) + barrier.recv_event() # Earlier frames have reached the task's queue. + if shutdown in ("task", "connection"): + (task if shutdown == "task" else conn).close() + expected = RuntimeError if shutdown == "task" else APIConnectionError + else: + conn._ws.inbox.put( + OSError("receive failed") if shutdown == "recv" else "{", + ) + conn._reader.join(timeout=1) + assert not conn._reader.is_alive() + expected = OSError if shutdown == "recv" else json.JSONDecodeError + assert task.recv_event()["payload"] == {"n": 1} + assert task.recv() == b"queued-audio" + with pytest.raises(expected) as first: + task.recv() + task.close() # Cleanup must not overwrite an earlier connection error. + with pytest.raises(expected) as again: + task.recv() + assert again.value is first.value + + +def test_task_handshake_timeout_detaches_without_killing_connection( + bounded_connection, +): + conn = bounded_connection() + task = conn.open_task(timeout=0.02) + with pytest.raises(TimeoutError): + task.run_task( + task_group="audio", + task="asr", + function="recognition", + model="test", + ) + assert task.task_id not in conn._tasks + assert not conn.closed + next_task = conn.open_task() + _started(conn, next_task) + next_task.send_binary(b"still usable") + + +def test_task_timeout_is_isolated_and_explicit_close_releases_owners( + bounded_connection, +): + conn = bounded_connection() + first, second = conn.open_task(timeout=0.02), conn.open_task() + _started(conn, first) + _started(conn, second) + first.send_binary(b"first") + first.claim_binary_downlink() + with pytest.raises(TimeoutError): + first.recv() + first.close() + second.send_binary(b"second") + second.claim_binary_downlink() + conn._ws.inbox.put(_event("task-finished", second.task_id)) + assert second.recv_event()["header"]["event"] == "task-finished" + assert not conn.closed + + +@pytest.mark.parametrize("claim", [False, True]) +def test_binary_immediately_after_started_is_not_dropped( + bounded_connection, + claim, +): + """The reader may read audio before run_task consumes task-started.""" + routed = threading.Event() + + class FastReplySocket(QueueWebSocket): + def send(self, payload, opcode=1): + super().send(payload, opcode=opcode) + task_id = json.loads(payload)["header"]["task_id"] + self.inbox.put(_event("task-started", task_id)) + self.inbox.put(b"first-output") + self.inbox.put(_event("result-generated", "reader-barrier")) + assert routed.wait(timeout=1) + + conn = bounded_connection(FastReplySocket()) + task = conn.open_task() + if claim: + task.claim_binary_downlink() # TTS claims before run_task(). + original_route = conn._route_event + + def observe_route(event): + original_route(event) + if event["header"].get("task_id") == "reader-barrier": + routed.set() + + conn._route_event = observe_route + task.run_task( + task_group="audio", + task="tts", + function="SpeechSynthesizer", + model="test", + ) + assert task.recv() == b"first-output" + + +@pytest.mark.parametrize( + "error_type", + [RuntimeError, KeyboardInterrupt, SystemExit], +) +@pytest.mark.parametrize("after_start", [False, True]) +def test_reader_start_failure_closes_connected_socket( + monkeypatch, + error_type, + after_start, +): + ws = QueueWebSocket() + error = error_type("cannot start reader") + original_start = threading.Thread.start + readers = [] + + def fail_start(self): + readers.append(self) + if after_start: + original_start(self) + raise error + + monkeypatch.setattr(threading.Thread, "start", fail_start) + try: + with pytest.raises(error_type) as caught: + WsConnection( + url=URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + assert caught.value is error + assert ws.closed + assert all(not reader.is_alive() for reader in readers) + finally: + ws.close() + for reader in readers: + if reader.is_alive(): + reader.join(timeout=1) + + +def test_independent_session_close_wakes_blocked_receiver(): + entered = threading.Event() + + class ObservedSocket(QueueWebSocket): + def recv(self): + entered.set() + return super().recv() + + ws = ObservedSocket() + session = WsSession( + url=URL, + connector=lambda url, headers: ws, + timeout=0.5, + ) + errors = [] + + def receive(): + try: + session.recv() + except BaseException as exc: + errors.append(exc) + + receiver = threading.Thread( + target=receive, + name="test-session-receiver", + daemon=True, + ) + receiver.start() + try: + assert entered.wait(timeout=1) + session.close() + receiver.join(timeout=1) + assert not receiver.is_alive() + assert len(errors) == 1 and isinstance(errors[0], APIConnectionError) + assert ws.closed + finally: + session.close() + ws.close() + receiver.join(timeout=1) + assert not receiver.is_alive() From c2e1745e612dd19cb4412aff5d869a935e3a62bc Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 20 Oct 2026 16:07:55 +0800 Subject: [PATCH 8/9] feat(v2): mark the 2.0 audio API as preview - re-apply cbed9ae's preview marking, which the 2.0.1 audio alignment (#214) dropped: client.audio mounts lazily and emits a one-time FutureWarning on first access, Audio/AsyncAudio docstrings carry the preview note, and the examples READMEs flag the audio row - the audio surface is still iterating, so the warning stays until it stabilizes --- dashscope/v2/client.py | 43 ++++++++++++++++++++++-- dashscope/v2/resources/audio/__init__.py | 9 ++++- examples/README.md | 2 +- examples/README_zh.md | 2 +- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/dashscope/v2/client.py b/dashscope/v2/client.py index 55069f82..f2c91dc6 100644 --- a/dashscope/v2/client.py +++ b/dashscope/v2/client.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any, Dict, Mapping, Optional, Union +import warnings import httpx @@ -116,7 +117,7 @@ def __init__( self.models = Models(self) self.images = Images(self) self.videos = Videos(self) - self.audio = Audio(self) + self._audio = None self.fine_tunes = FineTunes(self) self.deployments = Deployments(self) self.rl = Rl(self) @@ -124,6 +125,25 @@ def __init__( self.tingwu = TingWu(self) self._agentstudio = None + @property + def audio(self) -> "Audio": + """Audio capabilities (lazily mounted). + + Preview: the 2.0 audio API surface is not final and may change + in a future release. + """ + if self._audio is None: + warnings.warn( + "dashscope 2.0 audio API is a preview and may change " + "in a future release.", + FutureWarning, + stacklevel=2, + ) + self._audio = Audio(self) + audio = self._audio + assert audio is not None + return audio + @property def reinforcement_learning(self) -> "Rl": """Long-form alias for ``client.rl``.""" @@ -338,7 +358,7 @@ def __init__( self.models = AsyncModels(self) self.images = AsyncImages(self) self.videos = AsyncVideos(self) - self.audio = AsyncAudio(self) + self._audio = None self.fine_tunes = AsyncFineTunes(self) self.deployments = AsyncDeployments(self) self.rl = AsyncRl(self) @@ -346,6 +366,25 @@ def __init__( self.tingwu = AsyncTingWu(self) self._agentstudio = None + @property + def audio(self) -> "AsyncAudio": + """Audio capabilities (lazily mounted). + + Preview: the 2.0 audio API surface is not final and may change + in a future release. + """ + if self._audio is None: + warnings.warn( + "dashscope 2.0 audio API is a preview and may change " + "in a future release.", + FutureWarning, + stacklevel=2, + ) + self._audio = AsyncAudio(self) + audio = self._audio + assert audio is not None + return audio + @property def reinforcement_learning(self) -> "AsyncRl": """Long-form alias for ``client.rl``.""" diff --git a/dashscope/v2/resources/audio/__init__.py b/dashscope/v2/resources/audio/__init__.py index a399ab09..300bff25 100644 --- a/dashscope/v2/resources/audio/__init__.py +++ b/dashscope/v2/resources/audio/__init__.py @@ -70,7 +70,11 @@ class Audio: - """Sync audio surface.""" + """Sync audio surface. + + Preview: the 2.0 audio API surface is not final and may change in + a future release. + """ def __init__(self, client) -> None: self.asr = Asr(client) @@ -88,6 +92,9 @@ class AsyncAudio: Only batch transcription is exposed here; the ws realtime resources (``asr.recognize``, ``tts``, ``realtime``) are sync-only by design (design doc M3 3.5). + + Preview: the 2.0 audio API surface is not final and may change in + a future release. """ def __init__(self, client) -> None: diff --git a/examples/README.md b/examples/README.md index fbb4584e..751647f3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,7 +93,7 @@ Uniform style: `client = dashscope.Client()` (reads the key from the `DASHSCOPE_ | `text_generation/chat.py` | Text generation: one-line `dashscope.chat()`, non-streaming, streaming (text_stream) | | `text_generation/tokenization.py` | Token counting | | `multimodal_vision/multimodal_chat.py` | Multimodal chat (qwen-vl image understanding) | -| `audio/` | Speech samples: ASR/TTS/realtime/voice CLIs (see `audio/README.md`; `direct_call/` holds the no-`with` variants) | +| `audio/` | Speech samples: ASR/TTS/realtime/voice CLIs (see `audio/README.md`; `direct_call/` holds the no-`with` variants); 2.0 audio API is a preview and may change | | `audio/tingwu.py` | TingWu offline tasks and realtime sessions | | `image_video_synthesis/images.py` | Image generation: one-call generate-and-wait + the manual jobs flow | | `image_video_synthesis/videos.py` | Video generation: one-call generate-and-wait + the manual jobs flow | diff --git a/examples/README_zh.md b/examples/README_zh.md index 73db1211..37d00573 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -93,7 +93,7 @@ examples/ | `text_generation/chat.py` | 文本生成:一行 `dashscope.chat()`、非流式、流式(text_stream) | | `text_generation/tokenization.py` | token 计数 | | `multimodal_vision/multimodal_chat.py` | 多模态对话(qwen-vl 图像理解) | -| `audio/` | 语音示例:ASR/TTS/realtime/音色 CLI(见 `audio/README.md`;`direct_call/` 为不使用 `with` 的副本) | +| `audio/` | 语音示例:ASR/TTS/realtime/音色 CLI(见 `audio/README.md`;`direct_call/` 为不使用 `with` 的副本);2.0 audio API 为预览,后续可能变更 | | `audio/tingwu.py` | 听悟离线任务与实时会话 | | `image_video_synthesis/images.py` | 图像生成:一键生成并等待 + 手动 jobs 流程 | | `image_video_synthesis/videos.py` | 视频生成:一键生成并等待 + 手动 jobs 流程 | From 1b2e413e5016a80e75947b0c8bfa09af83b7264b Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Tue, 20 Oct 2026 16:52:05 +0800 Subject: [PATCH 9/9] docs(readme): update documentation to reflect new features and usage for version 2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将SDK描述更新为官方客户端,涵盖更多功能如微调、Agentic RL、应用调用等 - 添加版本2.0新特性的详细对比表格,展示1.x到2.0的变化 - 更新安装命令为 pip install -U dashscope 并要求Python 3.9+ - 添加快速开始示例,包括一行调用、客户端使用、流式处理和异步操作 - 详细介绍核心概念如客户端、协议、类型化响应、流式处理和错误处理 - 更新认证和配置部分,说明分层配置机制和区域端点设置 - 添加能力矩阵和实时WebSocket连接说明 - 更新AI助手介绍和Shell补全说明 - 添加示例目录结构和迁移指南 - 完善中英文文档的同步更新 --- README.md | 463 +++++++++++++++++++++++++++++++++++++-------------- README_zh.md | 454 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 677 insertions(+), 240 deletions(-) diff --git a/README.md b/README.md index d5f07ea7..54194a42 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,31 @@ > **English** | [中文](README_zh.md) -The DashScope Python SDK provides a comprehensive interface to [Alibaba Cloud Model Studio (Bailian)](https://www.alibabacloud.com/help/en/model-studio/) APIs, covering text generation, multi-modal understanding, embeddings, reranking, image/video generation, speech synthesis & recognition, and more. +The DashScope Python SDK is the official client for [Alibaba Cloud Model Studio (Bailian)](https://www.alibabacloud.com/help/en/model-studio/), covering text generation, multi-modal understanding, embeddings, reranking, image/video generation, speech synthesis & recognition, fine-tuning, agentic RL, applications and more. -## What is New +**Version 2.0** introduces a resource-style client — `dashscope.Client()` — modeled on the ergonomics of the OpenAI SDK: one client holds a shared connection pool, every capability hangs off it as a typed resource (`client.chat`, `client.images`, `client.audio`, …), responses are typed objects instead of raw dicts, and failures raise exceptions by default. The 1.x module-level API (`dashscope.Generation`, …) is unchanged and still fully supported, so 2.0 is purely additive. -**v1.27.0 ships an interactive AI assistant — [DashScope SDK Expert](#ai-assistant-dashscope-sdk-expert).** Run `dashscope` with no arguments (or ask directly with `dashscope expert "how do I stream Generation output"`) to get SDK/API answers, runnable examples, CLI usage, and error diagnosis right in your terminal. Guidance is drawn from per-domain quick-reference skills (text, multimodal, speech, retrieval, fine-tuning, agent, cli) built on the SDK's public interfaces — parameters, outputs, and error codes — so you can ask instead of reading the docs. Type `/help` inside the assistant to view available commands. +## What's new in 2.0 + +| | 1.x | 2.0 | +|---|---|---| +| Entry point | Module-level static classes: `Generation.call(...)` | A resource-style client: `client.chat.completions.create(...)` | +| One-liner | — | `dashscope.chat("qwen-plus", "Tell me a joke")` | +| Response | Raw dict-like; check `status_code == HTTPStatus.OK` | Typed object; read `resp.output_text`, `resp.usage.total_tokens` | +| Errors | Inspect `resp.code` / `resp.message` by hand | Typed `DashScopeError` exceptions raised by default | +| Streaming | Generator of raw frames | `ChatStream` with `.text_stream`, `.reasoning_stream`, `.get_final_message()` | +| Protocols | DashScope native + a separate compatible-mode entry | One client speaks `native`, `openai`, or `anthropic` via `protocol=` | +| Async | Separate `Aio*` classes | `dashscope.AsyncClient()` mirrors the whole sync surface | +| Connections | A fresh session per call | One keep-alive pool per client, shared across resources | +| Config | Module globals (`dashscope.api_key`, `set_region`) | Layered: args → `dashscope.settings` → env → `~/.dashscope/config.toml` | ## Installation -To install the DashScope Python SDK, simply run: + ```shell -pip install dashscope +pip install -U dashscope ``` -The base install covers SDK API calls and the `dashscope` CLI command. -Optional feature groups are available as extras: +The base install covers SDK API calls and the `dashscope` CLI command. Python 3.9+ is required. Optional feature groups are available as extras: | Extra | Provides | Install | |-------|----------|---------| @@ -23,181 +34,363 @@ Optional feature groups are available as extras: | `rl` | Agentic RL fine-tuning | `pip install "dashscope[rl]"` | | `tokenizer` | Local tokenizer without downloads | `pip install "dashscope[tokenizer]"` | -If you clone the code from github, you can install from source by running: +To install from a source checkout: + ```shell pip install -e . ``` +## Quick start + +Set your API key ([how to get one](https://help.aliyun.com/en/model-studio/get-api-key)): + +```shell +export DASHSCOPE_API_KEY="sk-xxx" +``` + +**One line** — the fastest way to a completion: + +```python +import dashscope + +completion = dashscope.chat("qwen-plus", "Tell me a joke") +print(completion.output_text) +``` -## Quick Start +**Client** — the recommended form for anything real. One client, reused across calls: ```python -from http import HTTPStatus -from dashscope import Generation +import dashscope + +client = dashscope.Client() # reads DASHSCOPE_API_KEY -responses = Generation.call( - model="qwen3.7-plus", +resp = client.chat.completions.create( + model="qwen-plus", messages=[ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Who are you?"}, ], - result_format="message", ) - -if responses.status_code == HTTPStatus.OK: - print(responses.output.choices[0].message.content) -else: - print(f"Error: {responses.code} - {responses.message}") +print(resp.output_text) +print(resp.usage.total_tokens, "tokens |", resp.request_id) ``` -## API Key Authentication +**Streaming** — `stream=True` returns a `ChatStream`: + +```python +stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Write a haiku about the sea"}], + stream=True, +) +for text in stream.text_stream: + print(text, end="", flush=True) -The SDK uses API key for authentication. To obtain an API Key, see [How to get an API Key](https://help.aliyun.com/en/model-studio/get-api-key). Please refer to [official documentation for alibabacloud china](https://www.alibabacloud.com/help/en/model-studio/) and [official documentation for alibabacloud international](https://www.alibabacloud.com/help/en/model-studio/) regarding how to obtain your api-key. +# Or collapse the whole stream into one merged ChatCompletion (with usage): +# final = stream.get_final_message() +``` -### Using the API Key +**Async** — `AsyncClient` mirrors the sync surface: -1. Set the API key via code ```python +import asyncio import dashscope -dashscope.api_key = 'YOUR-DASHSCOPE-API-KEY' -# Or specify the API key file path via code -# dashscope.api_key_file_path='~/.dashscope/api_key' +async def main(): + client = dashscope.AsyncClient() + resp = await client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + print(resp.output_text) + await client.aclose() +asyncio.run(main()) ``` -2. Set the API key via environment variables +## Core concepts -a. Set the API key directly using the environment variable below +### The client -```shell -export DASHSCOPE_API_KEY='YOUR-DASHSCOPE-API-KEY' +`dashscope.Client()` (and `dashscope.AsyncClient()`) is the single entry point. It owns one keep-alive connection pool shared by every resource call, and exposes capabilities as attributes: + +```python +client = dashscope.Client( + api_key="sk-xxx", # default: DASHSCOPE_API_KEY + workspace="ws-xxx123", # default: DASHSCOPE_WORKSPACE + region="cn-beijing", # default: DASHSCOPE_REGION + protocol="native", # native | openai | anthropic + timeout=300.0, + max_retries=2, +) ``` -b. Specify the API key file path via an environment variable +Use it as a context manager to close the pool automatically, or derive a variant with `with_options()` (unmentioned options are inherited; the connection pool is shared when connection-level options are unchanged): -```shell -export DASHSCOPE_API_KEY_FILE_PATH='~/.dashscope/api_key' +```python +with dashscope.Client() as client: + client.chat.completions.create(model="qwen-plus", messages=["hi"]) + +low_latency = client.with_options(timeout=10.0, max_retries=0) ``` -3. Save the API key to a file +Any call also accepts per-request `api_key=`, `workspace=`, and `timeout=` overrides. + +### Protocols + +One client can speak three wire protocols, selected with `protocol=` (or `DASHSCOPE_PROTOCOL` / `settings.protocol`). The request/response objects stay the same canonical, typed shape regardless of protocol — only the wire format changes: + +| `protocol` | Talks to | Use when | +|------------|----------|----------| +| `native` (default) | DashScope native API (`/api/v1`) | You want the full DashScope feature set | +| `openai` | OpenAI-compatible mode (`/compatible-mode/v1`) | You're porting OpenAI code — no `openai` package needed | +| `anthropic` | Anthropic-compatible mode (`/apps/anthropic`) | You're porting Anthropic Messages code | + ```python -from dashscope import save_api_key +client = dashscope.Client(protocol="openai") +resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], +) +``` + +### Typed responses -save_api_key(api_key='YOUR-DASHSCOPE-API-KEY', - api_key_file_path='api_key_file_location or (None, will save to default location "~/.dashscope/api_key"') +Every response is a typed object. For chat, read `output_text` for the first choice, or dig into `choices`, `usage`, `search_info`, `request_id`, and `model`: +```python +resp = client.chat.completions.create(model="qwen-plus", messages=["hi"]) + +resp.output_text # choices[0].message.content +resp.choices[0].message.content # same, explicit +resp.choices[0].message.reasoning_content # thinking-model reasoning, if any +resp.choices[0].message.tool_calls # tool calls, if any +resp.usage.input_tokens # also .output_tokens / .total_tokens +resp.request_id ``` -## Region and Endpoint Configuration +All responses share a common envelope: `request_id`, `status_code`, `code`, `message`, `is_error`, and raw `headers`. -By default the SDK sends requests to the China (Beijing) public endpoint `dashscope.aliyuncs.com`. If your Model Studio (Bailian) workspace lives in another region, switch the endpoint before making calls. +### Streaming -### Using `set_region` +`stream=True` returns a `ChatStream` (`AsyncChatStream` for `AsyncClient`) with typed helpers: -`dashscope.set_region(region, workspace_id)` points the HTTP, WebSocket and OpenAI-compatible base URLs at the given region in a single call. `workspace_id` is required and is used as the endpoint subdomain. +```python +stream = client.chat.completions.create(model="qwen-plus", messages=["hi"], stream=True) + +for text in stream.text_stream: # content deltas + ... +# stream.reasoning_stream # reasoning deltas (thinking models) +# stream.tool_call_stream # tool-call deltas +# stream.get_final_message() # merged ChatCompletion, incl. usage +``` + +By default each frame is incremental. Pass `incremental_output=False` to receive cumulative snapshots instead (the SDK merges deltas for you). + +> **Note:** the SDK does **not** auto-retry streaming requests. Wrap stream consumption in your own retry/backoff for production — see [`examples/04_real_world_scenarios/production_ready_rag_harness.py`](examples/04_real_world_scenarios/production_ready_rag_harness.py). + +### Errors + +Failures raise a typed `DashScopeError` by default (both non-2xx HTTP statuses and HTTP-200-with-a-business-error land here): ```python -import dashscope +from dashscope.v2 import errors + +try: + client.chat.completions.create(model="qwen-plus", messages=["hi"]) +except errors.RateLimitError as exc: + print("throttled:", exc.code, exc.status_code, exc.request_id) +except errors.ContentFilterError as exc: + print("blocked by moderation:", exc.message) +except errors.DashScopeError as exc: # base class for all SDK errors + print("request failed:", exc.code, exc.message) +``` -# Switch to the Singapore region for workspace "ws-xxx123" -dashscope.set_region(region="ap-southeast-1", workspace_id="ws-xxx123") +The hierarchy includes `APIConnectionError` / `APITimeoutError` (network) and, under `APIStatusError`: `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `RequestTooLargeError`, `RateLimitError`, `InternalServerError`, `ServiceUnavailableError`, `GatewayTimeoutError`, and `ContentFilterError`. -# All subsequent calls use: -# https://ws-xxx123.ap-southeast-1.maas.aliyuncs.com/api/v1 -print(dashscope.base_http_api_url) +Prefer to inspect errors inline instead of catching? Build the client with `raise_on_error=False` — failures come back as a normal response object with `is_error=True`, `code`, `message`, and `status_code`: + +```python +client = dashscope.Client(raise_on_error=False) +resp = client.chat.completions.create(model="qwen-plus", messages=["hi"]) +if resp.is_error: + print(resp.code, resp.message) ``` -Supported regions: +## Authentication -| Region | Location | -|--------|----------| -| `cn-beijing` | China (Beijing) | -| `cn-hongkong` | China (Hong Kong) | -| `ap-southeast-1` | Singapore | -| `ap-northeast-1` | Japan (Tokyo) | -| `eu-central-1` | Germany (Frankfurt) | -| `us-east-1` | US (Virginia) | +The SDK authenticates with an API key. Get one from the [Model Studio console](https://help.aliyun.com/en/model-studio/get-api-key). Provide it in any of these ways (highest priority first): -> **API keys are region-specific.** Each region issues its own API keys (`sk-` prefix) in its Model Studio console, and keys cannot be mixed across regions — using a key from another region fails with `401`. Switch `api_key` together with the region. +1. **Per client** — `dashscope.Client(api_key="sk-xxx")` +2. **Global settings** — `dashscope.settings.api_key = "sk-xxx"` (applies to clients created afterwards) +3. **Environment variable** — `export DASHSCOPE_API_KEY="sk-xxx"` +4. **Config file** — `api_key` in `~/.dashscope/config.toml` +5. **1.x key file** — the file written by `dashscope.save_api_key(...)` (`~/.dashscope/api_key`, or `DASHSCOPE_API_KEY_FILE_PATH`), honored as the lowest tier so existing 1.x setups keep working -Region-specific notes: +Any call also accepts a per-request `api_key=` override. -- WebSocket endpoints (`wss://.../api-ws/v1/inference`) are only served in `cn-beijing` and `ap-southeast-1`. `set_region` still sets `base_websocket_api_url` for every region, but WebSocket-based realtime APIs (realtime speech recognition/synthesis, multimodal dialog, etc.) are not available in the other regions. -- `eu-central-1` / `ap-northeast-1`: the deployment scope (Global, or EU / Japan) is chosen when the workspace is created in the console, not per API call. -- `us-east-1`: model names with the `-us` suffix (e.g. `qwen-plus-us`) restrict inference to the US; names without the suffix default to global inference. -- Batch inference, model fine-tuning and application development are currently only available in `cn-beijing` and `ap-southeast-1`. +> **API keys are region-specific.** Each region issues its own keys; a key from another region fails with `401`. Switch `api_key` together with `region`. -> `set_region` updates process-wide globals, so it is not concurrency-safe when a single process talks to multiple regions at the same time. Call it once at startup, or re-call it before each switch. +## Configuration -### Using environment variables +Configuration resolves in layers — **explicit arguments → `dashscope.settings` → environment variables → `~/.dashscope/config.toml`**. Set it once at startup, per client, or not at all. -You can also select the region without code: +**Global settings facade:** -```shell -export DASHSCOPE_API_REGION='ap-southeast-1' # default: cn-beijing -export DASHSCOPE_WORKSPACE_ID='ws-xxx123' # used to resolve the endpoint subdomain +```python +import dashscope + +dashscope.settings.api_key = "sk-xxx" +dashscope.settings.workspace = "ws-xxx123" +dashscope.settings.region = "ap-southeast-1" +dashscope.settings.protocol = "openai" +dashscope.settings.timeout = 60.0 +dashscope.settings.max_retries = 3 +dashscope.settings.log_level = "INFO" +``` + +**Environment variables:** + +| Variable | Sets | Default | +|----------|------|---------| +| `DASHSCOPE_API_KEY` | API key | — | +| `DASHSCOPE_WORKSPACE` | Workspace ID (endpoint subdomain) | — | +| `DASHSCOPE_REGION` | Region | `cn-beijing` | +| `DASHSCOPE_PROTOCOL` | `native` / `openai` / `anthropic` | `native` | +| `DASHSCOPE_BASE_URL` | Override the endpoint entirely (escape hatch) | — | +| `DASHSCOPE_TIMEOUT` | Request timeout (seconds) | `300` | +| `DASHSCOPE_MAX_RETRIES` | Automatic retries | `2` | +| `DASHSCOPE_LOG` | Log level | `WARNING` | +| `DASHSCOPE_CONFIG` | Path to `config.toml` | `~/.dashscope/config.toml` | + +**Config file** (`~/.dashscope/config.toml`): + +```toml +api_key = "sk-xxx" +workspace = "ws-xxx123" +region = "ap-southeast-1" +protocol = "native" +timeout = 300.0 +max_retries = 2 ``` -When a MaaS region is set via `DASHSCOPE_API_REGION`, the SDK builds the regional endpoints and substitutes `DASHSCOPE_WORKSPACE_ID` into them. You can also override each base URL directly: +### Regions and endpoints -| Environment variable | Overrides | -|----------------------|-----------| -| `DASHSCOPE_HTTP_BASE_URL` | HTTP endpoint (`dashscope.base_http_api_url`) | -| `DASHSCOPE_WEBSOCKET_BASE_URL` | WebSocket endpoint (`dashscope.base_websocket_api_url`) | -| `DASHSCOPE_COMPATIBLE_BASE_URL` | OpenAI-compatible endpoint (`dashscope.base_compatible_api_url`) | +By default requests go to the China (Beijing) gateway. When a `workspace` is set, endpoints are built from the workspace-scoped MaaS gateway `https://{workspace}.{region}.maas.aliyuncs.com`, with each protocol served under its own path. Point at another region with `region=` (or `DASHSCOPE_REGION` / `settings.region`): -`set_region` always builds workspace-exclusive endpoints. Some regions also offer shared domains without a workspace subdomain — `dashscope.aliyuncs.com` (Beijing), `dashscope-intl.aliyuncs.com` (Singapore) and `dashscope-us.aliyuncs.com` (US Virginia); use the override variables above to point at them. +```python +client = dashscope.Client(region="ap-southeast-1", workspace="ws-xxx123") +# -> https://ws-xxx123.ap-southeast-1.maas.aliyuncs.com/api/v1 +``` -### OpenAI-compatible chat completions +| Region | Location | +|--------|----------| +| `cn-beijing` | China (Beijing) — default | +| `cn-hongkong` | China (Hong Kong) | +| `ap-southeast-1` | Singapore | +| `ap-northeast-1` | Japan (Tokyo) | +| `eu-central-1` | Germany (Frankfurt) | +| `us-east-1` | US (Virginia) | -The SDK exposes an OpenAI-compatible chat completions entry that talks to `dashscope.base_compatible_api_url` (request path `chat/completions`) — no extra `openai` package required. It follows the region configured above. +Notes: + +- **No workspace configured?** The SDK falls back to the legacy shared gateway (`https://dashscope.aliyuncs.com` for Beijing) and emits a `UserWarning`. It works, but the workspace-scoped gateway is recommended for performance and stability. +- WebSocket-based realtime APIs (realtime speech, multimodal dialog) are served in `cn-beijing` and `ap-southeast-1`. +- Batch inference, fine-tuning and application development are currently available in `cn-beijing` and `ap-southeast-1`. +- `base_url=` (or `DASHSCOPE_BASE_URL`) overrides everything — the escape hatch for custom/private gateways; it does not require a workspace. +- The 1.x `dashscope.set_region(region, workspace_id)` global still works for the 1.x module-level API. + +## Capabilities + +Every capability is a resource on the client. Sync and async clients expose the same surface (async methods are `await`ed). + +| Capability | 2.0 resource | Example | +|------------|--------------|---------| +| Text chat / generation | `client.chat.completions.create(...)` | [`text_generation/chat.py`](examples/01_atomic_capabilities/text_generation/chat.py) | +| Multi-modal chat (vision, video) | `client.multimodal.chat.create(...)` | [`multimodal_vision/multimodal_chat.py`](examples/01_atomic_capabilities/multimodal_vision/multimodal_chat.py) | +| Text embedding | `client.embeddings.create(...)` (+ `.jobs` for batch) | [`embeddings/embeddings.py`](examples/01_atomic_capabilities/embeddings/embeddings.py) | +| Multi-modal embedding | `client.multimodal.embeddings.create(...)` | [`embeddings/`](examples/01_atomic_capabilities/embeddings/) | +| Rerank | `client.rerank.create(...)` | [`rerank/rerank.py`](examples/01_atomic_capabilities/rerank/rerank.py) | +| Decision (System One) | `client.decision.create(...)` | [`decision/decision.py`](examples/01_atomic_capabilities/decision/decision.py) | +| Token counting | `client.tokenization.count(...)` | [`text_generation/tokenization.py`](examples/01_atomic_capabilities/text_generation/tokenization.py) | +| Image generation | `client.images.generate(...)` (+ `.jobs`) | [`image_video_synthesis/images.py`](examples/01_atomic_capabilities/image_video_synthesis/images.py) | +| Video generation | `client.videos.generate(...)` (+ `.jobs`) | [`image_video_synthesis/videos.py`](examples/01_atomic_capabilities/image_video_synthesis/videos.py) | +| Speech: ASR / TTS / realtime *(preview)* | `client.audio.asr` · `client.audio.tts` · `client.audio.realtime` | [`audio/`](examples/01_atomic_capabilities/audio/) | +| Meeting & audio understanding (TingWu) | `client.tingwu.call(...)` / `.realtime(...)` | [`audio/tingwu.py`](examples/01_atomic_capabilities/audio/tingwu.py) | +| Files | `client.files.upload/list/get/delete(...)` | [`files/files.py`](examples/01_atomic_capabilities/files/files.py) | +| Models | `client.models.list/get(...)` | [`models/models.py`](examples/01_atomic_capabilities/models/models.py) | +| Fine-tuning | `client.fine_tunes.create/get/list/wait/cancel/delete(...)` | [`fine_tuning/fine_tunes.py`](examples/01_atomic_capabilities/fine_tuning/fine_tunes.py) | +| Model deployments | `client.deployments.create/scale/list/delete(...)` | [`fine_tuning/`](examples/01_atomic_capabilities/fine_tuning/) | +| Agentic RL | `client.rl` (`.jobs`, `register_functions`, `upload_data`) | [`fine_tuning/reinforcement_learning.py`](examples/01_atomic_capabilities/fine_tuning/reinforcement_learning.py) | +| Model Studio applications | `client.apps.completions.create(...)` | [`applications/apps.py`](examples/01_atomic_capabilities/applications/apps.py) | +| AgentStudio (agents, sessions, memory) | `client.agentstudio` | [`03_agent_recipes/agentstudio.py`](examples/03_agent_recipes/agentstudio.py) | + +A few common shapes: ```python -import dashscope -from dashscope.aigc.chat_completion import Completions +# Embeddings +resp = client.embeddings.create(model="text-embedding-v4", input=["hello", "world"], dimension=1024) +resp.embeddings[0].embedding + +# Multi-modal chat (image understanding) +resp = client.multimodal.chat.create( + model="qwen3-vl-plus", + messages=[{"role": "user", "content": [{"image": "https://.../cat.png"}, {"text": "What is this?"}]}], +) +resp.output_text -dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") +# Image generation (submit + wait); use client.images.jobs for manual control +job = client.images.generate(model="wanx2.1-t2i-turbo", prompt="a cozy flower shop", size="1024*1024") +if job.succeeded: + print(job.image_urls) -response = Completions.create( - model="qwen3.8-max", - messages=[{"role": "user", "content": "Hello"}], - api_key="YOUR-DASHSCOPE-API-KEY", - stream=False, # set True to get a generator of ChatCompletionChunk -) -print(response) +# A Model Studio application +resp = client.apps.completions.create(app_id="YOUR_APP_ID", prompt="How do I make stew?") +resp.text ``` -A complete runnable example is available in [`examples/legacy_1x/set_region_example.py`](examples/legacy_1x/set_region_example.py). +> **Audio is a preview.** The 2.0 `client.audio` surface is not final and may change; accessing it emits a `FutureWarning`. -## AI Assistant: DashScope SDK Expert +### Realtime over one WebSocket + +For latency-sensitive realtime audio, open a shared duplex socket and run multiple sessions on it instead of handshaking per task: + +```python +connection = client.ws_connection() +session = client.audio.asr.recognize(model="fun-asr-realtime", connection=connection) +# ... drive the session; the caller owns the connection ... +connection.close() +``` + +## Supported models -The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built on the bundled Agentic CLI (`dashscope/acli`) framework. For DashScope SDK/CLI users it is the recommended way to get development consultation and AI coding help — answering SDK/API questions, generating runnable examples, showing CLI usage, and diagnosing errors, right in your terminal. +| Category | Example models | 2.0 resource | +|----------|----------------|--------------| +| Text generation | qwen-max, qwen-plus, qwen-flash | `client.chat` | +| Multi-modal understanding | qwen3-vl-plus, qwen-omni | `client.multimodal.chat` | +| Text embedding | text-embedding-v4, text-embedding-v3 | `client.embeddings` | +| Multi-modal embedding | tongyi-embedding-vision-plus | `client.multimodal.embeddings` | +| Rerank | gte-rerank-v2, qwen3-rerank | `client.rerank` | +| Image generation | wanx2.1-t2i-turbo, wan-image | `client.images` | +| Video generation | wanx2.1-t2v, wan-i2v | `client.videos` | +| Speech synthesis (TTS) | cosyvoice-v1, qwen-tts | `client.audio.tts` | +| Speech recognition (ASR) | fun-asr-realtime, paraformer-v1 | `client.audio.asr` | +| Omni (realtime) | qwen-omni-realtime | `client.audio.realtime` | -- Run `dashscope` with no arguments to start the assistant. On first run it offers to install the SDK Expert knowledge pack (per-domain quick-reference skills: text, multimodal, speech, retrieval, fine-tuning, agent, cli), so guidance comes from the SDK's public interfaces — parameters, outputs, error codes — without reading the source -- Ask it instead of reading docs — e.g. `dashscope expert "how do I stream Generation output"` or `dashscope expert "CLI command to cancel a fine-tuning job"`, each of which asks once and exits. Type `/help` inside the assistant to list available commands (`/setup`, `/skill`, `/stats`, ...); classic SDK subcommands still work, plain `dashscope expert` enters the assistant interactively, and `dashscope expert --help` shows all the forms -- Full walkthrough: [DashScope SDK Expert guide](https://help.aliyun.com/en/model-studio/dashscope-sdk-expert) +For the latest model list, visit the [Model Studio model plaza](https://bailian.console.aliyun.com/). -## Supported Models +## AI Assistant: DashScope SDK Expert -| Category | Recommended Models | SDK Class | -|----------|-------------------|-----------| -| Text Generation | qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash | `Generation` | -| Multi-Modal Understanding | qwen3.5-omni-plus, qwen3.7-plus (vision) | `MultiModalConversation` | -| Text Embedding | qwen3.7-text-embedding, text-embedding-v4 | `TextEmbedding` | -| Multi-Modal Embedding | tongyi-embedding-vision-plus, qwen3-vl-embedding | `MultiModalEmbedding` | -| Text ReRank | qwen3.7-text-rerank, qwen3-rerank | `TextReRank` | -| Image Generation | wan2.7-image-pro, qwen-image-2.0-pro | `ImageSynthesis` | -| Video Generation | wan2.7-t2v, wan2.7-i2v, happyhorse-1.0-t2v/i2v | `VideoSynthesis` | -| Speech Synthesis (TTS) | cosyvoice-v3.5-plus, cosyvoice-v1 | `SpeechSynthesizer`, `HttpSpeechSynthesizer` | -| Speech Recognition (ASR) | fun-asr-realtime, fun-asr, paraformer-v1 | `Transcription` | -| Omni (Real-time) | qwen3.5-omni-plus-realtime | `MultiModalConversation` | +The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built on the bundled Agentic CLI (`dashscope/acli`). It answers SDK/API questions, generates runnable examples, shows CLI usage, and diagnoses errors right in your terminal. -For the latest model list, visit [Bailian Model Plaza](https://bailian.console.aliyun.com/). +- Run `dashscope` with no arguments to start the assistant. On first run it offers to install the knowledge pack (per-domain quick-reference skills: text, multimodal, speech, retrieval, fine-tuning, agent, cli), so guidance comes from the SDK's public interfaces — parameters, outputs, error codes — without reading the source. +- Ask it instead of reading docs — e.g. `dashscope expert "how do I stream chat output"` (asks once and exits). Type `/help` inside the assistant for available commands; `dashscope expert --help` shows all forms. +- Install with the `acli` extra: `pip install "dashscope[acli]"`. +- Full walkthrough: [DashScope SDK Expert guide](https://help.aliyun.com/en/model-studio/dashscope-sdk-expert). -## Shell Completion +## Shell completion -Run the appropriate command once, then restart your shell (or re-source your config file): +Run the command for your shell once, then restart the shell (or re-source your config): | Shell | Install command | |-------|-----------------| @@ -205,29 +398,55 @@ Run the appropriate command once, then restart your shell (or re-source your con | **zsh** | `dashscope --install-completion zsh` | | **fish** | `dashscope --install-completion fish` | -To preview the completion script without installing: +Preview the script without installing: + ```shell dashscope --show-completion bash ``` ## Logging -To output Dashscope logs, you need to configure the logger. -```shell -export DASHSCOPE_LOGGING_LEVEL='info' +Set the level via settings or the environment: + +```python +dashscope.settings.log_level = "INFO" +``` + +```shell +export DASHSCOPE_LOG="INFO" ``` -## Output -The output contains the following fields: +## Examples + +The [`examples/`](examples/) directory is a production best-practices collection built on the 2.0 client, organized as an advancement path: + ``` - request_id (str): The request id. - status_code (int): HTTP status code, 200 indicates that the - request was successful, others indicate an error. - code (str): Error code if error occurs, otherwise empty str. - message (str): Set to error message on error. - output (Any): The request output. - usage (Any): The request usage information. +examples/ +├── 01_atomic_capabilities/ Minimal runnable quickref per capability +├── 02_advanced_engineering/ Mock debugging, semantic cache, circuit breaker + multi-region DR +├── 03_agent_recipes/ Tool-call loop, RAG, MCP server, AgentStudio +├── 04_real_world_scenarios/ End-to-end production harnesses (RAG, realtime meetings, customer service) +└── legacy_1x/ 1.x module-level API examples, for reference ``` +See [`examples/README.md`](examples/README.md). Several flagship cases run fully offline (mock transports) with no API key. + +## Migrating from 1.x + +The 1.x module-level API is unchanged and still supported — you can adopt 2.0 incrementally. When you're ready, the mapping is direct: + +| 1.x | 2.0 | +|-----|-----| +| `Generation.call(model=..., messages=..., result_format="message")` | `client.chat.completions.create(model=..., messages=...)` | +| `resp.status_code == HTTPStatus.OK` then `resp.output.choices[0].message.content` | `resp.output_text` (errors raise by default) | +| `MultiModalConversation.call(...)` | `client.multimodal.chat.create(...)` | +| `TextEmbedding.call(...)` | `client.embeddings.create(...)` | +| `TextReRank.call(...)` | `client.rerank.create(...)` | +| `ImageSynthesis.call(...)` / `.async_call(...)` | `client.images.generate(...)` / `client.images.jobs` | +| `dashscope.api_key = "sk-xxx"` | `dashscope.Client(api_key=...)` or `dashscope.settings.api_key = ...` | +| `dashscope.set_region(region, workspace_id)` | `dashscope.Client(region=..., workspace=...)` | +| OpenAI-compatible via `dashscope.aigc.chat_completion.Completions` | `dashscope.Client(protocol="openai")` | + ## License + This project is licensed under the Apache License (Version 2.0). diff --git a/README_zh.md b/README_zh.md index 2af23d07..31cec0b6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -2,201 +2,395 @@ > [English](README.md) | **中文** -DashScope Python SDK 提供了访问[阿里云百炼(Model Studio)](https://help.aliyun.com/zh/model-studio/) API 的完整接口,覆盖文本生成、多模态理解、向量(Embedding)、重排(Rerank)、图像/视频生成、语音合成与识别等能力。 +DashScope Python SDK 是[阿里云百炼(Model Studio)](https://help.aliyun.com/zh/model-studio/)的官方客户端,覆盖文本生成、多模态理解、向量(Embedding)、重排(Rerank)、图像/视频生成、语音合成与识别、模型调优、Agentic RL、应用调用等能力。 -## 最新动态 +**2.0 版本**引入了资源风格的客户端 —— `dashscope.Client()`,其易用性对标 OpenAI SDK:一个客户端持有共享连接池,每项能力都以类型化资源的形式挂载其上(`client.chat`、`client.images`、`client.audio`……),响应是类型化对象而非裸字典,失败默认抛出异常。1.x 的模块级 API(`dashscope.Generation` 等)保持不变且仍被完整支持,因此 2.0 是纯增量的。 -**v1.27.0 内置交互式 AI 助手 —— [DashScope SDK Expert](#ai-助手dashscope-sdk-expert)。** 直接运行 `dashscope`(不带任何参数),或用 `dashscope expert "如何流式输出 Generation 结果"` 直接提问,即可在终端中获得 SDK/API 答疑、可运行示例、CLI 用法和错误诊断。助手基于按领域划分的速查技能(文本、多模态、语音、检索、微调、Agent、CLI),这些技能构建在 SDK 的公开接口——参数、输出、错误码——之上,让你直接提问,无需翻文档。在助手内输入 `/help` 可查看可用命令。 +## 2.0 新特性 + +| | 1.x | 2.0 | +|---|---|---| +| 入口 | 模块级静态类:`Generation.call(...)` | 资源风格客户端:`client.chat.completions.create(...)` | +| 一行调用 | —— | `dashscope.chat("qwen-plus", "讲个笑话")` | +| 响应 | 裸字典;需判断 `status_code == HTTPStatus.OK` | 类型化对象;直接读 `resp.output_text`、`resp.usage.total_tokens` | +| 错误 | 手动检查 `resp.code` / `resp.message` | 默认抛出类型化的 `DashScopeError` 异常 | +| 流式 | 裸帧生成器 | `ChatStream`,提供 `.text_stream`、`.reasoning_stream`、`.get_final_message()` | +| 协议 | DashScope 原生 + 单独的 compatible-mode 入口 | 一个客户端通过 `protocol=` 讲 `native`、`openai` 或 `anthropic` | +| 异步 | 独立的 `Aio*` 类 | `dashscope.AsyncClient()` 完整镜像同步接口 | +| 连接 | 每次调用新建 session | 每个客户端一个 keep-alive 连接池,跨资源共享 | +| 配置 | 模块级全局变量(`dashscope.api_key`、`set_region`) | 分层:参数 → `dashscope.settings` → 环境变量 → `~/.dashscope/config.toml` | ## 安装 -安装 DashScope Python SDK,只需运行: ```shell -pip install dashscope +pip install -U dashscope ``` -基础安装包含 SDK API 调用和 `dashscope` CLI 命令。可选功能组通过 extras 安装: +基础安装包含 SDK API 调用和 `dashscope` CLI 命令,要求 Python 3.9+。可选功能组通过 extras 安装: | Extra | 提供的能力 | 安装命令 | |-------|----------|---------| -| `acli` | 交互式 AI 助手(DashScope SDK Expert) | `pip install "dashscope[acli]"` | +| `acli` | 交互式 AI 助手(DashScope SDK Expert) | `pip install "dashscope[acli]"` | | `rl` | Agentic RL 微调 | `pip install "dashscope[rl]"` | | `tokenizer` | 本地 tokenizer(无需下载) | `pip install "dashscope[tokenizer]"` | -如果从 GitHub 克隆了源码,可以通过源码安装: +从源码安装: + ```shell pip install -e . ``` ## 快速开始 +先配置 API Key([如何获取](https://help.aliyun.com/zh/model-studio/get-api-key)): + +```shell +export DASHSCOPE_API_KEY="sk-xxx" +``` + +**一行调用** —— 最快拿到一次补全: + ```python -from http import HTTPStatus -from dashscope import Generation +import dashscope + +completion = dashscope.chat("qwen-plus", "讲个笑话") +print(completion.output_text) +``` + +**客户端** —— 正式项目的推荐写法。一个客户端,跨调用复用: -responses = Generation.call( - model="qwen3.7-plus", +```python +import dashscope + +client = dashscope.Client() # 读取 DASHSCOPE_API_KEY + +resp = client.chat.completions.create( + model="qwen-plus", messages=[ {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "Who are you?"}, + {"role": "user", "content": "你是谁?"}, ], - result_format="message", ) - -if responses.status_code == HTTPStatus.OK: - print(responses.output.choices[0].message.content) -else: - print(f"Error: {responses.code} - {responses.message}") +print(resp.output_text) +print(resp.usage.total_tokens, "tokens |", resp.request_id) ``` -## API Key 鉴权 +**流式** —— `stream=True` 返回 `ChatStream`: -SDK 使用 API Key 进行鉴权。获取 API Key 请参见[如何获取 API Key](https://help.aliyun.com/zh/model-studio/get-api-key),或参考[阿里云百炼官方文档(国内站)](https://help.aliyun.com/zh/model-studio/)和[阿里云百炼官方文档(国际站)](https://www.alibabacloud.com/help/en/model-studio/)。 +```python +stream = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "写一首关于大海的俳句"}], + stream=True, +) +for text in stream.text_stream: + print(text, end="", flush=True) -### 使用 API Key +# 或者把整个流合并成一个 ChatCompletion(含 usage): +# final = stream.get_final_message() +``` + +**异步** —— `AsyncClient` 镜像同步接口: -1. 通过代码设置 API Key ```python +import asyncio import dashscope -dashscope.api_key = 'YOUR-DASHSCOPE-API-KEY' -# 或者通过代码指定 API Key 文件路径 -# dashscope.api_key_file_path='~/.dashscope/api_key' +async def main(): + client = dashscope.AsyncClient() + resp = await client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "你好"}], + ) + print(resp.output_text) + await client.aclose() +asyncio.run(main()) ``` -2. 通过环境变量设置 API Key +## 核心概念 -a. 直接使用以下环境变量设置 API Key +### 客户端 -```shell -export DASHSCOPE_API_KEY='YOUR-DASHSCOPE-API-KEY' +`dashscope.Client()`(以及 `dashscope.AsyncClient()`)是唯一入口。它持有一个被所有资源调用共享的 keep-alive 连接池,并以属性形式暴露各项能力: + +```python +client = dashscope.Client( + api_key="sk-xxx", # 默认取 DASHSCOPE_API_KEY + workspace="ws-xxx123", # 默认取 DASHSCOPE_WORKSPACE + region="cn-beijing", # 默认取 DASHSCOPE_REGION + protocol="native", # native | openai | anthropic + timeout=300.0, + max_retries=2, +) ``` -b. 通过环境变量指定 API Key 文件路径 +用上下文管理器可自动关闭连接池;也可用 `with_options()` 派生一个变体(未指定的选项会被继承;当连接级选项不变时共享同一连接池): -```shell -export DASHSCOPE_API_KEY_FILE_PATH='~/.dashscope/api_key' +```python +with dashscope.Client() as client: + client.chat.completions.create(model="qwen-plus", messages=["hi"]) + +low_latency = client.with_options(timeout=10.0, max_retries=0) ``` -3. 将 API Key 保存到文件 +每次调用还接受请求级的 `api_key=`、`workspace=`、`timeout=` 覆盖。 + +### 协议 + +一个客户端可以讲三种线路协议,通过 `protocol=`(或 `DASHSCOPE_PROTOCOL` / `settings.protocol`)选择。无论哪种协议,请求/响应对象都保持同一套规范化的类型形态 —— 变化的只是线路格式: + +| `protocol` | 请求的目标 | 适用场景 | +|------------|----------|----------| +| `native`(默认) | DashScope 原生 API(`/api/v1`) | 需要 DashScope 的完整能力 | +| `openai` | OpenAI 兼容模式(`/compatible-mode/v1`) | 迁移 OpenAI 代码 —— 无需 `openai` 库 | +| `anthropic` | Anthropic 兼容模式(`/apps/anthropic`) | 迁移 Anthropic Messages 代码 | + ```python -from dashscope import save_api_key +client = dashscope.Client(protocol="openai") +resp = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "你好"}], +) +``` + +### 类型化响应 -save_api_key(api_key='YOUR-DASHSCOPE-API-KEY', - api_key_file_path='api_key_file_location or (None, will save to default location "~/.dashscope/api_key"') +每个响应都是类型化对象。对于对话,用 `output_text` 读取第一个 choice,或深入 `choices`、`usage`、`search_info`、`request_id`、`model`: +```python +resp = client.chat.completions.create(model="qwen-plus", messages=["hi"]) + +resp.output_text # choices[0].message.content +resp.choices[0].message.content # 等价,显式写法 +resp.choices[0].message.reasoning_content # 思考模型的推理内容(若有) +resp.choices[0].message.tool_calls # 工具调用(若有) +resp.usage.input_tokens # 还有 .output_tokens / .total_tokens +resp.request_id ``` -## 区域与端点配置 +所有响应共享统一信封:`request_id`、`status_code`、`code`、`message`、`is_error`,以及原始的 `headers`。 + +### 流式 + +`stream=True` 返回 `ChatStream`(`AsyncClient` 对应 `AsyncChatStream`),并提供类型化辅助属性: -默认情况下,SDK 将请求发往华北2(北京)公共端点 `dashscope.aliyuncs.com`。如果你的百炼(Model Studio)业务空间位于其他区域,请在调用前先切换端点。 +```python +stream = client.chat.completions.create(model="qwen-plus", messages=["hi"], stream=True) + +for text in stream.text_stream: # 内容增量 + ... +# stream.reasoning_stream # 推理增量(思考模型) +# stream.tool_call_stream # 工具调用增量 +# stream.get_final_message() # 合并后的 ChatCompletion,含 usage +``` -### 使用 `set_region` +默认每帧是增量的。传入 `incremental_output=False` 可改为接收累积快照(SDK 会替你合并增量)。 -`dashscope.set_region(region, workspace_id)` 会一次性把 HTTP、WebSocket 和 OpenAI-compatible 三个 base URL 指向指定区域。`workspace_id` 为必填项,会作为端点的子域名。 +> **注意:** SDK **不会**自动重试流式请求。生产环境请在你自己的重试/退避逻辑中包裹流消费 —— 参见 [`examples/04_real_world_scenarios/production_ready_rag_harness.py`](examples/04_real_world_scenarios/production_ready_rag_harness.py)。 + +### 错误 + +失败默认抛出类型化的 `DashScopeError`(非 2xx 的 HTTP 状态码,以及 HTTP 200 但业务报错,都会归入这里): ```python -import dashscope +from dashscope.v2 import errors + +try: + client.chat.completions.create(model="qwen-plus", messages=["hi"]) +except errors.RateLimitError as exc: + print("被限流:", exc.code, exc.status_code, exc.request_id) +except errors.ContentFilterError as exc: + print("被内容审核拦截:", exc.message) +except errors.DashScopeError as exc: # 所有 SDK 错误的基类 + print("请求失败:", exc.code, exc.message) +``` + +异常层级包含 `APIConnectionError` / `APITimeoutError`(网络层),以及在 `APIStatusError` 之下的:`BadRequestError`、`AuthenticationError`、`PermissionDeniedError`、`NotFoundError`、`RequestTooLargeError`、`RateLimitError`、`InternalServerError`、`ServiceUnavailableError`、`GatewayTimeoutError`、`ContentFilterError`。 -# 切换到新加坡区域,业务空间为 "ws-xxx123" -dashscope.set_region(region="ap-southeast-1", workspace_id="ws-xxx123") +想内联检查错误而不捕获异常?用 `raise_on_error=False` 构建客户端 —— 失败会以正常响应对象返回,带 `is_error=True`、`code`、`message`、`status_code`: -# 之后所有调用都会使用: -# https://ws-xxx123.ap-southeast-1.maas.aliyuncs.com/api/v1 -print(dashscope.base_http_api_url) +```python +client = dashscope.Client(raise_on_error=False) +resp = client.chat.completions.create(model="qwen-plus", messages=["hi"]) +if resp.is_error: + print(resp.code, resp.message) ``` -支持的区域: +## 鉴权 -| 区域 | 地理位置 | -|--------|----------| -| `cn-beijing` | 华北2(北京) | -| `cn-hongkong` | 中国(香港) | -| `ap-southeast-1` | 新加坡 | -| `ap-northeast-1` | 日本(东京) | -| `eu-central-1` | 德国(法兰克福) | -| `us-east-1` | 美国(弗吉尼亚) | +SDK 使用 API Key 鉴权,可在[百炼控制台](https://help.aliyun.com/zh/model-studio/get-api-key)获取。可通过以下任一方式提供(优先级由高到低): -> **各地域 API Key 相互独立。**每个地域的 API Key(`sk-` 前缀)需在对应地域的百炼控制台创建,不可跨地域混用——使用其他地域的 Key 会返回 `401`。切换地域时请同步更换 `api_key`。 +1. **按客户端** —— `dashscope.Client(api_key="sk-xxx")` +2. **全局设置** —— `dashscope.settings.api_key = "sk-xxx"`(对之后创建的客户端生效) +3. **环境变量** —— `export DASHSCOPE_API_KEY="sk-xxx"` +4. **配置文件** —— `~/.dashscope/config.toml` 中的 `api_key` +5. **1.x key 文件** —— 由 `dashscope.save_api_key(...)` 写入的文件(`~/.dashscope/api_key`,或 `DASHSCOPE_API_KEY_FILE_PATH`),作为最低优先级,让既有 1.x 配置继续可用 -地域特殊说明: +每次调用还接受请求级的 `api_key=` 覆盖。 -- WebSocket 端点(`wss://.../api-ws/v1/inference`)目前仅 `cn-beijing` 与 `ap-southeast-1` 提供。`set_region` 对所有地域都会设置 `base_websocket_api_url`,但实时语音识别/合成、多模态对话等基于 WebSocket 的实时 API 在其他地域不可用。 -- `eu-central-1` / `ap-northeast-1`:部署范围(全球,或欧盟 / 日本)在控制台创建业务空间时选择,不在 API 调用层配置。 -- `us-east-1`:模型名带 `-us` 后缀(如 `qwen-plus-us`)限定美国境内推理;不带后缀默认全球推理。 -- 批量推理、模型调优、应用开发等高级功能目前仅 `cn-beijing` 与 `ap-southeast-1` 支持。 +> **各地域 API Key 相互独立。** 每个地域签发各自的 Key;使用其他地域的 Key 会返回 `401`。切换 `region` 时请同步更换 `api_key`。 -> `set_region` 修改的是进程级全局变量,因此在单进程同时访问多个区域时并非并发安全。建议在启动时调用一次,或在每次切换前重新调用。 +## 配置 -### 使用环境变量 +配置按层解析 —— **显式参数 → `dashscope.settings` → 环境变量 → `~/.dashscope/config.toml`**。可以在启动时设置一次、按客户端设置,或完全不设置。 -也可以不写代码,直接通过环境变量选择区域: +**全局设置门面:** -```shell -export DASHSCOPE_API_REGION='ap-southeast-1' # 默认:cn-beijing -export DASHSCOPE_WORKSPACE_ID='ws-xxx123' # 用于解析端点子域名 +```python +import dashscope + +dashscope.settings.api_key = "sk-xxx" +dashscope.settings.workspace = "ws-xxx123" +dashscope.settings.region = "ap-southeast-1" +dashscope.settings.protocol = "openai" +dashscope.settings.timeout = 60.0 +dashscope.settings.max_retries = 3 +dashscope.settings.log_level = "INFO" ``` -当通过 `DASHSCOPE_API_REGION` 设置了 MaaS 区域时,SDK 会构造对应的区域端点,并把 `DASHSCOPE_WORKSPACE_ID` 代入其中。你也可以直接覆盖每一个 base URL: +**环境变量:** + +| 变量 | 设置的对象 | 默认值 | +|----------|------|---------| +| `DASHSCOPE_API_KEY` | API Key | —— | +| `DASHSCOPE_WORKSPACE` | 业务空间 ID(端点子域名) | —— | +| `DASHSCOPE_REGION` | 地域 | `cn-beijing` | +| `DASHSCOPE_PROTOCOL` | `native` / `openai` / `anthropic` | `native` | +| `DASHSCOPE_BASE_URL` | 完全覆盖端点(逃生舱) | —— | +| `DASHSCOPE_TIMEOUT` | 请求超时(秒) | `300` | +| `DASHSCOPE_MAX_RETRIES` | 自动重试次数 | `2` | +| `DASHSCOPE_LOG` | 日志级别 | `WARNING` | +| `DASHSCOPE_CONFIG` | `config.toml` 路径 | `~/.dashscope/config.toml` | + +**配置文件**(`~/.dashscope/config.toml`): + +```toml +api_key = "sk-xxx" +workspace = "ws-xxx123" +region = "ap-southeast-1" +protocol = "native" +timeout = 300.0 +max_retries = 2 +``` + +### 区域与端点 -| 环境变量 | 覆盖的对象 | -|----------------------|-----------| -| `DASHSCOPE_HTTP_BASE_URL` | HTTP 端点(`dashscope.base_http_api_url`) | -| `DASHSCOPE_WEBSOCKET_BASE_URL` | WebSocket 端点(`dashscope.base_websocket_api_url`) | -| `DASHSCOPE_COMPATIBLE_BASE_URL` | OpenAI-compatible 端点(`dashscope.base_compatible_api_url`) | +默认请求发往华北2(北京)网关。当设置了 `workspace` 时,端点会基于业务空间专属的 MaaS 网关 `https://{workspace}.{region}.maas.aliyuncs.com` 构造,每种协议走各自的路径。用 `region=`(或 `DASHSCOPE_REGION` / `settings.region`)指向其他地域: -`set_region` 构造的始终是业务空间专属域名。部分地域还提供不含 workspace 子域名的共享域名——北京 `dashscope.aliyuncs.com`、新加坡 `dashscope-intl.aliyuncs.com`、美国 `dashscope-us.aliyuncs.com`,如需使用可通过上面的环境变量直接覆盖。 +```python +client = dashscope.Client(region="ap-southeast-1", workspace="ws-xxx123") +# -> https://ws-xxx123.ap-southeast-1.maas.aliyuncs.com/api/v1 +``` -### OpenAI-compatible 对话补全 +| 区域 | 地理位置 | +|--------|----------| +| `cn-beijing` | 华北2(北京)—— 默认 | +| `cn-hongkong` | 中国(香港) | +| `ap-southeast-1` | 新加坡 | +| `ap-northeast-1` | 日本(东京) | +| `eu-central-1` | 德国(法兰克福) | +| `us-east-1` | 美国(弗吉尼亚) | -SDK 提供了 OpenAI-compatible 的对话补全入口,它会请求 `dashscope.base_compatible_api_url`(请求路径 `chat/completions`)——无需额外安装 `openai` 库,并自动跟随上面配置的区域。 +说明: + +- **未配置 workspace?** SDK 会回退到传统的共享网关(北京为 `https://dashscope.aliyuncs.com`)并发出 `UserWarning`。这可以正常工作,但为了性能与稳定性,推荐使用业务空间专属网关。 +- 基于 WebSocket 的实时 API(实时语音、多模态对话)在 `cn-beijing` 与 `ap-southeast-1` 提供。 +- 批量推理、模型调优、应用开发目前仅在 `cn-beijing` 与 `ap-southeast-1` 提供。 +- `base_url=`(或 `DASHSCOPE_BASE_URL`)会覆盖一切 —— 这是自定义/私有网关的逃生舱,且不要求配置 workspace。 +- 1.x 的 `dashscope.set_region(region, workspace_id)` 全局函数对 1.x 模块级 API 仍然有效。 + +## 能力一览 + +每项能力都是客户端上的一个资源。同步与异步客户端暴露相同的接口(异步方法用 `await` 调用)。 + +| 能力 | 2.0 资源 | 示例 | +|------------|--------------|---------| +| 文本对话 / 生成 | `client.chat.completions.create(...)` | [`text_generation/chat.py`](examples/01_atomic_capabilities/text_generation/chat.py) | +| 多模态对话(图像、视频) | `client.multimodal.chat.create(...)` | [`multimodal_vision/multimodal_chat.py`](examples/01_atomic_capabilities/multimodal_vision/multimodal_chat.py) | +| 文本向量 | `client.embeddings.create(...)`(批量用 `.jobs`) | [`embeddings/embeddings.py`](examples/01_atomic_capabilities/embeddings/embeddings.py) | +| 多模态向量 | `client.multimodal.embeddings.create(...)` | [`embeddings/`](examples/01_atomic_capabilities/embeddings/) | +| 重排 | `client.rerank.create(...)` | [`rerank/rerank.py`](examples/01_atomic_capabilities/rerank/rerank.py) | +| 决策(System One) | `client.decision.create(...)` | [`decision/decision.py`](examples/01_atomic_capabilities/decision/decision.py) | +| Token 计数 | `client.tokenization.count(...)` | [`text_generation/tokenization.py`](examples/01_atomic_capabilities/text_generation/tokenization.py) | +| 图像生成 | `client.images.generate(...)`(+ `.jobs`) | [`image_video_synthesis/images.py`](examples/01_atomic_capabilities/image_video_synthesis/images.py) | +| 视频生成 | `client.videos.generate(...)`(+ `.jobs`) | [`image_video_synthesis/videos.py`](examples/01_atomic_capabilities/image_video_synthesis/videos.py) | +| 语音:ASR / TTS / 实时 *(预览)* | `client.audio.asr` · `client.audio.tts` · `client.audio.realtime` | [`audio/`](examples/01_atomic_capabilities/audio/) | +| 会议与音频理解(通义听悟 TingWu) | `client.tingwu.call(...)` / `.realtime(...)` | [`audio/tingwu.py`](examples/01_atomic_capabilities/audio/tingwu.py) | +| 文件 | `client.files.upload/list/get/delete(...)` | [`files/files.py`](examples/01_atomic_capabilities/files/files.py) | +| 模型 | `client.models.list/get(...)` | [`models/models.py`](examples/01_atomic_capabilities/models/models.py) | +| 模型调优 | `client.fine_tunes.create/get/list/wait/cancel/delete(...)` | [`fine_tuning/fine_tunes.py`](examples/01_atomic_capabilities/fine_tuning/fine_tunes.py) | +| 模型部署 | `client.deployments.create/scale/list/delete(...)` | [`fine_tuning/`](examples/01_atomic_capabilities/fine_tuning/) | +| Agentic RL | `client.rl`(`.jobs`、`register_functions`、`upload_data`) | [`fine_tuning/reinforcement_learning.py`](examples/01_atomic_capabilities/fine_tuning/reinforcement_learning.py) | +| 百炼应用 | `client.apps.completions.create(...)` | [`applications/apps.py`](examples/01_atomic_capabilities/applications/apps.py) | +| AgentStudio(智能体、会话、记忆) | `client.agentstudio` | [`03_agent_recipes/agentstudio.py`](examples/03_agent_recipes/agentstudio.py) | + +几个常见写法: ```python -import dashscope -from dashscope.aigc.chat_completion import Completions +# 向量 +resp = client.embeddings.create(model="text-embedding-v4", input=["你好", "世界"], dimension=1024) +resp.embeddings[0].embedding + +# 多模态对话(图像理解) +resp = client.multimodal.chat.create( + model="qwen3-vl-plus", + messages=[{"role": "user", "content": [{"image": "https://.../cat.png"}, {"text": "这是什么?"}]}], +) +resp.output_text -dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") +# 图像生成(提交 + 等待);手动控制用 client.images.jobs +job = client.images.generate(model="wanx2.1-t2i-turbo", prompt="一间温馨的花店", size="1024*1024") +if job.succeeded: + print(job.image_urls) -response = Completions.create( - model="qwen3.8-max", - messages=[{"role": "user", "content": "你好"}], - api_key="YOUR-DASHSCOPE-API-KEY", - stream=False, # 设为 True 则返回 ChatCompletionChunk 生成器 -) -print(response) +# 百炼应用 +resp = client.apps.completions.create(app_id="YOUR_APP_ID", prompt="土豆炖猪脚怎么做?") +resp.text ``` -完整可运行示例见 [`examples/legacy_1x/set_region_example.py`](examples/legacy_1x/set_region_example.py)。 +> **语音为预览版。** 2.0 的 `client.audio` 接口尚未定型,可能变化;访问它会发出 `FutureWarning`。 -## AI 助手:DashScope SDK Expert +### 单条 WebSocket 上的实时能力 -SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供的 Agentic CLI(`dashscope/acli`)框架构建。对于 DashScope SDK/CLI 用户,它是获取开发咨询和 AI 编码帮助的推荐方式——直接在终端中解答 SDK/API 问题、生成可运行示例、展示 CLI 用法、诊断错误。 +对于时延敏感的实时语音,可以打开一条共享双工 socket,在其上运行多个会话,而不必每个任务都重新握手: -- 直接运行 `dashscope`(不带参数)即可启动助手。首次运行时会提示安装 SDK Expert 知识包(按领域划分的速查技能:文本、多模态、语音、检索、微调、Agent、CLI),使助手的指导来自 SDK 的公开接口——参数、输出、错误码——而无需阅读源码 -- 直接提问代替翻文档——如 `dashscope expert "如何流式输出 Generation 结果"` 或 `dashscope expert "取消微调任务的 CLI 命令"`,均为一次性提问后退出。在助手内输入 `/help` 可列出可用命令(`/setup`、`/skill`、`/stats` 等);经典 SDK 子命令依然可用,单独执行 `dashscope expert` 进入交互式助手,`dashscope expert --help` 查看全部形式 -- 完整使用指南:[DashScope SDK Expert 文档](https://help.aliyun.com/zh/model-studio/dashscope-sdk-expert) +```python +connection = client.ws_connection() +session = client.audio.asr.recognize(model="fun-asr-realtime", connection=connection) +# ……驱动该会话;连接由调用方持有…… +connection.close() +``` ## 支持的模型 -| 类别 | 推荐模型 | SDK 类 | -|----------|-------------------|-----------| -| 文本生成 | qwen3.8-max、qwen3.7-max、qwen3.7-plus、qwen3.6-flash | `Generation` | -| 多模态理解 | qwen3.5-omni-plus、qwen3.7-plus(视觉) | `MultiModalConversation` | -| 文本向量 | text-embedding-v4、text-embedding-v3 | `TextEmbedding` | -| 多模态向量 | tongyi-embedding-vision-plus、qwen3-vl-embedding | `MultiModalEmbedding` | -| 文本重排 | qwen3-rerank、gte-rerank-v2 | `TextReRank` | -| 图像生成 | wan2.7-image-pro、qwen-image-2.0-pro | `ImageSynthesis` | -| 视频生成 | wan2.7-t2v、wan2.7-i2v、happyhorse-1.0-t2v/i2v | `VideoSynthesis` | -| 语音合成(TTS) | cosyvoice-v3.5-plus、cosyvoice-v1 | `SpeechSynthesizer`、`HttpSpeechSynthesizer` | -| 语音识别(ASR) | fun-asr-realtime、fun-asr、paraformer-v1 | `Transcription` | -| 全模态(实时) | qwen3.5-omni-plus-realtime | `MultiModalConversation` | +| 类别 | 示例模型 | 2.0 资源 | +|----------|----------------|--------------| +| 文本生成 | qwen-max、qwen-plus、qwen-flash | `client.chat` | +| 多模态理解 | qwen3-vl-plus、qwen-omni | `client.multimodal.chat` | +| 文本向量 | text-embedding-v4、text-embedding-v3 | `client.embeddings` | +| 多模态向量 | tongyi-embedding-vision-plus | `client.multimodal.embeddings` | +| 重排 | gte-rerank-v2、qwen3-rerank | `client.rerank` | +| 图像生成 | wanx2.1-t2i-turbo、wan-image | `client.images` | +| 视频生成 | wanx2.1-t2v、wan-i2v | `client.videos` | +| 语音合成(TTS) | cosyvoice-v1、qwen-tts | `client.audio.tts` | +| 语音识别(ASR) | fun-asr-realtime、paraformer-v1 | `client.audio.asr` | +| 全模态(实时) | qwen-omni-realtime | `client.audio.realtime` | 最新模型列表请访问[百炼模型广场](https://bailian.console.aliyun.com/)。 +## AI 助手:DashScope SDK Expert + +SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供的 Agentic CLI(`dashscope/acli`)框架构建。它能直接在终端中解答 SDK/API 问题、生成可运行示例、展示 CLI 用法、诊断错误。 + +- 直接运行 `dashscope`(不带参数)即可启动助手。首次运行时会提示安装知识包(按领域划分的速查技能:文本、多模态、语音、检索、微调、Agent、CLI),使助手的指导来自 SDK 的公开接口 —— 参数、输出、错误码 —— 而无需阅读源码。 +- 直接提问代替翻文档 —— 如 `dashscope expert "如何流式输出对话结果"`(一次性提问后退出)。在助手内输入 `/help` 查看可用命令;`dashscope expert --help` 查看全部形式。 +- 通过 `acli` extra 安装:`pip install "dashscope[acli]"`。 +- 完整使用指南:[DashScope SDK Expert 文档](https://help.aliyun.com/zh/model-studio/dashscope-sdk-expert)。 + ## Shell 命令补全 -运行对应命令一次,然后重启 Shell(或重新 source 配置文件): +针对你的 Shell 运行一次对应命令,然后重启 Shell(或重新 source 配置文件): | Shell | 安装命令 | |-------|-----------------| @@ -204,31 +398,55 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 | **zsh** | `dashscope --install-completion zsh` | | **fish** | `dashscope --install-completion fish` | -如需预览补全脚本而不安装: +预览补全脚本而不安装: + ```shell dashscope --show-completion bash ``` ## 日志 -如需输出 DashScope 日志,请配置日志级别: -```shell -export DASHSCOPE_LOGGING_LEVEL='info' +通过设置或环境变量配置日志级别: + +```python +dashscope.settings.log_level = "INFO" +``` +```shell +export DASHSCOPE_LOG="INFO" ``` -## 输出 +## 示例 + +[`examples/`](examples/) 目录是基于 2.0 客户端的生产最佳实践合集,按进阶路径组织: -输出包含以下字段: ``` - request_id (str): 请求 ID。 - status_code (int): HTTP 状态码,200 表示请求成功,其他值表示错误。 - code (str): 出错时的错误码,否则为空字符串。 - message (str): 出错时设置为错误信息。 - output (Any): 请求输出。 - usage (Any): 请求用量信息。 +examples/ +├── 01_atomic_capabilities/ 每项能力的极简可运行速查 +├── 02_advanced_engineering/ 本地 mock 调试、语义缓存、熔断 + 多地域容灾 +├── 03_agent_recipes/ 工具调用循环、RAG、MCP server、AgentStudio +├── 04_real_world_scenarios/ 端到端生产级 harness(RAG、实时会议、智能客服) +└── legacy_1x/ 1.x 模块级 API 示例,仅供参考 ``` +详见 [`examples/README_zh.md`](examples/README_zh.md)。其中若干旗舰案例可完全离线运行(mock 传输),无需 API Key。 + +## 从 1.x 迁移 + +1.x 的模块级 API 保持不变且仍被支持 —— 你可以渐进式地采用 2.0。准备好之后,映射关系是一一对应的: + +| 1.x | 2.0 | +|-----|-----| +| `Generation.call(model=..., messages=..., result_format="message")` | `client.chat.completions.create(model=..., messages=...)` | +| 先判断 `resp.status_code == HTTPStatus.OK`,再取 `resp.output.choices[0].message.content` | `resp.output_text`(错误默认抛异常) | +| `MultiModalConversation.call(...)` | `client.multimodal.chat.create(...)` | +| `TextEmbedding.call(...)` | `client.embeddings.create(...)` | +| `TextReRank.call(...)` | `client.rerank.create(...)` | +| `ImageSynthesis.call(...)` / `.async_call(...)` | `client.images.generate(...)` / `client.images.jobs` | +| `dashscope.api_key = "sk-xxx"` | `dashscope.Client(api_key=...)` 或 `dashscope.settings.api_key = ...` | +| `dashscope.set_region(region, workspace_id)` | `dashscope.Client(region=..., workspace=...)` | +| OpenAI 兼容通过 `dashscope.aigc.chat_completion.Completions` | `dashscope.Client(protocol="openai")` | + ## 许可证 本项目采用 Apache License (Version 2.0) 许可证。