diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index e207745c..9beeb4e9 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -9,15 +9,14 @@ on: - 'main' jobs: - test: + current: if: ${{ !(github.event_name == 'pull_request' && startsWith(github.event.pull_request.title, 'docs:')) }} runs-on: ${{ matrix.os }} strategy: matrix: os: [ ubuntu-latest ] - python-version: [ '3.9', '3.12', '3.13', '3.14' ] - + python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] steps: - name: Checkout repository @@ -55,10 +54,55 @@ jobs: run: | python -m pip list --format=columns - - name: Run tests with coverage + - name: Run current tests with coverage run: | - coverage run -m pytest tests/unit + coverage run -m pytest tests --ignore=tests/legacy_1x - name: Generate coverage report run: | coverage report -m + + legacy: + if: ${{ !(github.event_name == 'pull_request' && startsWith(github.event.pull_request.title, 'docs:')) }} + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest ] + python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Update setuptools + run: | + pip install --upgrade pip + pip install setuptools==68.2.2 wheel==0.41.2 + + - name: Set PYTHONPATH + run: | + echo "PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/src" >> $GITHUB_ENV + + - name: Install dependencies + run: | + export PIP_DEFAULT_TIMEOUT=300 + pip install -r requirements.txt + pip install -r requirements-test.txt + + - name: Run archived legacy tests + run: | + python -m pytest tests/legacy_1x diff --git a/.gitignore b/.gitignore index c8a620a1..0fbbae98 100644 --- a/.gitignore +++ b/.gitignore @@ -144,5 +144,5 @@ cython_debug/ # Local design/analysis docs — kept out of the public repo /md/ -# Local PyPI investigation tooling; the CI guard lives in tests/unit/. +# Local PyPI investigation tooling; the CI guard lives in tests/legacy_1x/unit/. /scripts/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index efb06421..a9e4c914 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,36 +3,36 @@ repos: rev: v4.3.0 hooks: - id: check-ast - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: sort-simple-yaml - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: check-yaml exclude: | (?x)^( meta.yaml - | tests/legacy/ + | tests/legacy_1x/legacy/ )$ - id: check-xml - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: check-toml - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: check-docstring-first - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: check-json - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: fix-encoding-pragma - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: detect-private-key - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - id: trailing-whitespace - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ - repo: https://github.com/asottile/add-trailing-comma rev: v3.1.0 hooks: - id: add-trailing-comma # 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)/ + exclude: ^(tests/legacy_1x/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.7.0 hooks: @@ -45,7 +45,7 @@ repos: pb2\.py$ | grpc\.py$ | ^docs - | ^tests/legacy/ + | ^tests/legacy_1x/legacy/ | ^examples/01_atomic_capabilities/audio/ | \.html$ | \.pyi$ @@ -67,13 +67,13 @@ repos: hooks: - id: black args: [ --line-length=79 ] - exclude: ^(tests/legacy|examples/01_atomic_capabilities/audio)/ + exclude: ^(tests/legacy_1x/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|examples/01_atomic_capabilities/audio)/ + exclude: ^(tests/legacy_1x/legacy|examples/01_atomic_capabilities/audio)/ - repo: https://github.com/pylint-dev/pylint rev: v3.0.2 hooks: @@ -97,6 +97,7 @@ repos: --disable=R0913, --disable=E0401, --disable=E1101, + --disable=E0611, # Runtime meta-path aliases are invisible to astroid --disable=C0415, --disable=W0603, --disable=R1705, @@ -122,6 +123,7 @@ repos: --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=E0603, # Absolute self-imports and lazy exports in reinforcement ] # Tests get their own entry (pylint 3.0.2 discovers rcfiles from the # invocation CWD, not per linted-file directory, so a tests/pylintrc @@ -132,7 +134,7 @@ repos: - id: pylint name: pylint (tests) files: ^tests/ - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ args: [ --disable=W0511, --disable=W0718, @@ -141,6 +143,7 @@ repos: --disable=R0913, --disable=E0401, --disable=E1101, + --disable=E0611, # Runtime meta-path aliases are invisible to astroid --disable=C0415, --disable=W0603, --disable=R1705, @@ -190,14 +193,14 @@ repos: hooks: - id: eslint files: \.(js|jsx)$ - exclude: '(.*js_third_party.*|^tests/legacy/)' + exclude: '(.*js_third_party.*|^tests/legacy_1x/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/)' + exclude: '(.*css_third_party.*|^tests/legacy_1x/legacy/)' args: [ '--fix' ] - repo: https://github.com/pre-commit/mirrors-prettier rev: 'v3.0.0' @@ -205,4 +208,4 @@ repos: - id: prettier additional_dependencies: [ 'prettier@3.0.0' ] files: \.(tsx?)$ - exclude: ^tests/legacy/ + exclude: ^tests/legacy_1x/legacy/ diff --git a/MANIFEST.in b/MANIFEST.in index 5ddd1f49..6712512a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ -include dashscope/resources/qwen.tiktoken +include dashscope/legacy_1x/tokenizers/qwen.tiktoken include dashscope/py.typed include dashscope/__init__.pyi include NOTICE diff --git a/README.md b/README.md index 4a6f7b84..3ed6ff90 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 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. -**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. +**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 complete 1.x public surface remains available at its historical import paths and is implemented by facades under `dashscope.legacy_1x`; AgentStudio and Agentic RL share their large business cores with 2.0 without sharing public return or error contracts. ## What's new in 2.0 @@ -16,7 +16,7 @@ The DashScope Python SDK is the official client for [Alibaba Cloud Model Studio | Errors | Inspect `resp.code` / `resp.message` by hand | Typed `DashScopeError` exceptions raised by default *(evolving)* | | 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 | +| Async | Separate `Aio*` classes | `dashscope.AsyncClient()` mirrors the sync surface (except the duplex audio WebSocket sessions) | | 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` | @@ -26,11 +26,16 @@ The DashScope Python SDK is the official client for [Alibaba Cloud Model Studio pip install -U dashscope ``` -The base install covers SDK API calls, the `dashscope` CLI command, Agentic RL fine-tuning and the local tokenizer. Python 3.9+ is required. One optional feature group remains: +The base install covers SDK API calls, the `dashscope` CLI command, Agentic RL fine-tuning and the local tokenizer. Python 3.9+ is required. Optional feature groups all belong to the bundled AI assistant: | Extra | Provides | Install | |-------|----------|---------| | `acli` | Interactive AI assistant (DashScope SDK Expert) | `pip install "dashscope[acli]"` | +| `acli-openai` | Run the assistant on an OpenAI-compatible model provider | `pip install "dashscope[acli-openai]"` | +| `acli-anthropic` | Run the assistant on an Anthropic model provider | `pip install "dashscope[acli-anthropic]"` | +| `acli-voice` | Voice input and spoken replies in the assistant | `pip install "dashscope[acli-voice]"` | +| `acli-camera` | Camera capture for image input in the assistant | `pip install "dashscope[acli-camera]"` | +| `acli-all` | All of the above (includes `acli`) | `pip install "dashscope[acli-all]"` | To install from a source checkout: @@ -120,9 +125,16 @@ client = dashscope.Client( protocol="native", # native | openai | anthropic timeout=300.0, max_retries=2, + base_url=None, # override the endpoint entirely; default: DASHSCOPE_BASE_URL + config_path=None, # alternate config.toml; default: DASHSCOPE_CONFIG + protocol_options=None, # extra kwargs forwarded to the selected protocol adapter + raise_on_error=True, # False: return error responses instead of raising + http_client=None, # your own httpx.Client (AsyncClient for AsyncClient) ) ``` +All options are keyword-only. + 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): ```python @@ -134,6 +146,8 @@ low_latency = client.with_options(timeout=10.0, max_retries=0) Any call also accepts per-request `api_key=`, `workspace=`, and `timeout=` overrides. +Need proxies, custom connection limits or event hooks? Pass your own `http_client=httpx.Client(...)` (`httpx.AsyncClient` for `AsyncClient`). The SDK still applies authentication, retries and absolute endpoint URLs on top of it, and never closes a client it does not own. + ### 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: @@ -185,7 +199,9 @@ for text in stream.text_stream: # content deltas 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). +Thinking models surface reasoning on `.reasoning_stream` and, in the merged message, as `message.reasoning_content`. Under `protocol="anthropic"` the SDK also keeps `message.reasoning_signature` plus the raw `thinking` / `redacted_thinking` blocks in `message.reasoning_blocks`, so echoing the assistant message back on the next turn preserves extended thinking. + +> **Note:** streaming retries stop at the handshake. The SDK retries connection failures and retryable HTTP statuses only *before* the first event reaches you; once a stream is open it is never replayed, so a mid-stream drop surfaces as an exception. 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 @@ -194,7 +210,7 @@ By default each frame is incremental. Pass `incremental_output=False` to receive Failures raise a typed `DashScopeError` by default (both non-2xx HTTP statuses and HTTP-200-with-a-business-error land here): ```python -from dashscope.v2 import errors +from dashscope import errors try: client.chat.completions.create(model="qwen-plus", messages=["hi"]) @@ -206,7 +222,12 @@ except errors.DashScopeError as exc: # base class for all SDK errors print("request failed:", exc.code, exc.message) ``` -The hierarchy includes `APIConnectionError` / `APITimeoutError` (network) and, under `APIStatusError`: `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `RequestTooLargeError`, `RateLimitError`, `InternalServerError`, `ServiceUnavailableError`, `GatewayTimeoutError`, and `ContentFilterError`. +Everything derives from `DashScopeError`, in two families: + +- **Server errors** — under `APIStatusError`: `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `RequestTooLargeError`, `RateLimitError`, `InternalServerError`, `ServiceUnavailableError`, `GatewayTimeoutError`, and `ContentFilterError` (a `BadRequestError` subclass). +- **Client-side errors** — under `SdkError`: `APIConnectionError` / `APITimeoutError` for network failures, plus the WebSocket family `WsEventError`, `WsStateError`, `WsQueueCapacityExceededError` and `WsBinaryOwnerConflictError`. Each carries a stable `sdk.*` code. + +A 200 response whose body does not parse into the expected envelope is reported as `InternalServerError` rather than leaking a decoder exception. 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`: @@ -257,11 +278,19 @@ dashscope.settings.log_level = "INFO" | `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_BASE_URL` | Override the HTTP endpoint entirely (escape hatch) | — | +| `DASHSCOPE_HTTP_BASE_URL` | 1.x name for the same override, honored as a fallback | — | +| `DASHSCOPE_WEBSOCKET_BASE_URL` | Override the WebSocket endpoint | derived from region/workspace | | `DASHSCOPE_TIMEOUT` | Request timeout (seconds) | `300` | | `DASHSCOPE_MAX_RETRIES` | Automatic retries | `2` | +| `DASHSCOPE_HTTP_MAX_CONNECTIONS` | Connection-pool size | `100` | +| `DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS` | Keep-alive connections held in the pool | `20` | +| `DASHSCOPE_HTTP_KEEPALIVE_EXPIRY` | Keep-alive expiry (seconds) | `5.0` | | `DASHSCOPE_LOG` | Log level | `WARNING` | | `DASHSCOPE_CONFIG` | Path to `config.toml` | `~/.dashscope/config.toml` | +| `DASHSCOPE_API_KEY_FILE_PATH` | Path to the 1.x key file (lowest-priority auth tier) | `~/.dashscope/api_key` | + +The three `DASHSCOPE_HTTP_*` pool variables are also settable as `http_max_connections`, `http_max_keepalive_connections` and `http_keepalive_expiry` in `settings` and `config.toml`. Passing your own `http_client=` takes precedence over all of them. **Config file** (`~/.dashscope/config.toml`): @@ -302,7 +331,7 @@ Notes: ## Capabilities -Every capability is a resource on the client. Sync and async clients expose the same surface (async methods are `await`ed). +Every capability is a resource on the client. Sync and async clients expose the same surface (async methods are `await`ed) — with one exception: the duplex WebSocket audio sessions are sync-only by design, as noted below. | Capability | 2.0 resource | Example | |------------|--------------|---------| @@ -315,7 +344,10 @@ Every capability is a resource on the client. Sync and async clients expose the | 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/) | +| Speech recognition (ASR) | Batch `client.audio.asr.transcribe(...)` / `.jobs`; realtime `client.audio.asr.recognize(...)` | [`audio/asr_file.py`](examples/01_atomic_capabilities/audio/asr_file.py) · [`audio/asr_websocket.py`](examples/01_atomic_capabilities/audio/asr_websocket.py) | +| Speech synthesis (TTS) | HTTP `client.audio.tts.http.synthesize(...)` / `.stream(...)`; WebSocket `client.audio.tts.connect(...)` | [`audio/tts_http.py`](examples/01_atomic_capabilities/audio/tts_http.py) · [`audio/tts_websocket.py`](examples/01_atomic_capabilities/audio/tts_websocket.py) | +| Realtime omni dialog | `client.audio.realtime.connect(...)` | [`audio/realtime_omni.py`](examples/01_atomic_capabilities/audio/realtime_omni.py) | +| Voice & vocabulary management | `client.audio.voices` · `.voice_design` · `.vocabularies` · `.phrases` | [`audio/voice_clone.py`](examples/01_atomic_capabilities/audio/voice_clone.py) | | 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) | @@ -344,12 +376,27 @@ job = client.images.generate(model="wanx2.1-t2i-turbo", prompt="a cozy flower sh if job.succeeded: print(job.image_urls) +# HTTP speech synthesis: one POST, audio comes back as a temporary URL +synth = client.audio.tts.http.synthesize( + "Hello there", model="cosyvoice-v3-flash", voice="longanhuan", format="pcm", +) +print(synth.audio_url) + +# Streaming synthesis yields audio chunks as they are rendered +for chunk in client.audio.tts.http.stream( + "Hello there", model="cosyvoice-v3-flash", voice="longanhuan", format="pcm", +): + if chunk.audio: + ... # chunk.is_final marks the closing part + # A Model Studio application resp = client.apps.completions.create(app_id="YOUR_APP_ID", prompt="How do I make stew?") resp.text ``` -> **Audio is a preview.** The 2.0 `client.audio` surface is not final and may change; accessing it emits a `FutureWarning`. +### Async audio + +`AsyncClient` covers the HTTP-shaped audio resources — `await client.audio.asr.transcribe(...)` for batch transcription, `client.audio.tts.http.synthesize/.stream` for synthesis, and the management resources (`voices`, `voice_design`, `vocabularies`, `phrases`). The duplex WebSocket sessions (`asr.recognize`, `tts.connect`, `realtime.connect`) are sync-only by design; run them in a thread if you need them inside an async app. ### Realtime over one WebSocket @@ -362,20 +409,22 @@ session = client.audio.asr.recognize(model="fun-asr-realtime", connection=connec connection.close() ``` +Each task on the connection reads through a bounded queue (`task_queue_size=`, default 1024), so a slow consumer fails its own task with `errors.WsQueueCapacityExceededError` instead of holding audio in memory indefinitely or tearing down the shared socket. + ## Supported models | Category | Example models | 2.0 resource | |----------|----------------|--------------| -| Text generation | qwen-max, qwen-plus, qwen-flash | `client.chat` | +| Text generation | qwen-max, qwen-plus, qwen-turbo | `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` | +| Image generation | wanx2.1-t2i-turbo, wan2.6-image | `client.images` | +| Video generation | wanx2.1-t2v, wan2.7-i2v | `client.videos` | +| Speech synthesis (TTS) | cosyvoice-v3-flash, 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` | +| Omni (realtime) | qwen-omni-turbo-realtime | `client.audio.realtime` | For the latest model list, visit the [Model Studio model plaza](https://bailian.console.aliyun.com/). @@ -385,7 +434,7 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built - 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]"`. +- Install with the `acli` extra: `pip install "dashscope[acli]"`. Add `acli-voice` / `acli-camera` for voice and camera input, or `acli-all` for everything — see the [extras table](#installation). - Full walkthrough: [DashScope SDK Expert guide](https://help.aliyun.com/en/model-studio/dashscope-sdk-expert). ## Shell completion @@ -447,6 +496,33 @@ The 1.x module-level API is unchanged and still supported — you can adopt 2.0 | `dashscope.set_region(region, workspace_id)` | `dashscope.Client(region=..., workspace=...)` | | OpenAI-compatible via `dashscope.aigc.chat_completion.Completions` | `dashscope.Client(protocol="openai")` | +### Package layout and compatibility + +The 2.0 implementation sits directly under `dashscope`; the former `dashscope.v2` namespace is gone. AgentStudio and FineTune/Agentic RL each have a single business core under `dashscope.applications`, with two thin public facades on top: + +- the historical `dashscope.agentstudio` and `dashscope.finetune.agentic_rl` imports keep the 1.x clients, methods, return models and exception classes; +- `client.agentstudio` and `client.rl` expose the 2.0 resource APIs, the httpx transport and the `dashscope.errors` family. + +Historical deep modules such as `dashscope.aigc`, `dashscope.client.base_api`, `dashscope.files` and `dashscope.tokenizers` remain importable through lazy aliases to `dashscope.legacy_1x`. The local `get_tokenizer` utility therefore keeps its 1.x exception contract, while 2.0 remote token counting is the independent `client.tokenization` resource. The application cores are internal implementation paths — 2.0 callers should use the client resource tree. + +Concretely: the historical Agentic RL class still returns `FineTune` from `submit_job` and retains `cancel/list/get/delete/logs`, whereas the 2.0 job lifecycle is `client.rl.jobs` with errors from the SDK catalog. Likewise, standalone `dashscope.agentstudio.Client` keeps the old `AgentStudioError` hierarchy and its `raw` response attribute, while `client.agentstudio` raises `dashscope.errors` and exposes failed response data through `body`. + +> **Breaking (2.0 AgentStudio errors):** on the 2.0 surface — `client.agentstudio`, whose exceptions come from `dashscope.applications.agentstudio` — `ConflictError` is an alias of `APIStatusError` rather than a 409-specific subclass, so `except ConflictError` catches *every* status error (400/401/404/429/5xx), not just conflicts. Match on `err.status_code == 409` if you need conflict-only handling. The historical `dashscope.agentstudio.ConflictError` is untouched and still 409-specific. + +> **Breaking (`submit_job` / realtime WebSocket):** `submit_job` forwards extra keyword arguments straight to the transport, so the 1.x `request_timeout=` is renamed `timeout=`, and `workspace=` / `headers=` / `stream=` are no longer accepted (passing any of them raises `TypeError`). The realtime WebSocket errors (`WsStateError`, `WsQueueCapacityExceededError`, `WsBinaryOwnerConflictError`) now derive from `DashScopeError` instead of `RuntimeError`, so a bare `except RuntimeError` no longer catches them — catch `errors.DashScopeError` (or the specific class) instead. + +`dashscope.save_api_key` now comes from `dashscope._config`. It accepts an optional key-file path, expands `~`, creates missing parent directories, and writes UTF-8. Logs from the current client use the independent `dashscope._logging` logger hierarchy and do not reconfigure the shared `dashscope` root logger. + +### Development tests + +```bash +python -m pytest tests --ignore=tests/legacy_1x -q # current +python -m pytest tests/legacy_1x -q # archived compatibility +python -m pytest tests -q # everything +``` + +For current coverage, run `coverage run -m pytest tests --ignore=tests/legacy_1x`, then `coverage report -m`. + ## License This project is licensed under the Apache License (Version 2.0). diff --git a/README_zh.md b/README_zh.md index eb6ad822..ff02e8b9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -4,7 +4,7 @@ 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 是纯增量的。 +**2.0 版本**引入了资源风格的客户端 —— `dashscope.Client()`,其易用性对标 OpenAI SDK:一个客户端持有共享连接池,每项能力都以类型化资源的形式挂载其上(`client.chat`、`client.images`、`client.audio`……),响应是类型化对象而非裸字典,失败默认抛出异常。1.x 的完整公开接口仍保留在历史导入路径上,由 `dashscope.legacy_1x` 下的 Facade 实现;AgentStudio 与 Agentic RL 与 2.0 共享同一套业务核心,但各自的公开返回类型与错误契约保持独立。 ## 2.0 新特性 @@ -16,7 +16,7 @@ DashScope Python SDK 是[阿里云百炼(Model Studio)](https://help.aliyun. | 错误 | 手动检查 `resp.code` / `resp.message` | 默认抛出类型化的 `DashScopeError` 异常 *(完善中)* | | 流式 | 裸帧生成器 | `ChatStream`,提供 `.text_stream`、`.reasoning_stream`、`.get_final_message()` | | 协议 | DashScope 原生 + 单独的 compatible-mode 入口 | 一个客户端通过 `protocol=` 讲 `native`、`openai` 或 `anthropic` | -| 异步 | 独立的 `Aio*` 类 | `dashscope.AsyncClient()` 完整镜像同步接口 | +| 异步 | 独立的 `Aio*` 类 | `dashscope.AsyncClient()` 镜像同步接口(双工语音 WebSocket 会话除外) | | 连接 | 每次调用新建 session | 每个客户端一个 keep-alive 连接池,跨资源共享 | | 配置 | 模块级全局变量(`dashscope.api_key`、`set_region`) | 分层:参数 → `dashscope.settings` → 环境变量 → `~/.dashscope/config.toml` | @@ -26,11 +26,16 @@ DashScope Python SDK 是[阿里云百炼(Model Studio)](https://help.aliyun. pip install -U dashscope ``` -基础安装包含 SDK API 调用、`dashscope` CLI 命令、Agentic RL 微调和本地 tokenizer,要求 Python 3.9+。仅剩一个可选功能组: +基础安装包含 SDK API 调用、`dashscope` CLI 命令、Agentic RL 微调和本地 tokenizer,要求 Python 3.9+。可选功能组都属于内置的 AI 助手: | Extra | 提供的能力 | 安装命令 | |-------|----------|---------| | `acli` | 交互式 AI 助手(DashScope SDK Expert) | `pip install "dashscope[acli]"` | +| `acli-openai` | 让助手使用 OpenAI 兼容的模型服务方 | `pip install "dashscope[acli-openai]"` | +| `acli-anthropic` | 让助手使用 Anthropic 模型服务方 | `pip install "dashscope[acli-anthropic]"` | +| `acli-voice` | 助手的语音输入与语音播报 | `pip install "dashscope[acli-voice]"` | +| `acli-camera` | 助手的摄像头采集图片输入 | `pip install "dashscope[acli-camera]"` | +| `acli-all` | 以上全部(含 `acli`) | `pip install "dashscope[acli-all]"` | 从源码安装: @@ -120,9 +125,16 @@ client = dashscope.Client( protocol="native", # native | openai | anthropic timeout=300.0, max_retries=2, + base_url=None, # 整体覆盖端点;默认取 DASHSCOPE_BASE_URL + config_path=None, # 指定其他 config.toml;默认取 DASHSCOPE_CONFIG + protocol_options=None, # 透传给所选协议适配器的额外关键字参数 + raise_on_error=True, # False 时返回错误响应对象而不抛异常 + http_client=None, # 自备 httpx.Client(AsyncClient 用 httpx.AsyncClient) ) ``` +所有选项均为关键字参数(keyword-only)。 + 用上下文管理器可自动关闭连接池;也可用 `with_options()` 派生一个变体(未指定的选项会被继承;当连接级选项不变时共享同一连接池): ```python @@ -134,6 +146,8 @@ low_latency = client.with_options(timeout=10.0, max_retries=0) 每次调用还接受请求级的 `api_key=`、`workspace=`、`timeout=` 覆盖。 +需要代理、自定义连接数上限或 event hook?传入自己的 `http_client=httpx.Client(...)`(`AsyncClient` 对应 `httpx.AsyncClient`)。SDK 仍会在其之上完成鉴权、重试和绝对端点 URL 拼接,并且不会关闭不属于它的客户端。 + ### 协议 一个客户端可以讲三种线路协议,通过 `protocol=`(或 `DASHSCOPE_PROTOCOL` / `settings.protocol`)选择。无论哪种协议,请求/响应对象都保持同一套规范化的类型形态 —— 变化的只是线路格式: @@ -185,7 +199,9 @@ for text in stream.text_stream: # 内容增量 默认每帧是增量的。传入 `incremental_output=False` 可改为接收累积快照(SDK 会替你合并增量)。 -> **注意:** SDK **不会**自动重试流式请求。生产环境请在你自己的重试/退避逻辑中包裹流消费 —— 参见 [`examples/04_real_world_scenarios/production_ready_rag_harness.py`](examples/04_real_world_scenarios/production_ready_rag_harness.py)。 +思考模型的推理内容走 `.reasoning_stream`,合并后的消息里对应 `message.reasoning_content`。在 `protocol="anthropic"` 下,SDK 还会保留 `message.reasoning_signature` 以及原始的 `thinking` / `redacted_thinking` 块(位于 `message.reasoning_blocks`),因此把 assistant 消息原样回传即可延续 extended thinking。 + +> **注意:** 流式的重试只到握手为止。SDK 仅在第一个事件到达你之前重试连接失败和可重试的 HTTP 状态码;流一旦打开就绝不会重放,因此中途断流会以异常形式抛出。生产环境请在你自己的重试/退避逻辑中包裹流消费 —— 参见 [`examples/04_real_world_scenarios/production_ready_rag_harness.py`](examples/04_real_world_scenarios/production_ready_rag_harness.py)。 ### 错误 @@ -194,7 +210,7 @@ for text in stream.text_stream: # 内容增量 失败默认抛出类型化的 `DashScopeError`(非 2xx 的 HTTP 状态码,以及 HTTP 200 但业务报错,都会归入这里): ```python -from dashscope.v2 import errors +from dashscope import errors try: client.chat.completions.create(model="qwen-plus", messages=["hi"]) @@ -206,7 +222,12 @@ except errors.DashScopeError as exc: # 所有 SDK 错误的基类 print("请求失败:", exc.code, exc.message) ``` -异常层级包含 `APIConnectionError` / `APITimeoutError`(网络层),以及在 `APIStatusError` 之下的:`BadRequestError`、`AuthenticationError`、`PermissionDeniedError`、`NotFoundError`、`RequestTooLargeError`、`RateLimitError`、`InternalServerError`、`ServiceUnavailableError`、`GatewayTimeoutError`、`ContentFilterError`。 +所有异常都派生自 `DashScopeError`,分为两个族: + +- **服务端错误** —— `APIStatusError` 之下:`BadRequestError`、`AuthenticationError`、`PermissionDeniedError`、`NotFoundError`、`RequestTooLargeError`、`RateLimitError`、`InternalServerError`、`ServiceUnavailableError`、`GatewayTimeoutError`,以及 `ContentFilterError`(`BadRequestError` 的子类)。 +- **客户端侧错误** —— `SdkError` 之下:网络类的 `APIConnectionError` / `APITimeoutError`,以及 WebSocket 族的 `WsEventError`、`WsStateError`、`WsQueueCapacityExceededError`、`WsBinaryOwnerConflictError`。每个都带有稳定的 `sdk.*` 错误码。 + +HTTP 200 但响应体无法解析成约定信封时,会统一报为 `InternalServerError`,而不是把解析器的异常抛给调用方。 想内联检查错误而不捕获异常?用 `raise_on_error=False` 构建客户端 —— 失败会以正常响应对象返回,带 `is_error=True`、`code`、`message`、`status_code`: @@ -257,11 +278,19 @@ dashscope.settings.log_level = "INFO" | `DASHSCOPE_WORKSPACE` | 业务空间 ID(端点子域名) | —— | | `DASHSCOPE_REGION` | 地域 | `cn-beijing` | | `DASHSCOPE_PROTOCOL` | `native` / `openai` / `anthropic` | `native` | -| `DASHSCOPE_BASE_URL` | 完全覆盖端点(逃生舱) | —— | +| `DASHSCOPE_BASE_URL` | 完全覆盖 HTTP 端点(逃生舱) | —— | +| `DASHSCOPE_HTTP_BASE_URL` | 1.x 的同义覆盖项,作为兜底仍然生效 | —— | +| `DASHSCOPE_WEBSOCKET_BASE_URL` | 覆盖 WebSocket 端点 | 由地域/业务空间推导 | | `DASHSCOPE_TIMEOUT` | 请求超时(秒) | `300` | | `DASHSCOPE_MAX_RETRIES` | 自动重试次数 | `2` | +| `DASHSCOPE_HTTP_MAX_CONNECTIONS` | 连接池大小 | `100` | +| `DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS` | 连接池保持的 keep-alive 连接数 | `20` | +| `DASHSCOPE_HTTP_KEEPALIVE_EXPIRY` | keep-alive 过期时间(秒) | `5.0` | | `DASHSCOPE_LOG` | 日志级别 | `WARNING` | | `DASHSCOPE_CONFIG` | `config.toml` 路径 | `~/.dashscope/config.toml` | +| `DASHSCOPE_API_KEY_FILE_PATH` | 1.x key 文件路径(优先级最低的鉴权层) | `~/.dashscope/api_key` | + +三个 `DASHSCOPE_HTTP_*` 连接池变量同样可以在 `settings` 和 `config.toml` 中以 `http_max_connections`、`http_max_keepalive_connections`、`http_keepalive_expiry` 设置。传入自备的 `http_client=` 时,以它为准,上述配置全部被忽略。 **配置文件**(`~/.dashscope/config.toml`): @@ -302,7 +331,7 @@ client = dashscope.Client(region="ap-southeast-1", workspace="ws-xxx123") ## 能力一览 -每项能力都是客户端上的一个资源。同步与异步客户端暴露相同的接口(异步方法用 `await` 调用)。 +每项能力都是客户端上的一个资源。同步与异步客户端暴露相同的接口(异步方法用 `await` 调用)—— 只有一个例外:双工 WebSocket 语音会话按设计仅提供同步版本,见下文。 | 能力 | 2.0 资源 | 示例 | |------------|--------------|---------| @@ -315,7 +344,10 @@ client = dashscope.Client(region="ap-southeast-1", workspace="ws-xxx123") | 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/) | +| 语音识别(ASR) | 录音文件批量:`client.audio.asr.transcribe(...)` / `.jobs`;实时:`client.audio.asr.recognize(...)` | [`audio/asr_file.py`](examples/01_atomic_capabilities/audio/asr_file.py) · [`audio/asr_websocket.py`](examples/01_atomic_capabilities/audio/asr_websocket.py) | +| 语音合成(TTS) | HTTP:`client.audio.tts.http.synthesize(...)` / `.stream(...)`;WebSocket:`client.audio.tts.connect(...)` | [`audio/tts_http.py`](examples/01_atomic_capabilities/audio/tts_http.py) · [`audio/tts_websocket.py`](examples/01_atomic_capabilities/audio/tts_websocket.py) | +| 全模态实时对话 | `client.audio.realtime.connect(...)` | [`audio/realtime_omni.py`](examples/01_atomic_capabilities/audio/realtime_omni.py) | +| 音色与词表管理 | `client.audio.voices` · `.voice_design` · `.vocabularies` · `.phrases` | [`audio/voice_clone.py`](examples/01_atomic_capabilities/audio/voice_clone.py) | | 会议与音频理解(通义听悟 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) | @@ -344,12 +376,27 @@ job = client.images.generate(model="wanx2.1-t2i-turbo", prompt="一间温馨的 if job.succeeded: print(job.image_urls) +# HTTP 语音合成:一次 POST,音频以临时下载地址返回 +synth = client.audio.tts.http.synthesize( + "你好,世界", model="cosyvoice-v3-flash", voice="longanhuan", format="pcm", +) +print(synth.audio_url) + +# 流式合成按渲染进度返回音频分片 +for chunk in client.audio.tts.http.stream( + "你好,世界", model="cosyvoice-v3-flash", voice="longanhuan", format="pcm", +): + if chunk.audio: + ... # chunk.is_final 标记结束分片 + # 百炼应用 resp = client.apps.completions.create(app_id="YOUR_APP_ID", prompt="土豆炖猪脚怎么做?") resp.text ``` -> **语音为预览版。** 2.0 的 `client.audio` 接口尚未定型,可能变化;访问它会发出 `FutureWarning`。 +### 异步语音 + +`AsyncClient` 覆盖 HTTP 形态的语音资源 —— 批量转写 `await client.audio.asr.transcribe(...)`、合成 `client.audio.tts.http.synthesize/.stream`,以及管理类资源(`voices`、`voice_design`、`vocabularies`、`phrases`)。双工 WebSocket 会话(`asr.recognize`、`tts.connect`、`realtime.connect`)按设计只有同步版本;若需在异步应用中使用,请放到线程里运行。 ### 单条 WebSocket 上的实时能力 @@ -362,20 +409,22 @@ session = client.audio.asr.recognize(model="fun-asr-realtime", connection=connec connection.close() ``` +连接上每个任务都通过有界队列读取(`task_queue_size=`,默认 1024):消费过慢时只会让该任务以 `errors.WsQueueCapacityExceededError` 失败,既不会无限占用内存缓存音频,也不会拖垮共享的 socket。 + ## 支持的模型 | 类别 | 示例模型 | 2.0 资源 | |----------|----------------|--------------| -| 文本生成 | qwen-max、qwen-plus、qwen-flash | `client.chat` | +| 文本生成 | qwen-max、qwen-plus、qwen-turbo | `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` | +| 图像生成 | wanx2.1-t2i-turbo、wan2.6-image | `client.images` | +| 视频生成 | wanx2.1-t2v、wan2.7-i2v | `client.videos` | +| 语音合成(TTS) | cosyvoice-v3-flash、cosyvoice-v1、qwen-tts | `client.audio.tts` | | 语音识别(ASR) | fun-asr-realtime、paraformer-v1 | `client.audio.asr` | -| 全模态(实时) | qwen-omni-realtime | `client.audio.realtime` | +| 全模态(实时) | qwen-omni-turbo-realtime | `client.audio.realtime` | 最新模型列表请访问[百炼模型广场](https://bailian.console.aliyun.com/)。 @@ -385,7 +434,7 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 - 直接运行 `dashscope`(不带参数)即可启动助手。首次运行时会提示安装知识包(按领域划分的速查技能:文本、多模态、语音、检索、微调、Agent、CLI),使助手的指导来自 SDK 的公开接口 —— 参数、输出、错误码 —— 而无需阅读源码。 - 直接提问代替翻文档 —— 如 `dashscope expert "如何流式输出对话结果"`(一次性提问后退出)。在助手内输入 `/help` 查看可用命令;`dashscope expert --help` 查看全部形式。 -- 通过 `acli` extra 安装:`pip install "dashscope[acli]"`。 +- 通过 `acli` extra 安装:`pip install "dashscope[acli]"`。需要语音/摄像头输入可加装 `acli-voice` / `acli-camera`,或直接用 `acli-all` 装齐 —— 详见[安装](#安装)一节的 extra 表格。 - 完整使用指南:[DashScope SDK Expert 文档](https://help.aliyun.com/zh/model-studio/dashscope-sdk-expert)。 ## Shell 命令补全 @@ -447,6 +496,60 @@ examples/ | `dashscope.set_region(region, workspace_id)` | `dashscope.Client(region=..., workspace=...)` | | OpenAI 兼容通过 `dashscope.aigc.chat_completion.Completions` | `dashscope.Client(protocol="openai")` | +### 包布局与兼容策略 + +2.0 实现直接位于 `dashscope` 根包,原 `dashscope.v2` 命名空间已移除。 +AgentStudio 与 FineTune/Agentic RL 的唯一业务核心位于 +`dashscope.applications`,其上保留两套薄 Facade: + +- 历史 `dashscope.agentstudio` 与 `dashscope.finetune.agentic_rl` 路径继续 + 提供 1.x 的方法、返回类型和旧异常; +- `client.agentstudio` 与 `client.rl` 提供 2.0 资源接口、httpx 传输和 + `dashscope.errors` 新异常。 + +`dashscope.aigc`、`dashscope.client.base_api`、`dashscope.files`、 +`dashscope.tokenizers` 等其他旧路径继续通过懒加载别名指向 +`dashscope.legacy_1x`。本地 `get_tokenizer` 因此保持 1.x 异常契约;2.0 的远程 +分词计数由独立的 `client.tokenization` 资源提供。应用核心目录属于内部实现, +2.0 业务代码应通过主 Client 资源树调用。 + +历史 Agentic RL 的 `submit_job` 仍返回 `FineTune`,并保留 +`cancel/list/get/delete/logs`;2.0 的任务生命周期位于 `client.rl.jobs`, +其工作流错误使用新的 SDK 错误目录。历史 `dashscope.agentstudio.Client` +继续抛旧 `AgentStudioError` 并保留 `raw`,而 `client.agentstudio` 使用新 +errors,失败响应通过 `body` 暴露。 + +> **破坏性变更(2.0 AgentStudio 错误):** 在 2.0 接口上 —— +> `client.agentstudio`,其异常来自 `dashscope.applications.agentstudio` —— +> `ConflictError` 现在是 `APIStatusError` 的别名,而不再是仅对应 409 的 +> 子类,因此 `except ConflictError` 会捕获**所有**状态错误 +> (400/401/404/429/5xx),而不仅是冲突。若只关心冲突,请改用 +> `err.status_code == 409` 判断。历史路径 `dashscope.agentstudio.ConflictError` +> 未受影响,仍只对应 409。 + +> **破坏性变更(`submit_job` / realtime WebSocket):** `submit_job` 会把 +> 额外关键字参数直接转发给传输层,因此 1.x 的 `request_timeout=` 改名为 +> `timeout=`,且不再接受 `workspace=` / `headers=` / `stream=`(传入任一 +> 都会抛 `TypeError`)。realtime WebSocket 错误(`WsStateError`、 +> `WsQueueCapacityExceededError`、`WsBinaryOwnerConflictError`)现在继承自 +> `DashScopeError` 而非 `RuntimeError`,因此裸 `except RuntimeError` 不再能 +> 捕获它们 —— 请改捕获 `errors.DashScopeError`(或具体的错误类)。 + +`dashscope.save_api_key` 现在来自 `dashscope._config`,仍接受可选的 key +文件路径,并会展开 `~`、创建缺失的父目录、按 UTF-8 写入。当前客户端使用 +独立的 `dashscope._logging` logger 层级,不会重新配置共享的 `dashscope` +根 logger。 + +### 开发测试 + +```bash +python -m pytest tests --ignore=tests/legacy_1x -q # 当前实现 +python -m pytest tests/legacy_1x -q # 归档兼容套件 +python -m pytest tests -q # 全量 +``` + +当前实现的 coverage 使用 `coverage run -m pytest tests --ignore=tests/legacy_1x`,随后执行 `coverage report -m`。 + ## 许可证 本项目采用 Apache License (Version 2.0) 许可证。 diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 99375a16..450c9eb3 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -15,26 +15,37 @@ category=Warning, ) +from dashscope.legacy_1x._shim import install as _install_legacy_aliases + +_install_legacy_aliases() + from dashscope.version import __version__ -from dashscope.aigc.code_generation import CodeGeneration -from dashscope.aigc.conversation import Conversation, History, HistoryItem -from dashscope.aigc.generation import AioGeneration, Generation -from dashscope.aigc.image_synthesis import ImageSynthesis -from dashscope.aigc.multimodal_conversation import ( +from dashscope.legacy_1x.aigc.code_generation import CodeGeneration +from dashscope.legacy_1x.aigc.conversation import ( + Conversation, + History, + HistoryItem, +) +from dashscope.legacy_1x.aigc.generation import AioGeneration, Generation +from dashscope.legacy_1x.aigc.image_synthesis import ImageSynthesis +from dashscope.legacy_1x.aigc.multimodal_conversation import ( MultiModalConversation, AioMultiModalConversation, ) -from dashscope.aigc.video_synthesis import VideoSynthesis -from dashscope.app.application import Application -from dashscope.audio.asr.transcription import Transcription -from dashscope.audio.http_tts.http_speech_synthesizer import ( +from dashscope.legacy_1x.aigc.video_synthesis import VideoSynthesis +from dashscope.legacy_1x.app.application import Application +from dashscope.legacy_1x.audio.asr.transcription import Transcription +from dashscope.legacy_1x.audio.http_tts.http_speech_synthesizer import ( HttpSpeechSynthesizer, ) -from dashscope.audio.tts.speech_synthesizer import SpeechSynthesizer -from dashscope.api_entities.aio_session import close_shared_aio_session -from dashscope.api_entities.http_request import close_shared_sync_session -from dashscope.common.api_key import save_api_key -from dashscope.common.env import ( +from dashscope.legacy_1x.audio.tts.speech_synthesizer import SpeechSynthesizer +from dashscope.legacy_1x.api_entities.aio_session import ( + close_shared_aio_session, +) +from dashscope.legacy_1x.api_entities.http_request import ( + close_shared_sync_session, +) +from dashscope.legacy_1x.common.env import ( MAAS_REGIONS, api_key, api_key_file_path, @@ -43,33 +54,40 @@ base_websocket_api_url, validate_workspace_id, ) -from dashscope.finetune.deployments import Deployments -from dashscope.finetune.finetunes import FineTunes -from dashscope.embeddings.batch_text_embedding import BatchTextEmbedding -from dashscope.embeddings.batch_text_embedding_response import ( +from dashscope.legacy_1x.finetune.deployments import Deployments +from dashscope.legacy_1x.finetune.finetunes import FineTunes +from dashscope.legacy_1x.embeddings.batch_text_embedding import ( + BatchTextEmbedding, +) +from dashscope.legacy_1x.embeddings.batch_text_embedding_response import ( BatchTextEmbeddingResponse, ) -from dashscope.embeddings.multimodal_embedding import ( +from dashscope.legacy_1x.embeddings.multimodal_embedding import ( MultiModalEmbedding, MultiModalEmbeddingItemAudio, MultiModalEmbeddingItemImage, MultiModalEmbeddingItemText, AioMultiModalEmbedding, ) -from dashscope.embeddings.text_embedding import TextEmbedding -from dashscope.files import Files -from dashscope.models import Models -from dashscope.nlp.understanding import Understanding -from dashscope.rerank import AioTextReRank, TextReRank -from dashscope.tokenizers import ( +from dashscope.legacy_1x.embeddings.text_embedding import TextEmbedding +from dashscope.legacy_1x.files import Files +from dashscope.legacy_1x.models import Models +from dashscope.legacy_1x.nlp.understanding import Understanding +from dashscope.legacy_1x.rerank import AioTextReRank, TextReRank +from dashscope.legacy_1x.tokenizers import ( Tokenization, Tokenizer, get_tokenizer, list_tokenizers, ) -# dashscope 2.0 public surface (implementation lives in dashscope/v2/). -from dashscope.v2 import AsyncClient, Client, WsConnection, chat, settings +# dashscope 2.0 public surface. +from dashscope import errors +from dashscope._config import Config, Settings, save_api_key, settings +from dashscope._quickstart import chat +from dashscope.client import AsyncClient, Client +from dashscope.errors import DashScopeError, ErrorCode +from dashscope.transport.ws_connection import WsConnection, WsTask # cn-beijing defaults to the legacy dashscope.aliyuncs.com endpoints; # its MaaS URLs are only reachable via an explicit set_region() call. @@ -114,44 +132,77 @@ def set_region(region: str, workspace_id: str = None): base_websocket_api_url = f"wss://{host}/api-ws/v1/inference" -# The Assistants API (dashscope.assistants / dashscope.threads) is deprecated. +# The Assistants API (dashscope.legacy_1x.assistants / +# dashscope.legacy_1x.threads) is deprecated. # Resolve its names lazily (PEP 562) so a plain `import dashscope` does not emit # the DeprecationWarning for code that never touches them; accessing any of these -# names imports dashscope.assistants, which warns once. +# names imports dashscope.legacy_1x.assistants, which warns once. _DEPRECATED_LAZY_IMPORTS = { - "Assistant": "dashscope.assistants", - "AssistantList": "dashscope.assistants", - "Assistants": "dashscope.assistants", - "AssistantFile": "dashscope.assistants", - "DeleteResponse": "dashscope.assistants", - "MessageFile": "dashscope.threads", - "Messages": "dashscope.threads", - "Run": "dashscope.threads", - "RunList": "dashscope.threads", - "Runs": "dashscope.threads", - "RunStep": "dashscope.threads", - "RunStepList": "dashscope.threads", - "Steps": "dashscope.threads", - "Thread": "dashscope.threads", - "ThreadMessage": "dashscope.threads", - "ThreadMessageList": "dashscope.threads", - "Threads": "dashscope.threads", + "Assistant": "dashscope.legacy_1x.assistants", + "AssistantList": "dashscope.legacy_1x.assistants", + "Assistants": "dashscope.legacy_1x.assistants", + "AssistantFile": "dashscope.legacy_1x.assistants", + "DeleteResponse": "dashscope.legacy_1x.assistants", + "MessageFile": "dashscope.legacy_1x.threads", + "Messages": "dashscope.legacy_1x.threads", + "Run": "dashscope.legacy_1x.threads", + "RunList": "dashscope.legacy_1x.threads", + "Runs": "dashscope.legacy_1x.threads", + "RunStep": "dashscope.legacy_1x.threads", + "RunStepList": "dashscope.legacy_1x.threads", + "Steps": "dashscope.legacy_1x.threads", + "Thread": "dashscope.legacy_1x.threads", + "ThreadMessage": "dashscope.legacy_1x.threads", + "ThreadMessageList": "dashscope.legacy_1x.threads", + "Threads": "dashscope.legacy_1x.threads", } +_LEGACY_LAZY_MODULES = frozenset( + { + "aigc", + "agentstudio", + "api_entities", + "app", + "applications", + "assistants", + "audio", + "cli", + "common", + "embeddings", + "files", + "finetune", + "io", + "model", + "models", + "multimodal", + "nlp", + "protocol", + "rerank", + "threads", + "tokenizers", + "utils", + }, +) + def __getattr__(name): module_name = _DEPRECATED_LAZY_IMPORTS.get(name) - if module_name is None: + if module_name is not None: + value = getattr(importlib.import_module(module_name), name) + elif name in _LEGACY_LAZY_MODULES: + value = importlib.import_module(f"dashscope.{name}") + else: raise AttributeError( f"module {__name__!r} has no attribute {name!r}", ) - value = getattr(importlib.import_module(module_name), name) globals()[name] = value return value def __dir__(): - return sorted(set(globals()) | set(_DEPRECATED_LAZY_IMPORTS)) + return sorted( + set(globals()) | set(_DEPRECATED_LAZY_IMPORTS) | _LEGACY_LAZY_MODULES, + ) __all__ = [ @@ -218,8 +269,14 @@ def __dir__(): # dashscope 2.0 "AsyncClient", "Client", + "Config", + "DashScopeError", + "ErrorCode", + "Settings", "WsConnection", + "WsTask", "chat", + "errors", "set_region", ] diff --git a/dashscope/__init__.pyi b/dashscope/__init__.pyi index 538a6e22..46a54671 100644 --- a/dashscope/__init__.pyi +++ b/dashscope/__init__.pyi @@ -14,11 +14,18 @@ from typing import ( # --------------------------------------------------------------------------- # 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 +from dashscope import errors as errors +from dashscope._config import Config as Config +from dashscope._config import Settings as Settings +from dashscope._config import save_api_key as save_api_key +from dashscope._config import settings as settings +from dashscope._quickstart import chat as chat +from dashscope.client import AsyncClient as AsyncClient +from dashscope.client import Client as Client +from dashscope.errors import DashScopeError as DashScopeError +from dashscope.errors import ErrorCode as ErrorCode +from dashscope.transport.ws_connection import WsConnection as WsConnection +from dashscope.transport.ws_connection import WsTask as WsTask # --------------------------------------------------------------------------- # Config @@ -29,11 +36,34 @@ base_compatible_api_url: str base_http_api_url: str base_websocket_api_url: str -def save_api_key(api_key: str, file_path: Optional[str] = ...) -> None: ... def close_shared_aio_session() -> None: ... def close_shared_sync_session() -> None: ... def set_region(region: str, workspace_id: Optional[str] = ...) -> None: ... +# Lazily resolved compatibility modules. +aigc: Any +agentstudio: Any +api_entities: Any +app: Any +applications: Any +assistants: Any +audio: Any +cli: Any +common: Any +embeddings: Any +files: Any +finetune: Any +io: Any +model: Any +models: Any +multimodal: Any +nlp: Any +protocol: Any +rerank: Any +threads: Any +tokenizers: Any +utils: Any + # --------------------------------------------------------------------------- # Response types # --------------------------------------------------------------------------- @@ -41,7 +71,10 @@ class Message: role: str content: Union[str, List[Any]] def __init__( - self, role: str, content: Union[str, List[Any]] = None, **kwargs: Any + self, + role: str, + content: Optional[Union[str, List[Any]]] = None, + **kwargs: Any, ) -> None: ... class DashScopeAPIResponse: diff --git a/dashscope/v2/_config.py b/dashscope/_config.py similarity index 92% rename from dashscope/v2/_config.py rename to dashscope/_config.py index ad8bc0be..cfd8580d 100644 --- a/dashscope/v2/_config.py +++ b/dashscope/_config.py @@ -136,8 +136,8 @@ _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. +# Key-file auth (save_api_key, below): 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" @@ -203,6 +203,20 @@ def _validate_region(region: str) -> str: return region +def _validate_base_url(value: Optional[str]) -> Optional[str]: + """Reject a non-string base_url where it is configured. + + Left alone it reaches ``base_url.rstrip("/")`` in the transport and + surfaces as an ``AttributeError`` naming a method the caller never + wrote, far from the bad argument. + """ + if value is not None and not isinstance(value, str): + raise TypeError( + f"base_url must be a string, got {type(value).__name__}", + ) + return value + + def _protocol_default_base_url( protocol: str, workspace: Optional[str], @@ -321,7 +335,7 @@ def clear(self) -> None: self._explicit.clear() if had_log_level: # pylint: disable=import-outside-toplevel - from dashscope.v2._logging import reset_log_level + from dashscope._logging import reset_log_level reset_log_level() @@ -349,6 +363,7 @@ def base_url(self) -> str: @base_url.setter def base_url(self, value: Optional[str]) -> None: + value = _validate_base_url(value) with self._lock: self._explicit["base_url"] = value @@ -496,7 +511,7 @@ def log_level(self) -> Optional[str]: @log_level.setter def log_level(self, value: str) -> None: # pylint: disable=import-outside-toplevel - from dashscope.v2._logging import set_log_level + from dashscope._logging import set_log_level set_log_level(value) with self._lock: @@ -528,7 +543,7 @@ def _load_config_file(path: Path) -> Dict[str, Any]: 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`` -- + Same order as ``dashscope.legacy_1x.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 @@ -542,6 +557,27 @@ def _api_key_from_file() -> Optional[str]: return None +def save_api_key( + api_key: str, + api_key_file_path: Optional[str] = None, +) -> None: + """Store an api key in the key file ``_api_key_from_file`` reads back. + + Without an explicit path this writes ``~/.dashscope/api_key``, the same + fixed location the 1.x ``dashscope.save_api_key`` uses, so a key stored + through either stack is picked up by both. As in 1.x, + ``DASHSCOPE_API_KEY_FILE_PATH`` redirects only the read, not the write. + """ + target = ( + _DEFAULT_API_KEY_FILE_PATH + if api_key_file_path is None + else api_key_file_path + ) + path = Path(target).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(api_key, encoding="utf-8") + + def _pick( explicit: Any, settings_value: Any, @@ -686,12 +722,15 @@ def resolve_config( ), ) - 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, + configured_base_url = _validate_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, diff --git a/dashscope/v2/_error_registry.py b/dashscope/_error_registry.py similarity index 81% rename from dashscope/v2/_error_registry.py rename to dashscope/_error_registry.py index 9c2675fa..36cda129 100644 --- a/dashscope/v2/_error_registry.py +++ b/dashscope/_error_registry.py @@ -4,8 +4,8 @@ from dataclasses import dataclass, field from typing import Dict, List, Optional -__version__ = "1.0.6" -__commit__ = "56b7208" +__version__ = "1.0.11" +__commit__ = "84e5f33" __snapshot__ = False @@ -208,6 +208,56 @@ def format_message( vars=[], ) +SDK_CONNECTION_ERROR = SdkErrorDef( + name="sdk.ConnectionError", + message="Connection failed: {detail}.", + solution=( + "Check network connectivity, proxy and firewall " + "settings, and confirm the service endpoint is " + "reachable. Retry the request; persistent failures " + "usually indicate a local network problem." + ), + allow_retry=True, + vars=["detail"], +) + +SDK_CONNECTION_INTERRUPTED = SdkErrorDef( + name="sdk.ConnectionInterrupted", + message="Interrupted while waiting: {detail}.", + solution=( + "The thread was interrupted, typically because the " + "caller cancelled the operation. Re-issue the request " + "from a fresh thread if the cancellation was not " + "intentional." + ), + allow_retry=False, + vars=["detail"], +) + +SDK_TIMEOUT = SdkErrorDef( + name="sdk.Timeout", + message="Operation timed out: {detail}.", + solution=( + "The server or gateway did not respond within the " + "deadline. Check network latency and service status, " + "then retry; consider raising the timeout for " + "long-running operations." + ), + allow_retry=True, + vars=["detail"], +) + +SDK_IO_ERROR = SdkErrorDef( + name="sdk.IoError", + message="Local I/O failed: {detail}.", + solution=( + "Verify the file path exists and is readable, and that " + "the process has the required filesystem permissions." + ), + allow_retry=False, + vars=["detail"], +) + SDK_AGENTIC_RL_FUNCTION_REGISTRATION_FAILED = SdkErrorDef( name="sdk.agentic_rl.FunctionRegistrationFailed", message="Function component registration failed: {inner_code}.", @@ -436,12 +486,14 @@ def format_message( SDK_AGENTIC_RL_FUNCTION_LAYER_ERROR = SdkErrorDef( name="sdk.agentic_rl.FunctionLayerError", - message="Creating a layer of function failed.", + message="The function dependency layer is not usable.", 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." + "The dependency layer could not be created, or its build failed, " + "timed out, or its status could not be queried. For a build " + "failure, check that requirements.txt or setup.py is valid and " + "every dependency is installable, then review the layer build " + "logs. For a timeout, raise FC_LAYER_STATUS_TIMEOUT to allow more " + "time. For a status query error, retry once connectivity recovers." ), allow_retry=True, vars=[], @@ -594,6 +646,69 @@ def format_message( vars=[], ) +SDK_WS_PROTOCOL_ERROR = SdkErrorDef( + name="sdk.ws.ProtocolError", + message="WebSocket protocol violation: {detail}.", + solution=( + "The gateway sent a frame that violates the task protocol " + "(missing header, unknown task_id, or an unexpected event). " + "Not retryable; if it persists, report it at " + "https://github.com/dashscope/dashscope-sdk-python/issues " + "with the frame detail." + ), + allow_retry=False, + vars=["detail"], +) + +SDK_WS_UPSTREAM_QUEUE_FULL = SdkErrorDef( + name="sdk.ws.UpstreamQueueFull", + message="WebSocket upstream send queue is full.", + solution=( + "The client is producing frames faster than the network " + "drains them (the send buffer is full). Slow the producer " + "down — pace audio frames to real time instead of buffering " + "unboundedly." + ), + allow_retry=False, + vars=[], +) + +SDK_WS_QUEUE_CAPACITY_EXCEEDED = SdkErrorDef( + name="sdk.ws.QueueCapacityExceeded", + message="A task receive queue exceeded its configured capacity.", + solution=( + "The consumer is slower than the producer and the bounded " + "receive queue overflowed. Drain events faster or raise the " + "capacity; frames are never dropped silently." + ), + allow_retry=False, + vars=[], +) + +SDK_WS_BINARY_OWNER_CONFLICT = SdkErrorDef( + name="sdk.ws.BinaryOwnerConflict", + message="Binary stream ownership conflict: {detail}.", + solution=( + "A shared WebSocket connection supports at most one binary " + "uplink (ASR) and one binary downlink (TTS). Use a separate " + "connection for a second binary task." + ), + allow_retry=False, + vars=["detail"], +) + +SDK_WS_ILLEGAL_STATE = SdkErrorDef( + name="sdk.ws.IllegalState", + message="Illegal WebSocket task state: {detail}.", + solution=( + "The operation is not valid for the task's current state " + "(duplicate task_id, send after close, receive on a closed " + "session). Check the session lifecycle." + ), + allow_retry=False, + vars=["detail"], +) + # -- Catalogs ------------------------------------------------------ PUBLIC_ERRORS = [ @@ -616,6 +731,10 @@ def format_message( ] SDK_ERRORS = [ SDK_INVALID_API_KEY, + SDK_CONNECTION_ERROR, + SDK_CONNECTION_INTERRUPTED, + SDK_TIMEOUT, + SDK_IO_ERROR, SDK_AGENTIC_RL_FUNCTION_REGISTRATION_FAILED, SDK_AGENTIC_RL_DATASETS_UPLOAD_FAILED, SDK_AGENTIC_RL_DUPLICATE_FUNCTION_NAMES, @@ -647,4 +766,9 @@ def format_message( SDK_AGENTSTUDIO_API_TIMEOUT_ERROR, SDK_AGENTSTUDIO_STREAM_ERROR, SDK_AGENTSTUDIO_STREAM_CLOSED_ERROR, + SDK_WS_PROTOCOL_ERROR, + SDK_WS_UPSTREAM_QUEUE_FULL, + SDK_WS_QUEUE_CAPACITY_EXCEEDED, + SDK_WS_BINARY_OWNER_CONFLICT, + SDK_WS_ILLEGAL_STATE, ] diff --git a/dashscope/v2/_logging.py b/dashscope/_logging.py similarity index 99% rename from dashscope/v2/_logging.py rename to dashscope/_logging.py index bfb39d54..1ecfc2e8 100644 --- a/dashscope/v2/_logging.py +++ b/dashscope/_logging.py @@ -15,7 +15,7 @@ from typing import Any, Mapping, Optional LOG_ENV = "DASHSCOPE_LOG" -ROOT_NAME = "dashscope.v2" +ROOT_NAME = "dashscope._logging" _LEVELS = { "debug": logging.DEBUG, diff --git a/dashscope/_quickstart.py b/dashscope/_quickstart.py new file mode 100644 index 00000000..bba2a10e --- /dev/null +++ b/dashscope/_quickstart.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Module-level quickstart entry points backed by a managed Client.""" + +from __future__ import annotations + +import atexit +import threading +from typing import Any, Dict, Optional, Set + +from dashscope._config import Config, resolve_config +from dashscope.client import Client +from dashscope.types.completions import ChatCompletion + +__all__ = ["chat"] + +_lock = threading.Lock() +_client: Optional[Client] = None +_active_clients: Dict[Client, int] = {} +_retired_clients: Set[Client] = set() + + +def _new_client(config: Config) -> Client: + """Construct the quickstart client from one resolved config snapshot.""" + return Client( + api_key=config.api_key, + base_url=config.configured_base_url, + workspace=config.workspace, + region=config.region, + protocol=config.protocol, + timeout=config.timeout, + max_retries=config.max_retries, + ) + + +def _safe_close(client: Client) -> None: + try: + client.close() + except Exception: + # Cleanup must never hide the result of the user's chat call or make + # interpreter shutdown noisy. + pass + + +def _select_client(*, acquire: bool) -> Client: + """Return a client for the current settings, replacing stale instances.""" + global _client + desired = resolve_config() + close_after_unlock: Optional[Client] = None + with _lock: + current = _client + if current is None or current.config != desired: + replacement = _new_client(desired) + _client = replacement + if current is not None: + if _active_clients.get(current, 0): + _retired_clients.add(current) + else: + close_after_unlock = current + current = replacement + if acquire: + _active_clients[current] = _active_clients.get(current, 0) + 1 + if close_after_unlock is not None: + _safe_close(close_after_unlock) + return current + + +def _get_client() -> Client: + """Return the configuration-aware singleton (private test hook).""" + return _select_client(acquire=False) + + +def _release_client(client: Client) -> None: + close_after_unlock = False + with _lock: + active = _active_clients.get(client, 0) + if active <= 1: + _active_clients.pop(client, None) + if client in _retired_clients: + _retired_clients.remove(client) + close_after_unlock = True + else: + _active_clients[client] = active - 1 + if close_after_unlock: + _safe_close(client) + + +def _close_clients() -> None: + """Best-effort cleanup for the hidden quickstart connection pools.""" + global _client + with _lock: + clients = set(_active_clients) | _retired_clients + if _client is not None: + clients.add(_client) + _client = None + _active_clients.clear() + _retired_clients.clear() + for client in clients: + _safe_close(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``. Global settings and environment + changes are re-resolved before each call; an in-flight call keeps its + previous client alive until that call finishes. + """ + client = _select_client(acquire=True) + try: + return client.chat.completions.create( + model=model, + messages=[prompt], + **kwargs, + ) + finally: + _release_client(client) + + +atexit.register(_close_clients) diff --git a/dashscope/_shared/__init__.py b/dashscope/_shared/__init__.py new file mode 100644 index 00000000..2b1e5986 --- /dev/null +++ b/dashscope/_shared/__init__.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Neutral leaf layer shared by the 1.x API and the 2.0 implementation. + +What belongs here is anything the two stacks must agree on exactly once +per installed distribution: the process-wide session id, the mutable +client tag, the exception base class that ``except`` clauses on both +sides match against, and the validators that keep a request pointed at +``*.maas.aliyuncs.com``. Copying one of those into either stack would +silently split a single source of truth, so both stacks import from +here instead. + +The only thing this package reaches outside itself is +``dashscope.version``, read defensively by ``_sdk_ident``. That module +is a bare constant with no imports of its own, and it deliberately stays +where it is: ``setup.py`` and ``docs/source/conf.py`` ``exec()`` the +file to read the version *without* importing the package, and turning +it into a re-export of something here would drag the whole SDK and its +runtime dependencies into any build environment that only wanted a +string. + +What this package must never import is the 1.x business surface +(``client``, ``common``, ``finetune``, ``audio`` and the rest). +``common/utils`` reaches ``_sdk_ident`` while ``dashscope/__init__`` is +still executing, so an import back into that surface would close a cycle +through a partially initialized root. ``dashscope.version`` is safe +precisely because it has no imports of its own; the business modules +all have plenty. +""" diff --git a/dashscope/_shared/_errors.py b/dashscope/_shared/_errors.py new file mode 100644 index 00000000..d291c803 --- /dev/null +++ b/dashscope/_shared/_errors.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The one exception base class both stacks inherit from. + +``except dashscope.legacy_1x.common.error.DashScopeException`` has to catch errors +raised by the 1.x API classes *and* by the AgentStudio sub-SDK, which the +2.0 client mounts directly. That only works if every subclass in both +stacks derives from the same class object, so the base lives here rather +than in either stack: copying it would silently split the hierarchy and +let errors escape a caller's ``except`` clause. + +``dashscope.legacy_1x.common.error`` re-exports this name, so the 1.x import path +keeps working and keeps the identical class. +""" + + +class DashScopeException(Exception): + """Base class for every error the dashscope SDK raises.""" + + +__all__ = ["DashScopeException"] diff --git a/dashscope/_shared/_sdk_ident.py b/dashscope/_shared/_sdk_ident.py new file mode 100644 index 00000000..83fa7de5 --- /dev/null +++ b/dashscope/_shared/_sdk_ident.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""SDK identification headers shared by the 1.x API and the 2.0 package. + +``x-dashscope-sdk-client`` tells the backend which client and which +functional area issued a request, and ``x-dashscope-sdk-session-id`` +groups every request one interpreter process makes. + +The client segment is process-wide *mutable* state: entry points that +embed the SDK overwrite it (the ``dashscope`` CLI marks itself as +``python-cli`` via ``set_sdk_client``), and every caller in both stacks +must see the same value. Duplicating it would silently drop the override +for whichever stack held the copy, so it lives here -- one source of +truth, same reason as the session id in ``_shared._sdk_session``. + +The 2.0 transport builds its own header in +``dashscope.transport._ident`` because it appends a trailing +generation marker (``aigc`` -> ``aigc2``) that this unmarked 1.x shape +must not carry. Sub-SDKs that predate the marker -- AgentStudio, whose +``agentstudio`` tag the backend already reports on -- keep using this +module. + +``get_user_agent`` belongs here for the same reason as the two headers: +it is the other half of the request identity, and it must agree with +``get_sdk_headers`` on the version segment. It is the *1.x shape* +(``dashscope/; python/...; platform/...; processor/...``) and is +deliberately not shared with the 2.0 transport's own +``dashscope.transport.http.default_user_agent()`` +(``dashscope-v2-python/``). Those are two distinct backend +contracts, not two spellings of one value, so they must not be merged. + +Only the stdlib is imported at module level; ``dashscope.version`` is +pulled in lazily and defensively so this leaf stays below both stacks. +""" + +import os +import platform +from typing import Dict + +from dashscope._shared._sdk_session import sdk_session_id + +_SDK_CLIENT = "python-sdk" + +# Shared with the 2.0 transport so one process reports one +# x-dashscope-sdk-session-id across both stacks (see _shared._sdk_session; +# acli deliberately keeps its own, it is a one-way sync copy). +_SDK_SESSION_ID = sdk_session_id() + +__all__ = ["get_sdk_headers", "get_user_agent", "set_sdk_client"] + + +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 set_sdk_client(client: str) -> None: + """Override the client identifier in the x-dashscope-sdk-client header. + + Process-wide, used by entry points that embed the SDK (e.g. the + ``dashscope`` CLI marks itself as ``python-cli``). + """ + global _SDK_CLIENT # pylint: disable=global-statement + if client: + _SDK_CLIENT = client + + +def get_sdk_headers(module: str = "") -> Dict[str, str]: + """Structured SDK identification headers for backend statistics. + + Set DASHSCOPE_DISABLE_SDK_HEADERS=1 to omit them. + + Args: + module: Optional module name (e.g. agentstudio, rl, app). + """ + if os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): + return {} + parts = [_SDK_CLIENT, _VERSION] + if module: + parts.append(module) + return { + "x-dashscope-sdk-client": "/".join(parts), + "x-dashscope-sdk-session-id": _SDK_SESSION_ID, + } + + +def get_user_agent() -> str: + """The 1.x-shaped ``user-agent`` value. + + Kept byte-identical to the pre-move implementation: the version + segment is ``_VERSION`` here rather than a second read of + ``dashscope.version``, so it can never drift from the one in + ``get_sdk_headers``. + """ + try: + platform_ = ( + platform.platform().replace("\n", "").replace("\r", "").strip() + ) + except Exception: + platform_ = "unknown" + + try: + processor_ = ( + platform.processor().replace("\n", "").replace("\r", "").strip() + ) + except Exception: + processor_ = "unknown" + + ua = ( + f"dashscope/{_VERSION}; python/{platform.python_version()}; " + f"platform/{platform_}; " + f"processor/{processor_}" + ) + return ua diff --git a/dashscope/common/_sdk_session.py b/dashscope/_shared/_sdk_session.py similarity index 74% rename from dashscope/common/_sdk_session.py rename to dashscope/_shared/_sdk_session.py index c5fdea8e..044c9455 100644 --- a/dashscope/common/_sdk_session.py +++ b/dashscope/_shared/_sdk_session.py @@ -5,18 +5,18 @@ 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. +-- and the sub-SDKs owned by 2.0 (AgentStudio, agentic RL) build their +request headers from ``_shared._sdk_ident`` rather than from the v2 +transport. 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. +sync copy of the agenticCLI repo, so wiring it to this module would be +silently reverted by the next sync and cannot be upstreamed as written. """ from __future__ import annotations diff --git a/dashscope/_shared/_validation.py b/dashscope/_shared/_validation.py new file mode 100644 index 00000000..0190e5fa --- /dev/null +++ b/dashscope/_shared/_validation.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Host-label validation shared by the 1.x API and the 2.0 implementation. + +``workspace_id`` and ``region`` are interpolated into the host of the MaaS +endpoint, so both stacks must reject the same malformed values with the +same rule. Keeping the check in one leaf module means a value that is +safe for one stack is safe for the other, and neither stack has to import +the other to get it. + +Stdlib only, like the rest of ``dashscope._shared``. +""" + +import re + +# workspace_id and region become DNS labels of the MaaS endpoint host, so +# they must not contain characters (/, ?, #, @, :, whitespace, ...) that +# could break out of the host and redirect requests elsewhere. +# \A...\Z rather than ^...$: $ also matches before a trailing newline. +_WORKSPACE_ID_PATTERN = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z") +# RFC 1123 hostname label: 1-63 chars, no leading/trailing hyphen. Upper +# case is allowed because DNS is case-insensitive and httpx lowercases the +# host, so rejecting it would break working callers for no security gain. +_REGION_PATTERN = re.compile( + r"\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\Z", +) + +__all__ = ["validate_region", "validate_workspace_id"] + + +def validate_workspace_id(workspace_id): + """Validate that workspace_id is safe to use as a URL subdomain. + + Raises: + ValueError: If workspace_id contains characters outside + [A-Za-z0-9_-], is empty, or exceeds 64 characters. + """ + if not workspace_id or not _WORKSPACE_ID_PATTERN.fullmatch( + workspace_id, + ): + raise ValueError( + f"Invalid workspace_id {workspace_id!r}: must match " + "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$", + ) + return workspace_id + + +def validate_region(region): + """Validate that region is safe to use as a URL subdomain. + + Deliberately a hostname-label check and not a MAAS_REGIONS whitelist: + rejecting an unknown-but-well-formed region would break callers as soon + as a subpackage supports a region this list has not caught up with. + + Raises: + ValueError: If region is not a valid hostname label. + """ + if not region or not _REGION_PATTERN.fullmatch(region): + raise ValueError( + f"Invalid region {region!r}: must be a hostname label " + "(1-63 chars of [A-Za-z0-9-], not starting or ending " + "with '-')", + ) + return region diff --git a/dashscope/acli/agent.py b/dashscope/acli/agent.py index 1ca1e4fa..5bcd8171 100644 --- a/dashscope/acli/agent.py +++ b/dashscope/acli/agent.py @@ -514,18 +514,17 @@ def save_session(self) -> None: if not self.session_path: return try: + from dashscope.acli.utils.paths import atomic_write_text + self.session_path.parent.mkdir(parents=True, exist_ok=True) - tmp = self.session_path.with_suffix( - self.session_path.suffix + ".tmp", - ) - tmp.write_text( + atomic_write_text( + self.session_path, json.dumps( sanitize(self.messages), ensure_ascii=False, indent=2, ), ) - tmp.replace(self.session_path) except OSError: pass # Update session meta message count @@ -934,6 +933,7 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: tools_schema = self._filter_tools_schema( registry.to_schema_list(user_input_text), ) + registry_version = registry.version # Compress with the full request overhead in view: the system prompt # and tools schema ride on every API call, so they count against the @@ -952,6 +952,30 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: last_content = "" for _loop_i in range(self.max_turns): + # Connecting MCP services or enabling capabilities can mutate the + # global registry during a tool call. A schema frozen at turn start + # makes the newly registered tools unusable until the user sends a + # second message, so refresh both the schema and the prompt's + # ephemeral capability/MCP sections before the next model call. + if registry.version != registry_version: + system_prompt = self._system_prompt_for_turn(user_input_text) + if memory_context: + system_prompt += memory_context + tools_schema = self._filter_tools_schema( + registry.to_schema_list(user_input_text), + ) + overhead_tokens = estimate_tokens( + system_prompt, + ) + estimate_tokens( + json.dumps(tools_schema, ensure_ascii=False), + ) + await self._ensure_fits_context(overhead_tokens) + messages_with_system = [ + {"role": "system", "content": system_prompt}, + *self.messages, + ] + registry_version = registry.version + # Cheap in-place shrink of stale tool results (no LLM call): # keeps long tool loops from re-sending huge old outputs. if _loop_i > 0: @@ -1465,38 +1489,37 @@ async def _run_stream_body(self, user_input) -> AsyncIterator[str]: ), ) - # Record the completed turn as events (session-as-event-log - # direction) BEFORE persisting history.json: the event stream is - # the source of truth, so it must never be older than the - # fallback store if we crash between the two writes. Best-effort: - # subagents/SDK callers may run without a session manager, and - # event recording must never break the loop. - try: - from dashscope.acli.session import get_session_manager - - turn_topic = ( - self.session_path.parent.name if self.session_path else None - ) - get_session_manager().record_turn_event( - user_text=text_of(user_input), - assistant_text=last_content, - tools_used=self._current_turn_tools, - outcome=( - _classify_outcome( - self._turn_tool_successes, - self._turn_tool_failures, - ) - if self._current_turn_tools - else "" - ), - topic=turn_topic, - ) - get_session_manager().record_messages_snapshot( - self.messages, - topic=turn_topic, - ) - except Exception: - pass + # Record the completed turn as events only for a persistent session. + # A session_path-less agent is intentionally ephemeral (SDK calls, + # disabled session persistence, isolated subagents); defaulting such a + # run to SessionManager.current_topic silently polluted the interactive + # "default" session. The event stream remains the source of truth for + # sessions that explicitly opted in. + if self.session_path is not None: + try: + from dashscope.acli.session import get_session_manager + + turn_topic = self.session_path.parent.name + get_session_manager().record_turn_event( + user_text=text_of(user_input), + assistant_text=last_content, + tools_used=self._current_turn_tools, + outcome=( + _classify_outcome( + self._turn_tool_successes, + self._turn_tool_failures, + ) + if self._current_turn_tools + else "" + ), + topic=turn_topic, + ) + get_session_manager().record_messages_snapshot( + self.messages, + topic=turn_topic, + ) + except Exception: + pass # Persist session before memory write so a memory exception can't # cost us the conversation. diff --git a/dashscope/acli/cli/dispatch.py b/dashscope/acli/cli/dispatch.py index cadc35cb..02b7f607 100644 --- a/dashscope/acli/cli/dispatch.py +++ b/dashscope/acli/cli/dispatch.py @@ -198,7 +198,10 @@ def _handle_slash_command( f" JSON mode: " f"{'[green]on' if agent.json_mode else '[dim]off'}[/]", ) - console.print(f" Loop mode: [cyan]{config.loop_mode}[/cyan]") + loop_label = config.loop_mode + if loop_label not in ("", "auto"): + loop_label += " [yellow](legacy; inactive)[/yellow]" + console.print(f" Loop mode: [cyan]{loop_label}[/cyan]") console.print(f" Max turns: [cyan]{config.max_turns}[/cyan]") # config.timeout is the LLM request timeout; run_command enforces its # own, so showing one "Timeout" line reported a limit nothing used. diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index 5c79cabf..9a0ae7cc 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -235,14 +235,12 @@ def from_dict(cls, d: dict) -> DelegationConfig: class Config: provider: str = "tongyi" model: str = "qwen3.8-max" - # Dual-LLM: when thinking_model is set, Plan/Thinking loop phases route to - # the thinking model; Execute uses the execution model (provider/model - # above). - # Empty = single-model mode (default, backward compatible). + # Legacy compatibility fields. The experimental DualLLMRouter and + # structured LoopEngine were removed; non-default values are preserved on + # disk but get_provider_chain emits an explicit warning instead of silently + # pretending they affect execution. thinking_provider: str = "" thinking_model: str = "" - # Loop mode: "auto" = LLM-driven flat loop (default); "structured" = - # 6-phase closed cycle (Target→Plan→Execute→Evaluate→Feedback→Thinking). loop_mode: str = "auto" tongyi_api_key: str = "" anthropic_api_key: str = "" diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md index 75762b58..858b82ad 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/agent.md @@ -4,83 +4,142 @@ description: Quick reference for Agent APIs (agentstudio-hosted Agent/Applicatio --- # Agent +For Python SDK 2.0.3, use `dashscope.Client` / `dashscope.AsyncClient` at the package root; `dashscope.v2` does not exist. Close the client with a context manager or `close()`/`aclose()`. New application calls use `client.apps.completions`; hosted-agent management uses the lazily mounted `client.agentstudio`. Assistants APIs below are exclusively for 1.x maintenance. + ## Use Cases and Selection -- `dashscope.Application`: calls applications published in the Bailian console (AppBuilder/RAG/workflow/plugin apps) directly by `app_id`; no need to manage models or tools — the simplest option. -- `dashscope.agentstudio`: full lifecycle management of AgentStudio-hosted Agents (agents/sessions/environments/files/skills/deployments), with SSE event streaming, client-side tools and MCP; suitable when you need a hosted runtime and multi-turn sessions. -- `dashscope.Assistants/Threads/Runs`: OpenAI-like Assistants API. **Deprecated** (DeprecationWarning in source; migrate to the Responses API); use only for maintaining legacy code. - -## SDK APIs -### agentstudio (`from dashscope.agentstudio import Client, AsyncClient`) -- `Client(api_key=, workspace=, region=None, base_url=None, uid=None, timeout=None, max_retries=2)`; if base_url is not given, `workspace` (or env var `DASHSCOPE_WORKSPACE`) is required; base_url can also be set via `DASHSCOPE_AGENTSTUDIO_URL`/`AGENTSTUDIO_URL`; default region `cn-beijing`, timeout 600s. -- Resource entry points: `client.agents / .sessions / .environments / .files / .skills / .vaults / .deployments / .deployment_runs`. -- `agents.create(*, name, model, description=None, system_prompt=None, tools=None, mcp_servers=None, skills=None, multiagent=None, metadata=None)` → `Agent`; `model` is a `ModelConfig(id=…, temperature=…, max_tokens=…, enable_thinking=…, thinking_budget=…)` (bare id string = `ModelConfig(id=…)`); `retrieve(agent_id, *, version=None)` (alias `get`); `update(agent_id, *, version, name, model=None, ...)` — version must equal the current version (HTTP 409), `name` is required (`AGENT_005`), and a sent `model` replaces the whole config (keep the rest via `agent.model.copy_with(...)`); `archive(agent_id)`; `list(*, limit, page, include_archived)` → `CursorPage`; `list_versions(agent_id)`. -- `sessions.create(*, agent, environment_id=None, title=None, resources=None, metadata=None)` → `Session`; also `retrieve/update(title, metadata)/list(*, limit, page, agent_id, statuses, created_at_gt/gte/lt/lte)/archive/delete`. -- `environments.create(*, name, config, description=None, scope="organization", metadata=None)`; `files.upload(file, *, mime_type=None, progress=None)` (file accepts a path/binary stream/(name, fileobj)); `skills.create(*, file_id=None, file=None, mime_type=None)`, `skills.versions.create(skill_id, ...)`. - -### agentstudio SSE Streaming Execution (Core) -1. `client.sessions.events.send(session_id, events)`: sends an event list (at least 1 event, otherwise `ValueError`); request body is `{"input": [...]}`. Use the constructors in `types`: `user_message(text_or_blocks, *, session_thread_id=None, metadata=None)`, `user_interrupt()`, `user_tool_confirmation()`, `user_tool_result()`, `user_custom_tool_result()`, `user_define_outcome()`. -2. `with client.sessions.events.stream(session_id, *, timeout=None) as stream:` opens the SSE stream (GET `/sessions/{id}/events/stream`); iterating yields `Message` (`ServerEvent`) objects: `event.type`, `event.content` (block list), `event.stop_reason`, `event.session_status`. -3. `stream.text_stream`: yields only the text of blocks with `type=="text"` from `message` events, and stops automatically when `session_status` becomes `idle/terminated/rescheduling` — the most convenient option. -- Event types (`SSEEventType`): clients may send `message/interrupt/tool_confirmation/function_call_output/tool_call_output/define_outcome`; the server additionally sends `reasoning/tool_call/tool_call_output/mcp_call/mcp_call_output/session_status/error/thread_*/model_request_*/outcome_evaluation`, etc. Session state machine: `idle → running → (rescheduling) → idle | terminated`. - -### Application (Bailian App) -`Application.call(app_id, prompt=None, history=None, workspace=None, api_key=None, messages=None, **kwargs)`; `app_id` is required (raises `InputRequired` if missing); at least one of `prompt` or `messages` is required. kwargs: `stream`, `incremental_output`, `session_id` (for multi-turn continuation, pass the session_id from the previous response), `biz_params` (flow/plugin business parameters), `has_thoughts`, `doc_tag_codes`, `doc_reference_type` (`simple`/`indexed`), `memory_id`, `image_list`, `file_list`, `rag_options`, `temperature/top_p/top_k/seed`. - -### Assistants / Threads / Runs (Deprecated) -- `Assistants.call/create(*, model, name=None, description=None, instructions=None, tools=None, file_ids=[], metadata=None, workspace=None, api_key=None, top_p/top_k/temperature/max_tokens=None)`; `retrieve/list/update/delete`. -- `Threads.create(*, messages=None, metadata=None)`; `Messages.create(thread_id, *, content, role="user", file_ids=[], metadata=None)`. -- `Runs.create(thread_id, *, assistant_id, instructions=None, tools=None, stream=False, ...)`; `Runs.create_thread_and_run(*, assistant_id, thread=None, stream=False, ...)`; `Runs.retrieve(run_id, *, thread_id)`; `Runs.submit_tool_outputs(run_id, *, thread_id, tool_outputs, stream=False)`; `Runs.cancel(...)`. -## Inputs/Outputs -- **Application**: returns `ApplicationResponse` (`status_code/request_id/code/message`); `output` is an `ApplicationOutput`: `text` (answer text), `finish_reason`, `session_id` (returned for multi-turn), `thoughts` (plugin/RAG process when has_thoughts=True), `doc_references`, `workflow_message`; `usage.models[]` contains `model_id/input_tokens/output_tokens`. With `stream=True` a generator is returned; with `stream=True` and `incremental_output=False` the SDK automatically merges deltas into full output. **Failures do not raise exceptions** — you must check `response.status_code == HTTPStatus.OK`, otherwise read `code/message`. -- **agentstudio event stream**: each event is a `Message` (fields `id/type/role/content/metadata/status/session_thread_id/...`); `content` is a block list with block.type ∈ `text/image/audio/data/file/refusal/error`. The block.data of a `session_status` event contains `session_status` and `stop_reason` (e.g. `{"type": "end_turn"}`). -- **Assistants Run state machine**: `queued → in_progress → (requires_action) → completed | failed | cancelled | expired` (plus `cancelling`); on `requires_action`, take tool calls from `run.required_action.submit_tool_outputs`, execute them, send results back via `Runs.submit_tool_outputs`, and poll `retrieve` until `completed`. +- **Bailian applications:** `client.apps.completions.create(...)` invokes an AppBuilder/RAG/workflow/plugin application by `app_id`; application tools and flows remain configured in the console. +- **AgentStudio:** `client.agentstudio` manages hosted agents, sessions, environments, files, skills, deployments, and related resources, with typed models and SSE execution streams. +- **Assistants / Threads / Messages / Runs / Steps:** deprecated 1.x compatibility APIs for repairing existing applications only. There is no 2.0 `Responses` resource, so do not recommend migrating these APIs to Responses. Choose Apps or AgentStudio for new hosted-agent scenarios based on the required service, not a nonexistent resource. + +## Bailian Applications (`client.apps`) + +```text +client.apps.completions.create( + *, app_id, prompt=None, messages=None, stream=False, session_id=None, + biz_params=None, has_thoughts=None, ..., parameters=None, timeout=None, +) +``` -## Plugin and MCP Integration -- agentstudio: pass a list of function/plugin schemas to `tools` of `agents.create` and a list of MCP server configs to `mcp_servers`; the runtime automatically emits `tool_call`/`mcp_call` events; after executing local tools, send results back with `user_tool_result(...)`/`user_tool_confirmation(...)` events. -- Application: plugins are bound to the app in the Bailian console; callers only pass plugin/flow parameters via `biz_params`; set `has_thoughts=True` to see the invocation process. -- Assistants: `tools=[{"type": ...}]`, combined with the Run `requires_action` loop. +Supply `prompt` or `messages`; when both are present, `prompt` becomes the latest user message. Other supported options include document/memory/image/file inputs, `rag_options`, `incremental_output`, model controls, and service-specific `parameters`. For a continued conversation, pass the previous result's `session_id`. -## Minimal Examples ```python -from http import HTTPStatus -from dashscope import Application -resp = Application.call(app_id="YOUR_APP_ID", prompt="Introduce Bailian", has_thoughts=True) -if resp.status_code == HTTPStatus.OK: - print(resp.output.text, resp.output.session_id) -else: - print(resp.request_id, resp.code, resp.message) +import dashscope + +with dashscope.Client() as client: # reads DASHSCOPE_API_KEY by default + result = client.apps.completions.create( + app_id="YOUR_APP_ID", + prompt="Introduce Bailian", + has_thoughts=True, + ) + print(result.text) + print(result.session_id, result.finish_reason) ``` + +`AppCompletion` exposes flat `text`, `finish_reason`, `session_id`, `thoughts`, `doc_references`, `workflow_message`, and `usage`, together with common response metadata. It does not require legacy `.output.text` access. + +With `stream=True`, the return value is `AppStream` (`AsyncAppStream` on `AsyncClient`). Iterate `text_stream` for text and close the stream when finished, especially on early exit: + ```python -from dashscope.agentstudio import Client +import dashscope + +with dashscope.Client() as client: + stream = client.apps.completions.create( + app_id="YOUR_APP_ID", + prompt="Write a short greeting", + stream=True, + incremental_output=True, + ) + try: + for text in stream.text_stream: + print(text, end="") + finally: + stream.close() +``` + +## AgentStudio (`client.agentstudio`) + +Mount AgentStudio from the main client so configuration and cleanup stay connected: + +```python +import dashscope from dashscope.agentstudio.types import user_message -client = Client(api_key="sk-xxx", workspace="my-ws") -agent = client.agents.create(name="demo", model="qwen3.8-flash") -session = client.sessions.create(agent=agent.id) -client.sessions.events.send(session.id, [user_message("Hello!")]) -with client.sessions.events.stream(session.id) as stream: - for text in stream.text_stream: # auto-stops on idle/terminated - print(text, end="") + +with dashscope.Client(workspace="my-ws") as client: + studio = client.agentstudio + agent = studio.agents.create(name="demo", model="qwen3.8-flash") + session = studio.sessions.create(agent=agent.id) + studio.sessions.events.send(session.id, [user_message("Hello!")]) + with studio.sessions.events.stream(session.id) as stream: + for text in stream.text_stream: + print(text, end="") +# Closing the main client also closes its mounted AgentStudio client. ``` -## Common Error Codes -| Error code/exception | HTTP status | Meaning | Handling | -| --- | --- | --- | --- | -| `InputRequired` / `ModelRequired` / `InvalidInput` | raised locally | missing required params such as app_id/prompt/model | add params per the exception message; Application failures do not raise — check status_code | -| `invalid_request_error` / `request_too_large` (InvalidRequestError) | 400/413 | invalid agentstudio request parameters, or payload too large | check parameter names/types, e.g. empty events raises a local ValueError | -| `authentication_error` (AuthenticationError) | 401 | api_key missing or invalid | check api_key or DASHSCOPE_API_KEY | -| `permission_denied_error` / `permission_error` (PermissionDeniedError) | 403 | no access to the resource | check workspace/uid and resource ownership | -| `not_found_error` (NotFoundError) | 404 | agent/session and other resources do not exist | verify the id and region/workspace are correct | -| `conflict_error` (ConflictError) | 409 | version in agents.update does not match the server | retrieve the latest version first, then update | -| `rate_limit_error` / `billing_error` (RateLimitError) | 429 | rate limited or out of quota | retry with backoff (client default max_retries=2) | -| `api_error` / `timeout_error` (InternalServerError), `overloaded_error` (OverloadedError) | 500/502/504 / 503 | server error/overloaded | troubleshoot with `request_id` or retry | -| `APITimeoutError` / `APIConnectionError` | no response | connection/read/write timeout | increase timeout, check network, then retry | -| `StreamError` / `StreamClosedError` | mid-stream | SSE protocol error/stream closed | call `events.stream` again to reopen the stream | - -Match on the exception class, not on `.code`: `.code` echoes the server's raw -string, so a 403 may arrive as either `permission_denied_error` or -`permission_error`, and it is normalised to `api_error` when the server omits -it. The class is always the one listed above. +`Client(api_key=..., workspace=..., region=..., ...)` forwards API key, workspace, and region to AgentStudio. A workspace is required unless an AgentStudio base URL is configured. Standalone `dashscope.agentstudio.Client/AsyncClient` remain available, but are unnecessary for normal 2.0 main-client usage. + +Mounted resources are `agents`, `deployments`, `deployment_runs`, `sessions`, `environments`, `files`, `skills`, `vaults`, `memory_stores`, `webhook_endpoints`, and `security`. + +### Agents and Sessions + +- `agents.create(*, name, model, description=None, system_prompt=None, tools=None, mcp_servers=None, skills=None, multiagent=None, metadata=None)` returns `Agent`. `model` can be a model ID string or `ModelConfig(id=..., temperature=..., max_tokens=..., enable_thinking=..., thinking_budget=...)`. +- `agents.retrieve(agent_id, version=None)` (alias `get`), `list(...)`, and `list_versions(agent_id)` query agent state. `archive(agent_id)` archives it. +- `agents.update(agent_id, *, version, name, model=None, ...)` is optimistic-concurrency checked: `version` must match the current version. The service also requires `name`; sending `model` replaces its model configuration, so retain unspecified settings (for example with the existing model's `copy_with(...)`) rather than accidentally dropping them. +- `sessions.create(*, agent, environment_id=None, title=None, resources=None, vault_ids=None, environment_variables=None, mcp_configs=None, metadata=None)` returns `Session`. Sessions also support `retrieve`/`get`, `update`, `list`, `archive`, and `delete`. +- `environments.create(*, name, config, description=None, scope="organization", metadata=None)` creates an execution environment. `files.upload(file, *, mime_type=None, progress=None)` accepts a path, binary stream, or `(name, fileobj)`. Skills can be created from `file_id` or `file`, with nested version APIs. + +### AgentStudio SSE Execution + +1. `studio.sessions.events.send(session_id, events)` sends a non-empty event list; an empty list raises local `ValueError`. Constructors in `dashscope.agentstudio.types` include `user_message(...)`, `user_interrupt()`, `user_tool_confirmation()`, `user_tool_result()`, `user_custom_tool_result()`, and `user_define_outcome()`. +2. `with studio.sessions.events.stream(session_id, event_deltas=None, timeout=None) as stream:` opens a single-pass SSE stream. Iterating it yields typed server `Message` events with fields such as `type`, `content`, `stop_reason`, and `session_status`. +3. `stream.text_stream` yields text blocks from message events and stops on terminal stream/session states such as `idle`, `terminated`, or `rescheduling`. Opt into `event_deltas` when incremental `text_deltas` are required; do not assume all delta types arrive by default. + +Content blocks can represent text, images, audio, data, files, refusal, or errors. Client event types include message, interrupt, tool confirmation/results, and defined outcomes; server events additionally include reasoning, tool/MCP calls, session status, thread, model-request, and outcome-evaluation events. A typical session state flow is `idle → running → (rescheduling) → idle | terminated`. + +### Plugins, Tools, and MCP + +- AgentStudio: pass tool schemas through `agents.create(tools=[...])` and MCP configurations through `mcp_servers`. The runtime emits tool/MCP call events; execute client-side tools as required and send results or confirmations back through session events. +- Apps: bind plugins and workflows in the Bailian console. Callers pass business parameters with `biz_params`; `has_thoughts=True` requests process information in flat `thoughts`. + +## Errors (Brief) + +New code catches API exception classes from `dashscope.errors`; AgentStudio's stream-specific `StreamError` / `StreamClosedError` remain in `dashscope.agentstudio.exceptions`. Match exception classes rather than inferring the class solely from a raw wire `.code`. + +| HTTP / condition | Current class | Handling | +| --- | --- | --- | +| 400 | `BadRequestError` (`InvalidRequestError` is an alias) | Check parameter names/types; empty event lists are local `ValueError` | +| 401 | `AuthenticationError` | Check `DASHSCOPE_API_KEY` or explicit `api_key` | +| 403 | `PermissionDeniedError` | Check workspace, UID, region, and resource ownership | +| 413 | `RequestTooLargeError` | Reduce payload size and check service limits | +| 404 | `NotFoundError` | Verify agent/session/resource IDs | +| 409 | `APIStatusError` (there is no dedicated conflict catalog class) | Retrieve the latest agent version before retrying update | +| 429 | `RateLimitError` | Retry with backoff and check quota | +| 503 / mapped overloaded error | `ServiceUnavailableError` (`OverloadedError` is an alias) | Retry with backoff | +| 504 | `GatewayTimeoutError` | Retry or troubleshoot with `request_id`; do not label it `InternalServerError` | +| 500 or a code mapped to internal error | `InternalServerError` | Retry where appropriate and retain `request_id` | +| Unmapped HTTP status, including an unknown 5xx | `APIStatusError` | Inspect status/code/body; do not blindly guess `InternalServerError`. An unrecognized wire code may still use the known HTTP status mapping. | +| Connection/read/write timeout | `APIConnectionError` / `APITimeoutError` | Check network and timeout before retrying | +| AgentStudio stream protocol/closed stream | `StreamError` / `StreamClosedError` | Reopen `events.stream` when the operation is retryable | + +See the dedicated errors guide for the complete hierarchy and retry rules. + +## 1.x Compatibility — Maintenance Only + +These APIs retain their old response envelopes and local exception behavior. Do not mix them with 2.0 flat `AppCompletion` models or `client.agentstudio`. + +### Legacy Application + +`dashscope.Application.call(app_id, prompt=None, history=None, workspace=None, api_key=None, messages=None, **kwargs)` invokes a published app. At least one of `prompt` or `messages` is required; missing `app_id` raises `InputRequired`. Legacy kwargs include `stream`, `incremental_output`, `session_id`, `biz_params`, `has_thoughts`, document/memory/image/file inputs, `rag_options`, and model controls. + +`ApplicationResponse.output` contains `text`, `finish_reason`, `session_id`, `thoughts`, `doc_references`, and `workflow_message`. Legacy failures generally do not raise: check `status_code`, then inspect `code/message/request_id`. This is the opposite of the default 2.0 exception flow. + +### Legacy Assistants / Threads / Messages / Runs / Steps + +- `Assistants.call/create(*, model, name=None, description=None, instructions=None, tools=None, file_ids=[], metadata=None, ...)`; `retrieve/list/update/delete`. +- `Threads.create(*, messages=None, metadata=None)`; `Messages.create(thread_id, *, content, role="user", file_ids=[], metadata=None)`. +- `Runs.create(thread_id, *, assistant_id, instructions=None, tools=None, stream=False, ...)` and `Runs.create_thread_and_run(...)`; also `retrieve`, `submit_tool_outputs`, and `cancel`. Steps belong to this same legacy maintenance surface. +- Run states: `queued → in_progress → (requires_action) → completed | failed | cancelled | expired`, with `cancelling` also possible. On `requires_action`, read `run.required_action.submit_tool_outputs`, execute the requested tools, submit outputs, and continue polling. +- Legacy tool schemas and the Run tool-output loop must remain in this compatibility implementation; do not rewrite them as AgentStudio events unless intentionally migrating the application. ## Java SDK diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/api-doc.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/api-doc.md index e46a684b..b37a5661 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/api-doc.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/api-doc.md @@ -4,11 +4,16 @@ description: Look up SDK API parameters and usage arguments: [api_name] --- -First use run_command to check the installed SDK version and the actual API signature: -1. `run_command("python -c 'import dashscope; print(dashscope.__version__)'")` -2. `run_command("python -c 'import inspect, dashscope; print(inspect.signature(dashscope.{api_name}.call))'")` -3. If the signature is not clear enough, use `run_command("python -c 'import dashscope; help(dashscope.{api_name})'")` to get the full documentation. +First identify whether `{api_name}` refers to a 1.x module/class API or a 2.0 client resource, then inspect the installed SDK: -Based on the actual inspection results, explain all parameters of {api_name}, the return value format, and the supported model list, and provide a typical usage example (Python). +1. `run_command("python -c 'import dashscope; print(dashscope.__version__)'")`. +2. Resolve the exact public object and method. Use `inspect.signature` on that bound method; use `help()` on the resource or response model when parameter or return-field documentation is insufficient. Do not mechanically append `.call` to `{api_name}`. +3. For a 2.0 resource, a local `Client(api_key="sk-test")` is enough for inspection and does not make a network request. Close it afterwards. Read the resource implementation or Pydantic response model only when inspection leaves a specific question unresolved. +4. Consult the matching domain skill for the current 2.0.3 mapping and the 1.x compatibility notes. -Note: parameter descriptions must match the inspection results. Do not answer from memory. +Explain the parameters, return type and fields, streaming/task lifecycle where applicable, and supported models from the verified implementation. Include a typical Python example and distinguish these contracts explicitly: + +- 2.0: `Client` / `AsyncClient` resource methods, flattened typed responses, `dashscope.errors` exceptions, and `jobs.create/fetch/wait` for supported long-running tasks. +- 1.x: the existing `.call()` signature and its actual response or task fields; do not rewrite them as 2.0 fields. + +Parameter descriptions and model support must match the inspection results. If the installed version differs from the knowledge base, state the difference instead of guessing. diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md index d10a6277..25433ea6 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/cli.md @@ -5,11 +5,11 @@ description: Quick reference for all dashscope CLI command groups, subcommands, # dashscope CLI -This file is authoritative for answering `dashscope` CLI usage questions (based on the dashscope/cli/ source code); do not invent options. For details, have the user run `dashscope --help` to verify. +This file is authoritative for answering `dashscope` CLI usage questions (based on `dashscope/legacy_1x/cli/`); do not invent options. **The CLI is still implemented by the 1.x SDK, including its response envelopes and static resource classes. It is not a command facade over 2.0 `dashscope.Client` / `AsyncClient`.** For details, have the user run `dashscope --help` to verify. ## Command overview -| Command group | Subcommands | Purpose | Corresponding SDK class | +| Command group | Subcommands | Purpose | Corresponding 1.x SDK implementation | | --- | --- | --- | --- | | auth | whoami/login/logout | Verify the active API key and persist one to ~/.dashscope/api_key | dashscope.common.api_key (get_default_api_key/save_api_key) | | generation | create | Text generation (including streaming and multi-turn messages) | dashscope.Generation | @@ -22,16 +22,18 @@ This file is authoritative for answering `dashscope` CLI usage questions (based | tokenization | create | Tokenization/token counting | dashscope.Tokenization | | models | list/get | Query available models | dashscope.Models | | application | create | Call a Bailian application | dashscope.Application | -| image-synthesis | create | Text-to-image (async task) | dashscope.ImageSynthesis | -| image-generation | create | Image generation (supports reference images) | dashscope.ImageGeneration | +| image-synthesis | create/fetch/wait/cancel/list | Full text-to-image task lifecycle | dashscope.ImageSynthesis | +| image-generation | create/fetch/wait/cancel/list | Full image-generation task lifecycle, including reference images | `dashscope.legacy_1x.aigc.image_generation.ImageGeneration` | | video-synthesis | create/fetch/wait/cancel/list | Full video synthesis task lifecycle | dashscope.VideoSynthesis | | multimodal-conversation | create | Multimodal conversation | dashscope.MultiModalConversation | | multimodal-embedding | create | Multimodal embeddings (text/image/audio) | dashscope.MultiModalEmbedding | -| transcription | create | Audio file transcription (async task) | dashscope.Transcription | +| transcription | create/fetch/wait/cancel/list | Full audio file transcription task lifecycle | dashscope.Transcription | | speech-synthesis | create | Speech synthesis TTS | dashscope.HttpSpeechSynthesizer | -| rl (hidden alias agentic-rl) | run/get/cancel/logs/list/register_functions/test_functions/upload_data | Agentic RL fine-tuning | dashscope.finetune.agentic_rl.AgenticRL | +| rl (hidden alias agentic-rl) | run/get/cancel/logs/list/register_functions/test_functions/upload_data | Agentic RL fine-tuning | 1.x AgenticRL compatibility wrapper, not `client.rl` | -Note: code-generation and understanding still exist in the source but are deprecated/discontinued and are not registered in the CLI. +Notes: +- `dashscope.ImageGeneration` is **not** a root public export. The CLI imports the internal 1.x class shown above; do not recommend the nonexistent root import. +- code-generation and understanding still exist in the source but are deprecated/discontinued and are not registered in the CLI. ## Common usage @@ -44,7 +46,7 @@ dashscope auth logout # remove the saved key file dashscope generation create -m qwen3.7-plus -p "Hello" -s --temperature 0.7 dashscope ft create -m qwen3.8-32b -t file-xxx -v file-yyy -e 2 -b 16 -l 1e-5 dashscope files upload -f train.jsonl -p fine-tune -d "training set" -dashscope deployments create -m qwen3.8-32b -s demo -c 1 +dashscope deployments create -m qwen3.8-32b -s demo -c 1 --plan ptu dashscope oss upload -m qwen3.7-plus -f ./pic.png dashscope rerank create -m gte-rerank -q "query" -d doc1 -d doc2 -n 3 dashscope embeddings create -m text-embedding-v3 -i "text1" -i "text2" @@ -54,7 +56,9 @@ dashscope application create -a APP_ID -p "question" --has-thoughts dashscope image-synthesis create -m qwen-image-3.0 -p "a cat" -n 2 --size 1024*1024 dashscope image-generation create -m qwen-image-3.0 -t "a cat" --image ref.png dashscope video-synthesis create -m wan2.2-t2v-plus -p "ocean waves" -dashscope video-synthesis wait TASK_ID # fetch/cancel/list take task_id the same way +dashscope video-synthesis wait TASK_ID # fetch/cancel also take TASK_ID; list is a query +# image-synthesis, image-generation, and transcription expose the same +# create/fetch/wait/cancel/list task lifecycle. dashscope multimodal-conversation create -m qwen3-vl-plus -t "describe the image" --image a.jpg dashscope multimodal-embedding create -m multimodal-embedding-v1 --text "cat" --image c.png dashscope transcription create -m paraformer-v2 --file-url https://.../a.wav @@ -69,7 +73,7 @@ Key option notes: - generation create: `-p/--prompt`, `--messages` (JSON string), `-s/--stream`, `--temperature/--top-p/--top-k/--max-tokens/--seed/--stop/--repetition-penalty/--presence-penalty/--enable-search/--n/--result-format` - ft create: `-t/--training-file-ids` (repeatable), `-v/--validation-file-ids`, `--mode`, `-e/--n-epochs`, `-b/--batch-size`, `-l/--learning-rate`, `-p/--prompt-loss`; get/stream/cancel/delete all take job_id as a positional argument; list uses `-p/--page`, `-s/--size` - files: upload `-f/--file`, `-p/--purpose` (default fine-tune), `-d/--description`; all commands support `-u/--base-url` -- deployments: create `-m/-s/--suffix/-c/--capacity/--plan/--template-id`; `scale DEPLOYED_MODEL -c 2`; after create it blocks by default (timeout 3600 seconds) +- deployments: create `-m/-s/--suffix/-c/--capacity/--plan/--template-id`; **`--plan` is required** (for example `--plan ptu`); `scale DEPLOYED_MODEL -c 2`; after create it blocks by default (timeout 3600 seconds) - oss upload: `-f/--file`, `-m/--model`, `-k/--api-key` (reads DASHSCOPE_API_KEY), `-u/--base-url` - rerank create: `-q/--query`, `-d/--document` (repeatable), `--return-documents/--no-return-documents`, `-n/--top-n`, `-i/--instruct` - video-synthesis create: `--img-url/--first-frame-url/--last-frame-url/--duration/--resolution/--ratio/--seed/--prompt-extend/--watermark`; list supports `--start-time/--end-time/--model-name/--status/--region` diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/diagnose.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/diagnose.md index 6acac9f9..e84e3401 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/diagnose.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/diagnose.md @@ -5,9 +5,11 @@ arguments: [file_path] --- Diagnostic steps: -1. Use `read_file` to read the user's code at {file_path}. -2. Check the installed SDK version: `run_command("python -c 'import dashscope; print(dashscope.__version__)'")` -3. If the code calls a specific API (e.g. `dashscope.Generation.call`), inspect its actual signature: `run_command("python -c 'import inspect, dashscope; print(inspect.signature(dashscope.Generation.call))'")` -4. If you need to understand SDK internals, locate the source with `run_command("python -c 'import dashscope; import os; print(os.path.dirname(dashscope.__file__))'")`, then use `read_file` to read the relevant implementation. -Based on the actual findings above, point out the problems in the user's code and provide the complete fixed code. +1. Use `read_file` to read the user's code at `{file_path}`. Identify the API generation from the call itself: `Generation.call` and similar module/class APIs are 1.x; `Client` / `AsyncClient` resource calls are 2.0. An installed 2.0 package does not by itself make existing 1.x code wrong. +2. Check the installed version: `run_command("python -c 'import dashscope; print(dashscope.__version__)'")`. +3. Inspect the exact called object and method with `inspect.signature`; for 2.0, inspect the bound resource method and relevant response model. Do not assume a `.call()` method or a `.output` response envelope. +4. Classify the failure from its actual type, `code`, `status_code`, `message`, and `request_id`. For 2.0, use the `dashscope.errors` hierarchy and distinguish HTTP/API errors from `SdkError` connection, timeout, and local failures. For 1.x, preserve its response-checking and exception conventions. +5. Only if a specific behavior remains unclear, locate the package with `run_command("python -c 'import dashscope; import os; print(os.path.dirname(dashscope.__file__))'")` and read the relevant implementation. + +Point out the root cause and provide complete fixed code. When repairing existing 1.x code, keep its style and contract unless the user explicitly requests migration. When writing or migrating to 2.0, use current resource methods, flattened responses, client lifecycle management, and `dashscope.errors`; do not mix 1.x and 2.0 return handling in the same example. diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/error-code.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/error-code.md index 1d061c5b..32e6cceb 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/error-code.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/error-code.md @@ -4,8 +4,74 @@ description: Explain a DashScope API error code and how to fix it arguments: [code] --- -First check how this error code is actually handled in the SDK: -1. `run_command("python -c 'import dashscope; import os; print(os.path.dirname(dashscope.__file__))'")` -2. Use `run_command("grep -rn '{code}' ")` to search the SDK source for where this error code is defined and handled. +First identify whether the failing call uses the 1.x response convention or the 2.0 exception convention. Inspect the installed SDK and search for `{code}` in the public error definitions rather than guessing from the message: -Based on the actual code, explain what DashScope API error code {code} means, list common triggers, and give concrete fix steps with code examples. +1. `run_command("python -c 'import dashscope; print(dashscope.__version__)'")` +2. `run_command("python -c 'import dashscope; import os; print(os.path.dirname(dashscope.__file__))'")` +3. Search the relevant error registry, protocol parser, or 1.x API implementation for the exact wire code. Preserve the original `code`, `message`, and `request_id` in the diagnosis. + +## Python 2.0 errors + +New code imports concrete exceptions from `dashscope.errors`. The package root directly exports `DashScopeError` and `ErrorCode`, not every concrete subclass. + +```python +from dashscope.errors import ( + BadRequestError, + ContentFilterError, + DashScopeError, + RateLimitError, +) + +try: + response = client.chat.completions.create( + model="qwen-plus", + messages="Hello", + ) +except ContentFilterError as error: + print("Content blocked", error.request_id) +except BadRequestError as error: + print("Bad request", error.code, error.message, error.request_id) +except RateLimitError as error: + print("Throttled", error.status_code, error.request_id) +except DashScopeError as error: + print(type(error).__name__, error.code, error.message, error.request_id) +``` + +`ContentFilterError` is a `BadRequestError` subclass, so its `except` clause must come first. + +Both HTTP non-2xx responses and HTTP 200 responses carrying a business error code use the same exception family. Common HTTP status classes are: + +| Status | Exception | +| --- | --- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 413 | `RequestTooLargeError` | +| 429 | `RateLimitError` | +| 500 | `InternalServerError` | +| 503 | `ServiceUnavailableError` | +| 504 | `GatewayTimeoutError` | + +Wire codes refine the meaning: for example, `Arrearage` maps to `PermissionDeniedError`, not 429, and throttling codes map to `RateLimitError`. A gateway timeout is `GatewayTimeoutError`; do not label every unknown 5xx as `InternalServerError` without checking the actual status and code. `ResponseError` concerns response parsing and is not synonymous with every HTTP 500. + +Exceptions expose `message`, `code`, `status_code`, `request_id`, `body`, and `headers` as applicable. Connection failures, timeouts, WebSocket failures, and SDK-local failures belong to the `SdkError` branch; they have no HTTP response, have `status_code is None`, and may also provide `solution` and `allow_retry`. Do not discard these fields by flattening every failure into a status-only message. + +With `raise_on_error=True` (the default), catch `DashScopeError` or a more specific subclass. If the user explicitly selects return-value mode, check `is_error`: + +```python +lenient = client.with_options(raise_on_error=False) +response = lenient.chat.completions.create(model="qwen-plus", messages="Hello") +if response.is_error: + print(response.code, response.message, response.request_id) +``` + +`raise_on_error=False` does not suppress connection failures or timeouts because no response object exists. In a stream, a business error may be represented by an `is_error=True` item before iteration ends. Automatic transport retries do not make authentication, permission, quota, or parameter errors safe to retry blindly. + +## Python 1.x and compatible-mode errors + +For an existing 1.x call such as `Generation.call`, preserve its contract: API failures commonly return a response whose `status_code`, `code`, `message`, and `request_id` must be checked, while local validation errors may still be raised. Do not convert this into a 2.0 `except DashScopeError` example unless the user is migrating. + +OpenAI-compatible code using the official `openai` package keeps that library's own exception hierarchy; it is not the `dashscope.errors` hierarchy. Java errors likewise follow the Java SDK's independent API and version line. + +Based on the verified implementation, explain what `{code}` means, common triggers, whether retry is safe, and concrete fix steps with an example in the user's existing SDK style. diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md index 0495b090..6abedf60 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/fine-tuning.md @@ -5,102 +5,161 @@ description: Quick reference for fine-tuning and deployment (FineTunes/Files/Dep # Fine-tuning and Deployment -> Quick-reference note: all parameter names and return fields on this page follow the SDK source code (`dashscope/finetune/`, `dashscope/files.py`, `dashscope/common/`). Check the signatures on this page before answering; if unsure, verify on the spot with `inspect.signature` instead of relying on memory. +> Python SDK 2.0.3 uses `dashscope.Client` / `dashscope.AsyncClient` at the package root (`dashscope.v2` does not exist). Verify current signatures in `dashscope/resources/`, workflow code in `dashscope/applications/finetune/`, and old implementations in `dashscope/legacy_1x/finetune/`; use `inspect.signature` when unsure. New examples below use flat response fields and close their clients. -## Use Cases and Training Modes (SFT/CPT/DPO/AgenticRL) +## Use Cases and Training Modes -| Mode | Entry Point | Notes | +| Mode | Current entry point | Notes | | --- | --- | --- | -| SFT supervised fine-tuning | `FineTunes.call(..., mode="sft")` | `mode` is written into the request body as `training_type`; the source docstring lists values `sft` / `efficient_sft` | -| CPT continued pre-training / DPO preference optimization | `FineTunes.call` | The SDK does not enumerate CPT/DPO literals; pass them through `mode`/`hyper_parameters` per the server-side docs | -| AgenticRL reinforcement fine-tuning (GSPO) | `dashscope.finetune.agentic_rl.AgenticRL` | `training_type="reinforcement"`, `hyper_parameters["algorithm"]="gspo"` | +| SFT supervised fine-tuning | `client.fine_tunes.create(..., mode="sft")` | `mode` is sent as `training_type`; source documents `sft` / `efficient_sft` | +| CPT / DPO | `client.fine_tunes.create(...)` | The SDK does not enumerate all server literals; pass supported `mode` and `hyper_parameters` values through | +| Agentic RL (including GSPO configuration) | `client.rl` and `client.rl.jobs` | Current client workflow and job-management facade | +| Direct AgenticRL workflow | `dashscope.finetune.agentic_rl.AgenticRL` | Separate workflow object; do not treat it as `client.rl` or as a FineTunes manager | -## Standard Workflow (upload file → create fine-tune job → poll status → deploy → invoke) +## Standard SFT Workflow -1. Upload file: `Files.upload(file_path, purpose="fine_tune")` → take `output["uploaded_files"][0]["file_id"]` -2. Create job: `FineTunes.call(model, training_file_ids=[file_id], hyper_parameters={...})` → take `output.job_id` (like `ft-xxx`) -3. Poll status: `FineTunes.get(job_id)` for polling, or `FineTunes.wait(job_id)` to block until a terminal state (polls every 30s); logs via `FineTunes.logs(job_id, offset=1, line=1000)`, event stream via `FineTunes.stream_events(job_id)` -4. Deploy: `Deployments.call(model=, capacity=N)` → `output.deployed_model`; use `Deployments.get(deployed_model)` and wait for `output.status` to become `RUNNING` -5. Invoke: the deployment name is the model name; use regular inference APIs such as `Generation.call(model=deployed_model, ...)` -6. Cleanup: `Deployments.scale(deployed_model, capacity)` to scale; `Deployments.delete(deployed_model)` to release; `FineTunes.cancel(job_id)` / `FineTunes.delete(job_id)` to cancel/delete a job +1. Upload JSONL with `client.files.upload(file_path=..., purpose="fine-tune")` and read `File.file_id`. +2. Create a job with `client.fine_tunes.create(...)`; submission returns a `FineTune` snapshot with `job_id` and `status`. +3. Poll with `get(job_id)` or block with `wait(job_or_job_id)`. Read logs with `logs()` or consume SSE with `stream_events()`. +4. After success, deploy `FineTune.finetuned_model` with `client.deployments.create(model=..., capacity=...)`; poll `get()` until the desired deployment status. +5. Invoke the deployment by using `Deployment.deployed_model` as the model name in the appropriate inference API. +6. Scale or release with `client.deployments.scale/delete`; cancel or delete training with `client.fine_tunes.cancel/delete`. -## SDK APIs (source of truth: the source code) +```python +import dashscope -### FineTunes (`dashscope/finetune/finetunes.py`, SUB_PATH="fine-tunes") +with dashscope.Client() as client: # reads DASHSCOPE_API_KEY by default + uploaded = client.files.upload(file_path="train.jsonl") + + submitted = client.fine_tunes.create( + model="qwen3.8-32b", + training_file_ids=[uploaded.file_id], + hyper_parameters={"n_epochs": 3}, + ) + job = client.fine_tunes.wait(submitted) + print(job.job_id, job.status, job.finetuned_model) + + deployment = client.deployments.create( + model=job.finetuned_model, + capacity=1, + suffix="demo", + ) + print(deployment.deployed_model, deployment.status) +``` -The creation method is named `call` (the SDK has no `create` method): -`FineTunes.call(model, training_file_ids, validation_file_ids=None, mode=None, hyper_parameters={}, api_key=None, workspace=None, **kwargs) -> FineTune` +`AsyncClient` provides awaitable file, fine-tune, deployment, and RL resource methods; use `async with dashscope.AsyncClient()` and close streams with their async cleanup API. -| Parameter | Type | Notes | -| --- | --- | --- | -| model | str | Base model to fine-tune | -| training_file_ids | list/str | Training file id; a str is auto-converted to a single-element list | -| validation_file_ids | list/str/None | Validation file id | -| mode | str/None | Training mode, written into the request body as `training_type` (`sft`/`efficient_sft`) | -| hyper_parameters | dict | Hyperparameters, e.g. `n_epochs`, `batch_size`, `learning_rate` | -| kwargs["finetuned_output"] | str | Custom output model name, passed through as `finetuned_output` in the request body | +## Files (`client.files`) + +| Method | Return / important fields | +| --- | --- | +| `upload(*, file_path, purpose="fine-tune", description=None, ...)` | `File`: `file_id`, `name`, `purpose`, `size`, `uploaded_at` | +| `list(*, page_no=1, page_size=10, ...)` | `FileList`: `files`, `page_no`, `page_size`, `total_count` | +| `get(file_id, ...)` | `File` | +| `delete(file_id, ...)` | `DeletionStatus`, including `deleted` | + +Fine-tune uploads validate JSONL locally, one JSON object per line. Invalid JSONL raises local `ValueError`; filesystem failures remain SDK I/O errors. SFT rows commonly contain `{"messages": [...]}`; RL datasets may include fields such as `rollout_extra` required by the workflow. The 2.0 purpose default is hyphenated `fine-tune`, not the old `fine_tune` example. + +## Fine-Tune Jobs (`client.fine_tunes`) -Other methods: `get(job_id) -> FineTune`; `list(page_no=1, page_size=10) -> FineTuneList`; `cancel(job_id) -> FineTuneCancel`; `delete(job_id) -> FineTuneDelete`; `wait(job_id)` polls until `SUCCEEDED/FAILED/CANCELED`. +```text +create(*, model, training_file_ids, validation_file_ids=None, + mode=None, hyper_parameters=None, finetuned_model=None, ...) -> FineTune +``` + +- `training_file_ids` and `validation_file_ids` accept a string or list; a string is normalized to a one-element list. +- `finetuned_model` is the request-side custom output name and is sent as wire key `model_name`. It is not the 1.x `finetuned_output` request kwarg. +- `get(job_id)` returns a fresh `FineTune`; `list(page_no=1, page_size=10)` returns `FineTuneList.jobs` and `total_count`. +- `cancel(job_id)` and `delete(job_id)` return flat `JobStatus` snapshots. +- `logs(job_id, offset=1, lines=1000)` returns `JobLogs`; read normalized text from `content`. The Python argument is plural `lines` even though the wire query key is singular. +- `stream_events(job_id)` yields `FineTuneEvent` objects. Event `output` is the event payload, not a legacy response envelope. +- `wait(job_or_job_id, poll_interval=..., max_interval=..., timeout=...)` polls until `SUCCEEDED`, `FAILED`, or `CANCELED`; failed/cancelled jobs raise by default. It returns the final `FineTune` snapshot rather than invoking the deployment automatically. + +Flat `FineTune` fields include `job_id`, `job_name`, `status`, `model`, `base_model`, `finetuned_model`, `training_file_ids`, `validation_file_ids`, `hyper_parameters`, `training_type`, `created_at`, `finished_at`, and `usage`, plus common response metadata. `FineTuneList` uses `jobs` and `total_count`. + +## Deployments (`client.deployments`) + +```text +create(*, model, capacity, model_version=None, suffix=None, plan=None, + template_id=None, deploy_spec=None, billing_method=None, ...) -> Deployment +``` -### Deployments (`dashscope/finetune/deployments.py`, SUB_PATH="deployments") +- `get(deployed_model)` and `scale(deployed_model, *, capacity)` return `Deployment` snapshots. There is no SDK `wait()` on this resource; poll `get()` when readiness is required. +- `list(page_no=1, page_size=10)` returns `DeploymentList.deployments` and `total_count`. +- `delete(deployed_model)` returns `JobStatus`. +- `stream_events(deployed_model_id)` yields `DeploymentEvent`; its `output` is an event payload, not the old response envelope. +- Flat `Deployment` fields include `deployed_model`, `model`, `base_model`, `status`, `capacity`, `ready_capacity`, `charge_type`, `created_at`, and `updated_at`. -- `Deployments.call(model, capacity, version=None, suffix=None, ...) -> Deployment`: request body keys are `model_name/capacity/model_version/suffix`; fine-tuned models do not need `version`; with `suffix` set, the deployment name is `model_suffix` -- `get(deployed_model)`, `list(page_no=1, page_size=10)`, `delete(deployed_model)`, `scale(deployed_model, capacity)` +The Python SDK's `plan` parameter is optional at the signature level; server-side plan/billing requirements still depend on model and account. This differs from the legacy CLI's required `--plan` option. -### Files (`dashscope/files.py`, SUB_PATH="files") +## Agentic RL Through `client.rl` -- `Files.upload(file_path, purpose="fine_tune", description=None, ...) -> DashScopeAPIResponse`: see `FilePurpose` for `purpose` values (`fine_tune`/`assistants`); for `fine_tune`, the JSONL is validated locally first and invalid files raise `InvalidFileFormat` directly -- `list(page=1, page_size=10)`, `get(file_id)`, `delete(file_id)`; all return `DashScopeAPIResponse` +`client.reinforcement_learning` aliases `client.rl`. The resource separates workflow operations from `client.rl.jobs` job management. -### AgenticRL (`dashscope/finetune/agentic_rl.py`, brief) +### Workflow and function instances -- Usage: `client = AgenticRL(api_key=None)` → optionally `client.init(config_path="job.yaml")` (YAML-driven) → `await client.run(...)` completes "register functions → upload datasets → submit job" in one step; or call `register_functions()` / `upload_datasets()` / `submit_job()` step by step -- Key concepts: `RolloutFunctionComponent`/`RewardFunctionComponent` function components; `TrainingDataset`/`ValidationDataset` (`data_source_type`: `file_id`/`download_url`/`oss_mount`); `hyper_parameters` (`algorithm="gspo"`, `n_rollouts`, `kl_loss_coef`, `learning_rate`, etc.); `resources` (e.g. `mtu_spec_code`) -- Job management methods mirror FineTunes: `AgenticRL.get/list/cancel/delete/logs(job_id, ...)` +| Method | Contract | +| --- | --- | +| `register_functions(*, rollout_classpaths=None, reward_classpaths=None, group_reward_classpaths=None, workspace_dir="./", lazy_load=True, api_key=None)` | Returns `RegisteredFunctions`, including registered entity IDs and function instance IDs | +| `test_functions(*, instance_id, func_type, input_data, pull_logs=False, log_page_size=100, start_time=None, end_time=None, api_key=None)` | Returns the user function's own result, not a `FineTune` | +| `upload_data(training_files, validation_files=None, *, api_key=None)` | Returns `UploadedData.training_file_ids` and `validation_file_ids` | +| `query_function_instance_logs(...)` | Returns one raw log-query API dictionary | +| `query_all_function_instance_logs(...)` | Returns aggregated log strings | +| `delete_function_instance(...)` | Returns the raw delete API dictionary | -## Inputs/Outputs (JSONL format and job object fields) +These synchronous workflow methods internally use `asyncio.run`; do not call them from an already running event loop. Use `AsyncClient.rl` there. Their signatures do not accept a generic `timeout=` or per-call `workspace=`; do not copy those options from job methods. -- Training file: JSONL, one valid JSON object per line (the SDK validates line by line with `json.loads`); for SFT each line is usually `{"messages": [...]}`; RL data may carry a `rollout_extra` field -- `FineTune`: `status_code/request_id/code/message/output/usage`; `output` (`FineTuneOutput`) fields: `job_id, job_name, status, model, base_model, finetuned_output, training_file_ids, validation_file_ids, hyper_parameters, training_type, create_time, end_time, usage` -- `output.status` enum (`TaskStatus`): `PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/SUSPENDED/UNKNOWN`; after the job succeeds, use `output.finetuned_output` as the model name for deployment and invocation -- `FineTuneList.output`: `jobs` (List[FineTuneOutput]) + `page_no/page_size/total` -- `Deployment.output` (`DeploymentOutput`): `deployed_model, status, model_name, base_model, capacity, ready_capacity, charge_type, gmt_create, gmt_modified`; `status` (`DeploymentStatus`): `DEPLOYING/RUNNING(SERVING)/PENDING/DELETING/FAILED` +### Jobs (`client.rl.jobs`) -## Minimal Example (create an SFT job) +- `create(*, config_path=None, job_name=None, api_key=None, **kwargs) -> FineTune` initializes and runs the complete AgenticRL workflow, then returns after submission. `kwargs` override open-ended training configuration keys. It does not wait for training completion. +- `fetch(job_id) -> FineTune`; `wait(job_or_job_id) -> FineTune`; `list(...) -> FineTuneList`. +- `cancel(job_id)` and `delete(job_id)` return `JobStatus`; `logs(job_id, offset=1, lines=1000)` returns `JobLogs`. ```python import dashscope -from dashscope import Files, FineTunes - -dashscope.api_key = "sk-xxx" # or use the DASHSCOPE_API_KEY environment variable - -up = Files.upload("train.jsonl", purpose="fine_tune") # validates JSONL locally -file_id = up.output["uploaded_files"][0]["file_id"] - -job = FineTunes.call( - model="qwen3.8-32b", - training_file_ids=[file_id], - hyper_parameters={"n_epochs": 3}, -) -if job.status_code == 200: - rsp = FineTunes.wait(job.output.job_id) # blocks until SUCCEEDED/FAILED/CANCELED - print(rsp.output.status, rsp.output.finetuned_output) -else: - print(job.code, job.message) # on failure read code/message/request_id + +with dashscope.Client() as client: + submitted = client.rl.jobs.create(config_path="rl_config.yaml") + job = client.rl.jobs.wait(submitted) + print(job.job_id, job.status, job.finetuned_model) ``` -## Common Error Codes +Use `register_functions()`, `test_functions()`, and `upload_data()` when those stages must be driven individually; do not assume `jobs.create()` reuses objects returned by a separate workflow unless the configuration explicitly wires them together. + +## Direct `AgenticRL` Workflow — A Separate Contract + +`dashscope.finetune.agentic_rl.AgenticRL` owns the configurable workflow. Typical usage is `AgenticRL(...)`, `init(config_path=..., **kwargs)`, then `await run()`; alternatively drive `register_functions()`, `upload_datasets()`, and synchronous `submit_job()` stage by stage. Components include rollout/reward functions, training/validation datasets, hyperparameters such as `algorithm="gspo"`, and resource settings. + +```python +import asyncio +from dashscope.finetune.agentic_rl import AgenticRL + +async def main(): + workflow = AgenticRL() + workflow.init(config_path="rl_config.yaml") + result = await workflow.run() + print(result.status_code, result.request_id) + +asyncio.run(main()) +``` + +`run()`/`submit_job()` use `SubmitResult`, whose legacy-shaped fields include `status_code`, `request_id`, `code`, `message`, `output`, `usage`, and `headers`. That shape belongs to this workflow API; it is not a `client.rl.jobs` `FineTune`. The public workflow class does **not** provide FineTunes-style `get/list/cancel/delete/logs` methods. For those operations on a submitted RL job, use `client.rl.jobs`. + +In 1.x compatibility code, the AgenticRL wrapper adds `cancel/list/get/delete/logs`, and its `submit_job()` returns a 1.x `FineTune`. Keep that wrapper's return and management contract out of current `client.rl` and public `AgenticRL` examples. + +## Response and Error Handling + +Ordinary 2.0 models expose common `request_id`, `status_code`, `code`, `message`, `is_error`, and `headers` alongside the flat business fields documented above. By default, API/business failures raise exceptions from `dashscope.errors`. If a client is constructed with `raise_on_error=False`, check `response.is_error` before using business fields and retain `request_id` for diagnosis. Intentional raw/event payloads (`Job.output`, `FineTuneEvent.output`, `DeploymentEvent.output`, and direct AgenticRL `SubmitResult.output`) are exceptions, not the standard envelope pattern. + +## 1.x Compatibility — Maintenance Only -| Error code/exception | HTTP status | Meaning | Handling | -| --- | --- | --- | --- | -| `InvalidFileFormat` | none (local exception) | Uploaded file is not valid JSONL | Fix the JSON line by line and re-upload | -| `AuthenticationError` | 401 | API Key missing/invalid | Set `DASHSCOPE_API_KEY` or pass `api_key` explicitly | -| Response `code`/`message` | 403 | No access to the file/job/deployment | Check the `workspace` and resource ownership | -| Response `code`/`message` | 404 | `job_id`/`file_id`/`deployed_model` does not exist | Verify the id with the corresponding `list()` | -| `REPEATABLE_STATUS` | 503/504 | Service temporarily unavailable | Retry, or keep polling with `FineTunes.wait` | -| AgenticRL `error_code` 3001-3008 | - | 3001 key config, 3002 function registration, 3003 dataset upload, 3004 duplicate function name, 3005 job submission, 3006 flow failure, 3007 unsupported function type, 3008 function validation failure | Use the exception's `root_cause` to locate the root cause | +Use this section only to repair existing imports such as `from dashscope import Files, FineTunes, Deployments`; do not mix these objects with 2.0 client resources. -General error-handling pattern: first check `status_code == 200` on the response object; on failure read `code`/`message`/`request_id`; the base class for SDK local exceptions is `dashscope.common.error.DashScopeException`. +- `Files.upload(file_path, purpose="fine_tune", description=None)` returned `DashScopeAPIResponse`; old code reads `output["uploaded_files"][0]["file_id"]`. `Files.list` used `page=...`, and invalid fine-tune JSONL raised `InvalidFileFormat`. +- `FineTunes.call(model, training_file_ids, validation_file_ids=None, mode=None, hyper_parameters={}, ...)` created a job. Old custom naming used `kwargs["finetuned_output"]`; management methods were `get/list/cancel/delete/wait/logs/stream_events`. +- Legacy `FineTune.output` fields included `job_id`, `status`, `finetuned_output`, file IDs, hyperparameters, timestamps, and usage. Successful old jobs therefore deployed `response.output.finetuned_output`. +- `Deployments.call(model, capacity, version=None, suffix=None, ...)` created a deployment. Legacy code reads `Deployment.output.deployed_model`, `output.status`, `output.capacity`, and related fields; `scale` used positional capacity. +- Legacy responses generally did not raise for service failures: check `status_code`, then read `code`, `message`, and `request_id`. Do not apply that rule to 2.0 `Client` calls. ## Java SDK diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md index eff7bc4b..208b939d 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/multimodal.md @@ -4,88 +4,202 @@ description: Quick reference for multimodal (MultiModalConversation/ImageSynthes --- # Multimodal -Locate before answering: understanding tasks use `MultiModalConversation` (sync/streaming); generation tasks use `ImageSynthesis`/`VideoSynthesis` (async tasks). Parameter names must follow the source code in `dashscope/aigc/`; never answer from memory. +For new Python code, target **DashScope SDK 2.0.3** and use `dashscope.Client` / `dashscope.AsyncClient`. The 2.0 resources live in the root `dashscope` package; **`dashscope.v2` does not exist**. Set `DASHSCOPE_API_KEY` before running the examples. Use the explicitly marked 1.x section only for legacy maintenance, and do not mix 2.0 flattened responses with 1.x `.output` responses. -## Applicable Models and Scenarios +Authoritative sources: `dashscope/resources/multimodal/chat.py`, `images.py`, `videos.py`, and `_jobs.py`. -- Understanding (image/video understanding, OCR, function calling): `qwen3-vl-flash`, `qwen3-vl-plus`, qwen-ocr series, etc. (the `Models` class in source only has the constant `qwen_vl_chat_v1`; pass others as strings). -- Text-to-image / image editing (`ImageSynthesis`, task=`text2image`): `qwen-image-3.0`, `qwen-image-edit-plus` (image editing, automatically switches to image2image task). -- Video generation (`VideoSynthesis`, task=`video-generation`): `wan2.2-t2v-plus` (text-to-video), `wan2.2-i2v-plus` (image-to-video), `wan2.2-kf2v-flash` (first/last frame). +## 2.0.3 Resources and Models -## SDK Interfaces +- Multimodal understanding: `client.multimodal.chat.create`. Models include current Qwen VL/OCR model names; pass the model as a string and verify service-side availability. +- Image generation/editing: `client.images.generate` for submit-and-wait, or `client.images.jobs.create/fetch/wait` for explicit task control. +- Video generation: `client.videos.generate` or `client.videos.jobs.create/fetch/wait`. +- `AsyncClient` mounts the same resources. `await` each operation, and use `async with dashscope.AsyncClient() as client` to close connections. -| Class | Key Methods | Notes | -|---|---|---| -| `MultiModalConversation` | `call(model, messages, ...)` | `stream=True` returns a Generator, otherwise returns a response object; async version `AioMultiModalConversation.call` | -| `ImageSynthesis` | `call / async_call / fetch(task) / wait(task) / cancel(task) / list(...)` | `call` blocks until results are ready; `sync_call` only supports wan2.2-t2i-flash/plus | -| `VideoSynthesis` | Same as above (no `list` difference, parameters below) | Same async task flow | +`client.chat.completions.create` automatically routes messages containing image/audio/video parts to the multimodal endpoint. `client.multimodal.chat.create` always selects that endpoint in the native protocol, including text-only calls to VL models. Compatible protocols translate media on their own chat path. -Async task flow: `async_call(...)` returns a task (`output.task_id`, `task_status=PENDING`) → `fetch(task)` for a single query or `wait(task)` to poll until a terminal state; `cancel(task)` can only cancel `PENDING`. The `task` parameter accepts a task_id string or the response object from async_call. `task_status` values: `PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/UNKNOWN`. +## Multimodal Chat -Key parameters of `MultiModalConversation.call` (all optional, in source signature order): `messages`, `text`, `voice`, `language_type`, `stream`, `temperature`[0,2), `top_p`(0,1], `top_k`, `max_tokens`, `seed`, `stop`, `repetition_penalty`, `presence_penalty`, `result_format`("message"/"text"), `incremental_output`, `enable_search`, `tools`, `tool_choice`, `enable_thinking`, `n`(1-4), `ocr_options`, `logprobs`, `top_logprobs`; plus `api_key`, `workspace`. **Empty `model` raises `ModelRequired`; both messages and text empty raises `ValueError`.** +`create` has the same keyword-only signature and `ChatCompletion` / `ChatStream` return types as `client.chat.completions.create`: -Key parameters of `ImageSynthesis.call/async_call`: `prompt` (required, empty raises `InputRequired`), `negative_prompt`, `size`("width*height" e.g. "1024*1024"), `n`, `seed`, `style` (auto/photography/anime etc.), `prompt_extend`, `watermark`, `ref_img`, `sketch_image_url` (sketch), `base_image_url`+`mask_image_url` (imageedit), `ref_strength`[0,1], `ref_mode`(repaint/refonly). +```python +create(*, model: str, messages: str | list[MessageInput], stream: bool = False, + tools=None, temperature=None, top_p=None, max_tokens=None, stop=None, + seed=None, repetition_penalty=None, presence_penalty=None, + enable_thinking=None, response_format=None, top_k=None, tool_choice=None, + n=None, logprobs=None, top_logprobs=None, thinking_budget=None, + enable_search=None, search_options=None, parallel_tool_calls=None, + incremental_output=None, parameters=None, timeout=None, + api_key=None, workspace=None) +``` + +Use `parameters={...}` for model-specific options not present in the signature. Media content is a list of parts. With the native protocol, local paths and `file://` URIs are uploaded to OSS before the request. + +```python +import dashscope + +messages = [{ + "role": "user", + "content": [ + {"image": "https://example.com/cat.jpg"}, + {"text": "Describe this image"}, + ], +}] + +with dashscope.Client() as client: + response = client.multimodal.chat.create( + model="qwen-vl-plus", + messages=messages, + ) + print(response.output_text) + + stream = client.multimodal.chat.create( + model="qwen-vl-plus", + messages=messages, + stream=True, + ) + try: + for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + print(chunk.choices[0].delta.content, end="", flush=True) + finally: + stream.close() +``` + +The 2.0 response flattens list-form multimodal text into a string. Read `response.choices[0].message.content` or `response.output_text`, plus flat `usage` / `request_id`; **do not read `.output`**. Streaming chunks use `choices[i].delta`, with the same incremental/cumulative `incremental_output` semantics as text chat. -Key parameters of `VideoSynthesis.call/async_call`: `prompt`, `negative_prompt`, `img_url` (image-to-video), `audio_url`, `first_frame_url`/`last_frame_url` (or `head_frame`/`tail_frame`), `media`, `reference_urls`, `template`, `size`("1280*720"), `duration` (seconds, default 5), `resolution`("720P"/"1080P"), `ratio`("16:9"), `seed`, `prompt_extend`, `watermark`, `shot_type`, `audio_setting`. +## Images and Videos -## Input/Output +### Actual resource signatures -Multimodal messages (content is an array of dicts; local paths or URLs both work, the SDK auto-uploads to OSS and adds header `X-DashScope-OssResourceResolve: enable`): +Both `generate` methods return a finished `Job`. Their `jobs.create` methods accept the same model/input/generation fields but only submit; their `timeout` is the submit HTTP timeout. `generate` additionally has `submit_timeout`, `poll_interval`, and `max_interval`, and its `timeout` is the total wait budget. ```python -messages = [{"role": "user", "content": [ - {"image": "https://.../a.jpg"}, # or {"video": [...]} / {"audio": ...} - {"text": "Describe this image"}, -]}] +client.images.generate( + *, model: str, prompt: str, negative_prompt=None, n=None, size=None, + image_urls=None, extra_input=None, function=None, seed=None, style=None, + prompt_extend=None, watermark=None, parameters=None, + timeout=86400.0, submit_timeout=None, poll_interval=1.0, + max_interval=8.0, api_key=None, workspace=None, +) -> Job + +client.videos.generate( + *, model: str, prompt=None, image_url=None, media=None, extra_input=None, + function=None, seed=None, size=None, duration=None, resolution=None, + ratio=None, prompt_extend=None, watermark=None, parameters=None, + timeout=86400.0, submit_timeout=None, poll_interval=1.0, + max_interval=8.0, api_key=None, workspace=None, +) -> Job + +client.images.jobs.fetch(task_id: str, *, timeout=None, api_key=None, workspace=None) +client.videos.jobs.fetch(task_id: str, *, timeout=None, api_key=None, workspace=None) +client.images.jobs.wait(job_or_task_id, *, poll_interval=1.0, max_interval=8.0, + timeout=86400.0, api_key=None, workspace=None) +client.videos.jobs.wait(job_or_task_id, *, poll_interval=1.0, max_interval=8.0, + timeout=86400.0, api_key=None, workspace=None) ``` -Responses uniformly contain `status_code / request_id / code / message / output / usage` (on error `output` is None; first check `status_code==200` i.e. `HTTPStatus.OK`): +- Image `size` accepts `1024*1024` and normalizes `1024x1024` to the wire form. `image_urls` supplies reference images for supported models; use `extra_input` for model-specific input fields and `parameters` for model-specific generation options. +- Image protocol selection is automatic by model name. Legacy text2image models use asynchronous tasks; `wan2.6-t2i*` / `wan2.6-image*` use the multimodal-generation protocol. A synchronous multimodal `generate` is capped by `submit_timeout`, not polling settings. +- Video `image_url` is the public name for the legacy wire field `img_url`. Older models accept `prompt` and/or `image_url`. `wan2.7-i2v*` requires `media` or `image_url`; media items use asset types `first_frame`, `last_frame`, `driving_audio`, and `first_clip`. `image_url` is shorthand for one `first_frame` item and must not duplicate one already in `media`. +- Unlike 1.x video behavior, explicitly pass `prompt_extend=True` when prompt extension is wanted. -- `MultiModalConversationResponse.output`: `choices[0].message.content` (content array isomorphic to the input), `finish_reason`, `text`; `usage`: `input_tokens`/`output_tokens`/`characters`. -- `ImageSynthesisResponse.output`: `task_id`, `task_status`, `results` (each contains `url`); `usage.image_count`. -- `VideoSynthesisResponse.output`: `task_id`, `task_status`, `video_url`; `usage`: `video_count`/`video_duration`/`video_ratio`. +### Runnable examples -## Minimal Examples +```python +import dashscope + +with dashscope.Client() as client: + image_job = client.images.generate( + model="wanx2.1-t2i-turbo", + prompt="一只在月球上弹吉他的猫,水彩风格", + size="1024*1024", + timeout=600.0, + ) + print(image_job.status, image_job.image_urls) + + video_job = client.videos.generate( + model="wan2.7-t2v", + prompt="一只小猫在草地上奔跑", + size="1280*720", + timeout=900.0, + ) + print(video_job.status, video_job.video_url) +``` -Image-text understanding: +Explicit submission, polling, and waiting: ```python -from http import HTTPStatus -from dashscope import MultiModalConversation - -rsp = MultiModalConversation.call(model="qwen3-vl-flash", messages=messages) -if rsp.status_code == HTTPStatus.OK: - print(rsp.output.choices[0].message.content[0]["text"]) +import dashscope + +with dashscope.Client() as client: + submitted = client.images.jobs.create( + model="wanx2.1-t2i-turbo", + prompt="一只猫", + size="1024*1024", + ) + snapshot = client.images.jobs.fetch(submitted.task_id) + print(snapshot.status) + + done = client.images.jobs.wait(submitted, timeout=600.0) + print(done.image_urls) ``` -Text-to-image async task: +`Job` exposes flat `task_id`, `status`, `results`, `result_url`, `image_urls`, `video_url`, `usage`, and the shared response envelope. Each item in `results` can expose `url` (and service-specific extra fields). Status values are `PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELED`, and `UNKNOWN`; use `job.succeeded` / `job.finished` for checks. `wait` polls with exponential backoff. With default error handling, `create` / `fetch` raise on `FAILED`, and `wait` raises on `FAILED` or `CANCELED`; `UNKNOWN` is terminal and is returned. + +## Errors (brief) + +- In 2.0, catch `dashscope.errors.DashScopeError` or a more specific subclass. If using `raise_on_error=False`, inspect `response.is_error` / `code` / `message`; for jobs also inspect `status`. +- In 1.x, keep checking `status_code`, `code`, and `message` on the response, including task failure information under the 1.x `output` contract. + +## 1.x Compatibility and Maintenance Guide + +These APIs retain their old response shapes; do not rewrite only the response access while leaving the old entry point in place. + +| 1.x entry point | Maintenance notes | +| --- | --- | +| `MultiModalConversation.call` / `AioMultiModalConversation.call` | `stream=True` returns a generator/async generator; otherwise returns `MultiModalConversationResponse` | +| `ImageSynthesis.call/async_call/fetch/wait/cancel/list` | `call` submits and waits; `async_call` only submits; `sync_call` is limited to supported wan2.2 t2i models | +| `VideoSynthesis.call/async_call/fetch/wait/cancel/list` | Same asynchronous task pattern for video | + +For `MultiModalConversation.call`, key parameters remain `model`, `messages`, `text`, `voice`, `language_type`, `stream`, sampling options, `result_format`, `incremental_output`, `enable_search`, tools, thinking options, `ocr_options`, and logprobs. Empty `model` raises `ModelRequired`; empty `messages` and `text` raises `ValueError`. + +For `ImageSynthesis`, old inputs include `prompt` (required), `negative_prompt`, `size`, `n`, `seed`, `style`, `prompt_extend`, `watermark`, `ref_img`, `sketch_image_url`, `base_image_url`, `mask_image_url`, `ref_strength`, and `ref_mode`. For `VideoSynthesis`, old inputs include `prompt`, `negative_prompt`, `img_url`, `audio_url`, `first_frame_url` / `last_frame_url` (or `head_frame` / `tail_frame`), `media`, `reference_urls`, `template`, `size`, `duration`, `resolution`, `ratio`, `seed`, `prompt_extend`, `watermark`, `shot_type`, and `audio_setting`. Keep model-specific validation when repairing these calls. + +The 1.x task flow is `async_call(...)` → `fetch(task)` for one snapshot or `wait(task)` until terminal; `task` can be a task ID or previous response. `cancel` applies only to `PENDING`. Old response contracts are: + +- `MultiModalConversationResponse.output.choices[0].message.content` (a content-part list), plus `output.text` where supplied. +- `ImageSynthesisResponse.output.task_id/task_status/results`; each result has `url`. +- `VideoSynthesisResponse.output.task_id/task_status/video_url`. +- All expose `status_code`, `request_id`, `code`, `message`, and `usage`; check `status_code == HTTPStatus.OK` before reading `output`. + +Do not use a context manager for these 1.x classmethod calls: ```python -from dashscope import ImageSynthesis +from http import HTTPStatus +from dashscope import ImageSynthesis, MultiModalConversation + +response = MultiModalConversation.call( + model="qwen-vl-plus", + messages=[{"role": "user", "content": [ + {"image": "https://example.com/cat.jpg"}, + {"text": "Describe this image"}, + ]}], +) +if response.status_code == HTTPStatus.OK: + print(response.output.choices[0].message.content) -rsp = ImageSynthesis.async_call( +job = ImageSynthesis.async_call( model="qwen-image-3.0", - prompt="A cat running under the moonlight", n=2, size="1024*1024", + prompt="A cat running under the moonlight", + n=2, + size="1024*1024", ) -rsp = ImageSynthesis.wait(rsp) # poll until SUCCEEDED/FAILED -if rsp.output.task_status == "SUCCEEDED": - for r in rsp.output.results: - print(r.url) +job = ImageSynthesis.wait(job) +if job.status_code == HTTPStatus.OK and job.output.task_status == "SUCCEEDED": + for result in job.output.results: + print(result.url) ``` -## Common Error Codes - -| Error Code | HTTP Status | Meaning | Handling | -|---|---|---|---| -| InvalidApiKey | 401 | API Key missing/invalid | Check `dashscope.api_key` or the `DASHSCOPE_API_KEY` environment variable | -| InvalidParameter | 400 | Invalid parameter (size/format/field name) | Check each item against the parameter tables in this file; locally also raises `InvalidParameter` exception | -| DataInspectionFailed | 400 | prompt/image failed content moderation | Rewrite the prompt or change the input image | -| Throttling | 429 | Rate limit triggered | Retry with exponential backoff, reduce concurrency, confirm quota | -| InternalError | 500 | Server-side error | Retry with `request_id` or file a ticket | -| Task FAILED | 200 | Async task execution failed | Read `output.task_status` and the failure info in `output`, then adjust input and retry | - -Local validation exceptions (no request sent): `ModelRequired` (missing model), `InputRequired` (missing prompt), `ValueError` (messages/text both empty). Network/server exceptions may raise `RequestFailure` (contains `http_code`). - ## Java SDK Java SDK v2.22.23 entry points: diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/retrieval.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/retrieval.md index f85137e1..606eced9 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/retrieval.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/retrieval.md @@ -5,108 +5,220 @@ description: Quick reference for retrieval (Embedding / Batch Embedding / Multim # Retrieval -> Compiled from the SDK source code (embeddings/text_embedding.py, batch_text_embedding.py, multimodal_embedding.py, rerank/text_rerank.py); parameters and return fields are based on these sources. +For new Python code, target **DashScope SDK 2.0.3** and use `dashscope.Client` / `dashscope.AsyncClient`. The implementation is in the root `dashscope` package; **`dashscope.v2` does not exist**. Set `DASHSCOPE_API_KEY` before running the network examples. Keep the 1.x `.output` contract confined to the compatibility section. + +Authoritative sources: `dashscope/resources/embeddings.py`, `multimodal/embeddings.py`, `rerank.py`, `tokenization.py`, and `_jobs.py`. ## Applicable Models and Scenarios -**TextEmbedding** (task="text-embedding", `TextEmbedding.Models`): -- `text-embedding-v1` / `text-embedding-v2`: basic general-purpose vectors; only text_type is supported; fixed dimension. -- `text-embedding-v3`: supports text_type, dimension (default 1024), output_type (dense/sparse/dense&sparse). -- `text-embedding-v4`: everything v3 offers, plus exclusive dimension options 2048/1536. +- Text vectors: `text-embedding-v1/v2` (fixed dimension), `text-embedding-v3/v4` (configurable dimensions and dense/sparse output where supported). +- Offline batch text vectors: `text-embedding-async-v1/v2`, using a file containing one text per line. +- Multimodal vectors: `multimodal-embedding-one-peace-v1`, `multimodal-embedding-v1`, `qwen3-vl-embedding`, `qwen2.5-vl-embedding`, `tongyi-embedding-vision-plus`, and `tongyi-embedding-vision-flash`. +- Relevance ranking: `gte-rerank`, `gte-rerank-v2`, `qwen3-rerank`, and `qwen3-vl-rerank` where supported by the selected rerank resource. +- Token counting: `client.tokenization.count`; local tokenization is separately available under `dashscope.tokenizers` and sends no request. -**BatchTextEmbedding** (offline batch): `text-embedding-async-v1` / `text-embedding-async-v2`; submits a file url containing line-by-line texts for async batch processing. +Model support and allowed parameter values are service/model-specific; the SDK passes model names as strings rather than enforcing a whitelist. -**MultiModalEmbedding** (task="multimodal-embedding"): mixed text/image/audio/video vectors. Models: `multimodal-embedding-one-peace-v1`, `multimodal-embedding-v1`, `qwen3-vl-embedding`, `qwen2.5-vl-embedding`, `tongyi-embedding-vision-plus`, `tongyi-embedding-vision-flash`; coroutine version `AioMultiModalEmbedding`. +## 2.0.3 Text and Multimodal Embeddings -**TextReRank** (task="text-rerank"): reranks recalled results by relevance. Models: `gte-rerank`, `gte-rerank-v2`, `qwen3-rerank`, `qwen3-vl-rerank`; coroutine version `AioTextReRank`. +### Text embeddings -## SDK Interfaces +```python +client.embeddings.create( + *, model: str, input: str | list[str], text_type=None, dimension=None, + output_type=None, instruct=None, parameters=None, timeout=None, + api_key=None, workspace=None, +) -> EmbeddingResponse +``` -`TextEmbedding.call(model, input, workspace=None, api_key=None, text_type=None, dimension=None, output_type=None, instruct=None, **kwargs) -> DashScopeAPIResponse` +- `text_type` is typically `query` for retrieval queries or `document` for corpus/symmetric inputs. +- v3/v4 support model-dependent `dimension` and `output_type` (`dense`, `sparse`, or `dense&sparse`). Do not pass these to v1/v2. `instruct` supplies a task instruction where supported; additional options belong in `parameters`. +- Empty input raises `ValueError` locally. Streaming is not supported. -| Parameter | Description (based on source docstrings) | -| --- | --- | -| input | str or List[str], internally assembled as `{"texts": [...]}`; streaming not supported | -| text_type | `"query"` (retrieval query) / `"document"` (default; corpus and symmetric tasks) | -| dimension | v3/v4 only: 2048, 1536 (v4 only), 1024 (default), 768, 512, 256, 128, 64 | -| output_type | v3/v4 only: `"dense"` (default) / `"sparse"` / `"dense&sparse"` | -| instruct | Custom task instruction to guide the model in understanding query intent | +### Multimodal embeddings -`MultiModalEmbedding.call(model, input, api_key=None, workspace=None, dimension=None, output_type=None, fps=None, instruct=None, enable_fusion=None, res_level=None, max_video_frames=None, **kwargs)` +The actual 2.0 entry point is **`client.multimodal.embeddings.create`** (there is no method named `create_multimodal`): -- input is a List; elements use `MultiModalEmbeddingItemText(text, factor)` / `...ItemImage(image, factor)` / `...ItemAudio(audio, factor)` (factor: float, required), or equivalent dicts. -- dimension: vector dimension (supported values vary by model); output_type: currently only `"dense"`. -- fps: video frame-sampling ratio in [0,1], default 1.0; instruct: task instruction. -- enable_fusion: qwen3-vl-embedding only; when True, all content is fused into a single vector. -- res_level (0/1/2/3) and max_video_frames (<=64): snapshot-type models only. -- kwargs supports `auto_truncation`: automatically truncates audio >15s / text >70 words; default False (over-length input raises an error). -- Local files are automatically uploaded via OSS (header `X-DashScope-OssResourceResolve: enable`); empty input raises `InputRequired`; empty model raises `ModelRequired`. +```python +client.multimodal.embeddings.create( + *, model: str, input: dict | EmbeddingItem | list, + dimension=None, output_type=None, fps=None, instruct=None, + enable_fusion=None, res_level=None, max_video_frames=None, + parameters=None, timeout=None, api_key=None, workspace=None, +) -> EmbeddingResponse +``` -**BatchTextEmbedding** (BaseAsyncApi): `call(model, url, ...)` submits and waits; `async_call(model, url, ...)` only creates the task; `fetch(task)` checks status; `wait(task)` waits for completion; `cancel(task)` cancels only PENDING tasks; `list(...)` lists tasks. url is the address of a file with line-by-line texts and is required (empty raises InputRequired); kwargs supports text_type (query/document, default document). +Each input item sets exactly one of `text`, `image`, `audio`, or `video`, plus optional `factor`. Use dictionaries or `EmbeddingItem` from `dashscope.resources.multimodal.embeddings`. `fps`, `res_level`, and `max_video_frames` apply only to supported snapshot models; `enable_fusion` is model-specific (including qwen3-vl-embedding). Local paths and `file://` URIs upload to OSS first. Empty input or an item without a modality raises `ValueError`. -`TextReRank.call(model, query, documents, return_documents=None, top_n=None, api_key=None, instruct=None, **kwargs) -> ReRankResponse` +```python +import dashscope + +with dashscope.Client() as client: + embedded = client.embeddings.create( + model="text-embedding-v3", + input=["今天天气不错", "明天会下雨吗"], + text_type="document", + dimension=1024, + ) + for item in embedded.embeddings: + print(item.index, len(item.embedding)) + + multimodal = client.multimodal.embeddings.create( + model="multimodal-embedding-v1", + input=[ + {"text": "一只猫"}, + {"image": "https://example.com/cat.jpg"}, + ], + ) + for item in multimodal.embeddings: + print(item.index, item.type, len(item.embedding)) +``` -- return_documents: default False (results do not include original text); top_n: returns all documents by default. -- instruct: ranking instruction; English recommended. Empty query/documents raises InputRequired; empty model raises ModelRequired. -- AioTextReRank.call is the coroutine version, with an additional workspace parameter. +Both return the same flat `EmbeddingResponse`: `embeddings`, `usage`, and the shared response envelope. Each `Embedding` has `index`, `embedding`, and optional `type`; the 1.x wire name `text_index` is normalized to `index`. The old one-peace singular vector is normalized to item 0. **Do not read `.output["embeddings"]` in 2.0.** -## Input / Output +## 2.0.3 Batch Embedding Jobs -Synchronous interfaces return `DashScopeAPIResponse`: status_code (200 = success) / request_id / code / message / output / usage. -- Embedding: `output["embeddings"]` is `[{"text_index": i, "embedding": [...]}]` (each multimodal item contains `embedding`); `usage` reports token usage (e.g. `input_tokens`). -- Batch: `output` = `{task_id, task_status, url}`; after SUCCEEDED, url points to the result file; `usage.total_tokens`; task_status in PENDING / RUNNING / SUCCEEDED / FAILED / CANCELED. -- ReRank: input `input={"query": str, "documents": List[str]}`; `output.results` is `[{index, relevance_score, document}]`, where index is the position in documents; document is returned only when return_documents=True; `usage.total_tokens`. +```python +client.embeddings.jobs.create( + *, model: str, file_url: str, text_type=None, parameters=None, + timeout=None, api_key=None, workspace=None, +) -> Job +client.embeddings.jobs.fetch(task_id: str, *, timeout=None, + api_key=None, workspace=None) -> Job +client.embeddings.jobs.wait(job_or_task_id, *, poll_interval=1.0, + max_interval=8.0, timeout=86400.0, api_key=None, workspace=None) -> Job +``` -## Minimal Examples +`create` accepts exactly one `file_url`, not a list; an empty URL raises `ValueError`. `create` only submits, `fetch` obtains one snapshot, and `wait` polls to a terminal state. `timeout` on create/fetch is an HTTP timeout; on wait it is the total wait budget. ```python import dashscope -from dashscope import TextEmbedding -resp = TextEmbedding.call( - model=TextEmbedding.Models.text_embedding_v3, - input=["The wind is swift, the sky is high, the gibbons wail", "The islets are clear, the sand is white, the birds wheel"], - text_type="document", dimension=1024, -) -if resp.status_code == 200: # HTTPStatus.OK - for e in resp.output["embeddings"]: - print(e["text_index"], e["embedding"][:3]) +with dashscope.Client() as client: + submitted = client.embeddings.jobs.create( + model="text-embedding-async-v2", + file_url="https://example.com/embedding-input.txt", + text_type="document", + ) + snapshot = client.embeddings.jobs.fetch(submitted.task_id) + print(snapshot.status) + done = client.embeddings.jobs.wait(submitted, timeout=3600.0) + print(done.result_url) # download and parse this result file ``` +Read flat `task_id`, `status`, `result_url`, `usage`, `request_id`, and `job.succeeded` / `job.finished`. Status values are `PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELED`, and `UNKNOWN`. By default, `create` / `fetch` raise on `FAILED`, and `wait` raises on `FAILED` or `CANCELED`; `UNKNOWN` is terminal and returned. + +## 2.0.3 Rerank + ```python -from dashscope import TextReRank +client.rerank.create( + *, model: str, query: str, documents: list[str], top_n=None, + return_documents=None, instruct=None, parameters=None, timeout=None, + api_key=None, workspace=None, +) -> RerankResponse +``` -resp = TextReRank.call( - model=TextReRank.Models.gte_rerank, - query="What is a text embedding?", - documents=["Text embeddings map text into a dense vector space", "The weather is nice today", "Embeddings are often used for semantic retrieval"], - top_n=2, return_documents=True, -) -if resp.status_code == 200: - for r in resp.output.results: - print(r["index"], r["relevance_score"], r["document"]) +`top_n` defaults to service behavior (normally all documents); `return_documents=True` includes source text. `instruct` supplies a ranking instruction where supported. Empty `query` or `documents` raises `ValueError`. + +```python +import dashscope + +with dashscope.Client() as client: + ranked = client.rerank.create( + model="gte-rerank-v2", + query="什么是文本向量?", + documents=["文本向量把文本映射到向量空间。", "今天天气晴朗。"], + top_n=1, + return_documents=True, + ) + for result in ranked.results: + document_text = result.document.text if result.document else None + print(result.index, result.relevance_score, document_text) ``` +`RerankResponse.results` is flat. Each result has `index`, `relevance_score`, and optional `document`; `document` is an object whose text is read as `.text`, not a plain string. `index` refers to the original `documents` position. + +## 2.0.3 Tokenization + +```python +client.tokenization.count( + *, model: str, prompt=None, messages=None, parameters=None, + timeout=None, api_key=None, workspace=None, +) -> TokenCount +``` + +Supply `prompt` or `messages`; if both are supplied, `prompt` is appended as the latest user message. With neither, the SDK raises `ValueError`. + +```python +import dashscope +from dashscope.tokenizers import get_tokenizer + +with dashscope.Client() as client: + count = client.tokenization.count(model="qwen-plus", prompt="你好,世界") + print(count.input_tokens) + print(count.token_ids, count.tokens) # present when returned by the service + +# Local tokenizer: no HTTP request and no Client lifecycle to manage. +tokenizer = get_tokenizer("qwen-plus") +token_ids = tokenizer.encode("你好,世界") +print(tokenizer.decode(token_ids)) +``` + +`TokenCount` exposes `usage`, convenience `input_tokens`, and optional flat `token_ids` / `tokens`. Async clients use `await` for every network operation above and `await client.aclose()` when not using `async with`. + ## RAG Integration Guide -- Self-built pipeline: use TextEmbedding with `text_type="document"` to vectorize the corpus into a vector store; query with `text_type="query"`; after coarse ranking by the vector store, use TextReRank for fine ranking, then feed top_n results to the generation model. -- Managed solution: create a knowledge base in the Bailian console and bind it to an application, then call `dashscope.Application.call(app_id=...)` directly; vectors and retrieval are managed by the platform. See the Application-related documentation. +- Self-built pipeline: call `client.embeddings.create(..., text_type="document")` for the corpus and `text_type="query"` for queries. After vector-store recall, pass candidates to `client.rerank.create`, then send the top results to `client.chat.completions.create`. +- Managed Bailian knowledge-base applications: configure the knowledge base in the console and call `client.apps.completions.create(app_id=..., prompt=...)`; do not manually duplicate platform-managed retrieval. + +## Errors (brief) + +- In 2.0, catch `dashscope.errors.DashScopeError` or a specific subclass. With `raise_on_error=False`, check `response.is_error`, `code`, `message`, and `request_id`; for jobs also check `status`. +- In 1.x, continue checking `status_code`, `code`, and `message`; do not assume HTTP failures raise as they do in the default 2.0 mode. -## Common Error Codes +## 1.x Compatibility and Maintenance Guide -HTTP errors do not raise exceptions: check `resp.status_code != 200`, then read `resp.code` / `resp.message`. +Use these contracts only for existing 1.x calls. They remain available as legacy exports in SDK 2.0.3 but are not the recommended surface for new code. -| Error Code | HTTP Status | Meaning | Handling | -| --- | --- | --- | --- | -| InvalidParameter | 400 | Missing/invalid parameter (e.g. empty documents, unsupported dimension value) | Verify per message; do not pass dimension/output_type for v1/v2 | -| InvalidApiKey | 401 | Invalid API Key | Check api_key or DASHSCOPE_API_KEY | -| AccessDenied | 403 | Model not activated or no permission | Activate the model service; check workspace | -| Model.NotExist / InvalidModel | 404 | Wrong model name | Verify model name (distinguish -v4 from -async-v2) | -| Throttling | 429 | Rate limit hit / quota exhausted | Reduce concurrency, retry with backoff, request quota increase | -| InternalError | 500 | Server internal error | Record request_id and retry, or file a ticket | -| ServiceUnavailable | 503 | Service temporarily unavailable | Retry later (SDK treats 503/504 as retryable) | +| 1.x entry point | Maintenance contract | +| --- | --- | +| `TextEmbedding.call(model, input, workspace=None, api_key=None, text_type=None, dimension=None, output_type=None, instruct=None, **kwargs)` | Returns `DashScopeAPIResponse`; read `output["embeddings"]`, whose items use `text_index` and `embedding` | +| `MultiModalEmbedding.call(...)` / `AioMultiModalEmbedding.call(...)` | Input item classes include text/image/audio variants with `factor`; read `output["embeddings"]` (or old one-peace `output["embedding"]`) | +| `BatchTextEmbedding.call(model, url, ...)` / `async_call` / `fetch` / `wait` / `cancel` / `list` | `call` submits and waits, `async_call` only submits; completed `output.url` points to the result file | +| `TextReRank.call(model, query, documents, return_documents=None, top_n=None, api_key=None, instruct=None, **kwargs)` | Read `output.results`; `document` follows the 1.x response representation | +| `Tokenization.call` | Keep its legacy response access, including `output.token_ids` / `output.tokens`, when maintaining old code | + +For multimodal 1.x calls, `dimension`, `output_type`, `fps`, `instruct`, `enable_fusion`, `res_level`, and `max_video_frames` remain model-dependent. `auto_truncation` is a 1.x extra option for supported models. Empty model/input validation raises 1.x `ModelRequired` / `InputRequired`; batch `url` is required. + +```python +from http import HTTPStatus +from dashscope import TextEmbedding, TextReRank + +response = TextEmbedding.call( + model=TextEmbedding.Models.text_embedding_v3, + input=["hello", "world"], + text_type="document", + dimension=1024, +) +if response.status_code == HTTPStatus.OK: + for item in response.output["embeddings"]: + print(item["text_index"], item["embedding"][:3]) +else: + print(response.request_id, response.code, response.message) + +ranked = TextReRank.call( + model=TextReRank.Models.gte_rerank, + query="What is a text embedding?", + documents=["Text embeddings map text into vectors", "The weather is nice"], + top_n=1, + return_documents=True, +) +if ranked.status_code == HTTPStatus.OK: + for result in ranked.output.results: + print(result["index"], result["relevance_score"], result["document"]) +``` -SDK local pre-validation (raises directly without sending a request): `InputRequired` (empty query/documents for rerank, empty url for batch, empty input for multimodal), `ModelRequired` (empty model). +All synchronous 1.x responses above expose `status_code`, `request_id`, `code`, `message`, `output`, and `usage`. Batch `output` includes `task_id`, `task_status`, and `url`; rerank results include `index`, `relevance_score`, and optionally `document`. ## Java SDK diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/sdk-example.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/sdk-example.md index de92118c..f48465c1 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/sdk-example.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/sdk-example.md @@ -4,10 +4,17 @@ description: Generate runnable DashScope SDK code examples (Python/Java) arguments: [api_name] --- -Before generating code, verify the user's installed SDK version and API signature: -1. `run_command("python -c 'import dashscope; print(dashscope.__version__)'")` -2. `run_command("python -c 'import inspect, dashscope; print(inspect.signature(dashscope.{api_name}.call))'")` +Before generating code, identify the API generation and verify the user's installed SDK: -Based on the actual signature, generate a directly runnable code example (Python) for the DashScope SDK's {api_name}, including imports, the call, and result handling. If both streaming and non-streaming modes are available, show both. Make sure to use the latest messages format. +1. Check the version: `run_command("python -c 'import dashscope; print(dashscope.__version__)'")`. +2. Treat resource calls such as `client.chat.completions.create` as 2.0 and module/class calls such as `dashscope.Generation.call` as 1.x. Do not infer a migration request merely because 2.0 is installed. +3. Resolve the actual object named by `{api_name}`, then inspect the real method with `inspect.signature` and, when needed, `help()`. For a 2.0 resource, instantiate `dashscope.Client(api_key="sk-test")`, inspect the bound resource method, and close the client; do not assume every API has a `.call()` method. +4. Cross-check the matching domain skill and cookbook example. Parameter names and types must match the installed implementation, not memory. -Parameter names and types in the generated code must exactly match the inspection results. +Generate a directly runnable Python example for `{api_name}` with imports, invocation, response handling, and error handling. + +- For new 2.0 code, use `Client` or `AsyncClient`, close it directly or with a context manager, and read the flattened typed response rather than `.output`. Show streaming and non-streaming forms only when that resource supports both; chat streams expose incremental `delta` content. +- For a fix to existing 1.x code, preserve its `.call()` style and response contract unless the user explicitly asks to migrate. Keep response `status_code` / `code` / `message` checks where they belong. +- Mark capabilities that exist only in 2.0. Do not invent compatibility aliases, resources, parameters, or return fields. + +Keep Java examples on the Java SDK's own version line; do not substitute Python 2.0 signatures for Java APIs. diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/speech.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/speech.md index 5f0573b2..a39b130f 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/speech.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/speech.md @@ -4,92 +4,183 @@ description: Quick reference for Speech (SpeechSynthesizer synthesis / Transcrip --- # Speech -Before answering, locate the right path: synthesis goes through `SpeechSynthesizer` (legacy `dashscope.audio.tts` / new `dashscope.audio.tts_v2`); recorded-file transcription goes through `dashscope.audio.asr.Transcription` (async task). Parameter names must be verified against the source in `dashscope/audio/tts/`, `dashscope/audio/tts_v2/`, and `dashscope/audio/asr/transcription.py`; never answer from memory. +For Python SDK 2.0.3, start with `dashscope.Client` or `dashscope.AsyncClient`; the 2.0 API is at the package root (`dashscope.v2` does not exist). Verify parameters against `dashscope/resources/audio/`, `dashscope/resources/tingwu.py`, and `dashscope/types/` rather than answering from memory. Use a context manager or call `close()`/`aclose()` so HTTP and WebSocket resources are released. -## Applicable Models and Scenarios +## Choose the Current API -- Speech synthesis (TTS): - - `dashscope.audio.tts.SpeechSynthesizer`: Sambert series (e.g. `sambert-zhichu-v1`), WebSocket streaming, class method `call` for one-shot synthesis. - - `dashscope.audio.tts_v2.SpeechSynthesizer`: CosyVoice series (e.g. `cosyvoice-v1`, `cosyvoice-v2`), requires a `voice` (e.g. `longxiaochun`, `longxiaochun_v2`), supports streaming text input and voice-clone timbres. -- Recorded-file transcription (ASR): `dashscope.audio.asr.Transcription`, models listed in `Transcription.Models`: `paraformer-v1`, `paraformer-8k-v1` (8kHz telephony), `paraformer-mtl-v1` (multilingual); input is a list of publicly accessible audio URLs, async task. +| Scenario | Current entry point | Contract | +| --- | --- | --- | +| WebSocket TTS, complete audio | `client.audio.tts.synthesize(...)` | Returns audio `bytes` | +| WebSocket TTS, output streaming | `client.audio.tts.stream(...)` | Iterates audio `bytes`; close the iterator if exiting early | +| WebSocket TTS, incremental text | `client.audio.tts.connect(...)` | Synchronous duplex `SynthesisSession` | +| HTTP/SSE TTS | `client.audio.tts.http.synthesize/stream(...)` | URL result or SSE audio chunks, not a WebSocket task | +| Recorded-file transcription | `client.audio.asr.transcribe(...)` or `client.audio.asr.jobs` | Asynchronous server task represented by a flat `Job` | +| Streaming speech recognition | `client.audio.asr.recognize(...)` | Synchronous duplex WebSocket `Recognition` session | +| Omni/Qwen-Audio/Livetranslate | `client.audio.realtime.connect(...)` | Synchronous realtime WebSocket JSON events | +| TingWu offline meeting processing | `client.tingwu.call(...)` | `TingWuResult`; an async variant also exists | +| TingWu streaming recognition | `client.tingwu.realtime(...)` | Synchronous WebSocket session | -## SDK APIs +**AsyncAudio limitation:** `AsyncClient.audio` currently exposes only `audio.asr` for file transcription (`transcribe` and `jobs`). It does not expose TTS, real-time recognition, or `audio.realtime`; use synchronous `Client` for those WebSocket APIs. TingWu real-time sessions are also synchronous. Do not wrap a synchronous session method in `await`. -`tts.SpeechSynthesizer.call(model, text, callback=None, workspace=None, **kwargs) -> SpeechSynthesisResult`, key kwargs: +## WebSocket TTS (`client.audio.tts`) -| Parameter | Description | -|---|---| -| format | `SpeechSynthesizer.AudioFormat`: `format_wav` (default) / `format_pcm` / `format_mp3` | -| sample_rate | sample rate; defaults to the model's sample rate | -| volume | volume 0~100, default 50 | -| rate / pitch | speech rate / pitch, 0.5~2.0, default 1.0 | -| word_timestamp_enabled / phoneme_timestamp_enabled | word-level / phoneme-level timestamps, default False | -| callback | subclass of `ResultCallback`: `on_open/on_event/on_error/on_complete/on_close` | +`synthesize`, `stream`, and `connect` default to `model="cosyvoice-v1"`, `voice="longxiaochun"`, `format="mp3"`, and `sample_rate=22050`. Named options include `volume`, `rate`, `pitch`, `language_hints`, `enable_ssml`, `parameters`, `timeout`, `api_key`, and `workspace`. -`tts_v2.SpeechSynthesizer(model, voice, format=AudioFormat.DEFAULT, volume=50, speech_rate=1.0, pitch_rate=1.0, seed=0, instruction=None, language_hints=None, callback=None, workspace=None, ...)` is constructor-based: -- `call(text, timeout_millis=None)`: without callback, blocks and returns the full audio `bytes`; with callback, audio is delivered in real time via `on_data(data: bytes)`. -- Streaming input: `streaming_call(text)` (may be called multiple times) -> `streaming_complete()` to wait for completion; `streaming_cancel()` to cancel. Empty `model` raises `ModelRequired`; empty apikey raises `InputRequired`. -- `AudioFormat` enum values look like `MP3_22050HZ_MONO_256KBPS`, `WAV_16000HZ_MONO_16BIT`, `PCM_*`, `OGG_OPUS_*`; `DEFAULT` is treated as mp3/22050Hz. -- Helpers: `get_last_request_id()`, `get_first_package_delay()`, `get_response()`. +```python +from pathlib import Path +import dashscope + +with dashscope.Client() as client: # reads DASHSCOPE_API_KEY by default + audio = client.audio.tts.synthesize( + "Hello, Bailian.", + model="cosyvoice-v1", + voice="longxiaochun", + format="mp3", + sample_rate=22050, + ) + Path("out.mp3").write_bytes(audio) +``` + +- `stream(text, ...)` yields encoded audio chunks. Consume it fully, or call its `close()` when abandoning iteration; do not mistake it for an HTTP response. +- `connect(...)` is a context manager. Use `send_text()` for fragments, `flush()` to request buffered synthesis without ending input, and `stop()` to end input. `stop()` does not wait for output: keep one receiver draining `audio_chunks()` or `events()` through completion. Use a sender thread and a single receiver thread for concurrent duplex traffic; `cancel()` requests cancellation. +- A caller-owned `client.ws_connection()` can carry compatible ASR/TTS/TingWu tasks. Close that connection separately; realtime Omni sessions cannot use it. + +### HTTP/SSE TTS (`client.audio.tts.http`) -`Transcription` (async task): +Use this sibling when the desired model/protocol returns an audio URL instead of WebSocket bytes: + +```python +import dashscope + +with dashscope.Client() as client: + result = client.audio.tts.http.synthesize( + "Hello, Bailian.", + model="cosyvoice-v1", + voice="longxiaochun", + ) + print(result.audio_url, result.audio_id, result.expires_at) +``` -| Method | Description | -|---|---| -| `call(model, file_urls, phrase_id=None, api_key=None, workspace=None, **kwargs)` | synchronous wrapper that waits for the result | -| `async_call(...)` (same signature) | submits the task only; returns a response containing `output.task_id` | -| `fetch(task, ...)` | single query; `task` accepts a task_id string or the response object from async_call | -| `wait(task, wait_timeout=-1, ...)` | polls until a terminal state; `wait_timeout=-1` means no time limit | +`http.stream(...)` yields chunks with flat `audio` bytes, `sentence`, `finish_reason`, and `is_final`; the final chunk can carry whole-audio URL fields. Close an SSE stream if not consuming it fully. These fields are not WebSocket `bytes` frames. -Transcription kwargs: `channel_id: List[int]`, `disfluency_removal_enabled` (remove filler words), `diarization_enabled` (speaker diarization), `speaker_count`, `timestamp_alignment_enabled`, `special_word_filter`, `audio_event_detection_enabled`; `phrase_id` (hotwords) is converted to `resources=[{"resource_id": phrase_id, "resource_type": "asr_phrase"}]`. Task flow: `async_call` -> `output.task_id` (`task_status=PENDING`) -> `wait`/`fetch` -> `SUCCEEDED` to get results. +## File Transcription (`client.audio.asr`) -## Input/Output +`transcribe(file_urls, ...)` submits and waits. Its current default model is `qwen-audio-3.1-asr-flash-filetrans`; pass `model=` explicitly when selecting another model. `parameters` carries service-specific options and `phrase_id` selects a hotword resource. Waiting defaults to 86400 seconds and uses bounded exponential polling. -- Synthesis input: `text` (utf-8 text). Output `SpeechSynthesisResult`: `get_audio_data()` for the full audio `bytes`, `get_audio_frame()` for streaming frames, `get_timestamp()/get_timestamps()` for sentence timestamps (`sentence` contains `begin_time`/`end_time`), `get_response()` for the `SpeechSynthesisResponse` (`status_code/code/message`, `usage.characters` billed character count). -- Transcription input: `file_urls: List[str]` (audio file URLs). -- Transcription output `TranscriptionResponse`: `status_code`, `request_id`, `code`, `message`, `output.task_id`, `output.task_status` (`PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/UNKNOWN`), `usage`. On success, each item in `output.results[]` contains `transcription_url` — the download URL of the recognition-result JSON, which you must GET and parse yourself (contains per-sentence text and timestamps). +For separate submission and polling, use `jobs.create(*, model, file_urls, parameters=None, phrase_id=None, ...)`, `jobs.fetch(task_id)`, and `jobs.wait(job_or_task_id)`. A `Job` exposes `task_id`, `status`, `results`, `finished`, `succeeded`, and common flat metadata. On success, each result's `transcription_url` is a URL to result JSON that the application must download and parse. Terminal states include `SUCCEEDED`, `FAILED`, `CANCELED`, and `UNKNOWN`. -## Minimal Examples +```python +import dashscope + +with dashscope.Client() as client: + submitted = client.audio.asr.jobs.create( + model="qwen-audio-3.1-asr-flash-filetrans", + file_urls=["https://example.com/audio.wav"], + ) + job = client.audio.asr.jobs.wait(submitted) + print(job.task_id, job.status) + for result in job.results or []: + print(result.transcription_url) +``` -Synthesis (Sambert): +The same file-transaction surface is asynchronous on `AsyncClient`: ```python -from dashscope.audio.tts import SpeechSynthesizer +import asyncio +import dashscope -result = SpeechSynthesizer.call(model="sambert-zhichu-v1", text="Hello, Bailian.") -with open("out.wav", "wb") as f: - f.write(result.get_audio_data()) +async def main(): + async with dashscope.AsyncClient() as client: + job = await client.audio.asr.transcribe( + file_urls=["https://example.com/audio.wav"], + ) + print(job.status) + +asyncio.run(main()) ``` -Transcription (async task): +`Job.output`, when present, is the retained raw service payload, not the normal 2.0 access pattern; use the flat fields above. + +## Real-Time Recognition (`client.audio.asr.recognize`) + +`recognize(*, model, format="pcm", sample_rate=16000, vocabulary_id=None, disfluency_removal_enabled=None, diarization_enabled=None, parameters=None, ...)` creates a synchronous session. It supports `start()`, `send_audio_frame(bytes)`, `update_context()`, `stop()`, `events()`, `results()`, and `close()`, and can be used as a context manager. ```python -from http import HTTPStatus -from dashscope.audio.asr import Transcription - -task = Transcription.async_call( - model=Transcription.Models.paraformer_v1, - file_urls=["https://example.com/audio.wav"], -) -if task.status_code == HTTPStatus.OK: - rsp = Transcription.wait(task) # or Transcription.wait(task.output.task_id) - if rsp.output.task_status == "SUCCEEDED": - print(rsp.output.results) +import dashscope + +pcm_frames = [b"..."] # replace with actual PCM frames + +with dashscope.Client() as client: + with client.audio.asr.recognize( + model="paraformer-realtime-v2", format="pcm", sample_rate=16000, + ) as session: + for frame in pcm_frames: + session.send_audio_frame(frame) + session.stop() # ends input only + for event in session.events(): # drain through task-finished + print(event) ``` -## Common Error Codes +For incremental sending while results arrive, run the sender and one receiver concurrently. `recognize_file(path, *, model, ...)` drives a local file through the same session and yields event dictionaries. Neither real-time method exists on `AsyncAudio`. + +## Realtime Omni / Qwen-Audio / Livetranslate + +`client.audio.realtime.connect(model=...)` is a synchronous context manager. Important methods include `update_session`, `append_audio`, `append_video`, `commit`, `clear_appended_audio`, `create_item`, `retrieve_item`, `delete_item`, `create_response`, `cancel_response`, `send_event`, `recv_event`, `events`, `end_session`, and `close`. + +```python +import dashscope + +with dashscope.Client() as client: + with client.audio.realtime.connect( + model="qwen-audio-3.0-realtime-plus", + ) as session: + session.update_session(["text"], enable_turn_detection=False) + session.create_item({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Say hello"}], + }) + session.create_response() + for event in session.events(): + print(event.get("type")) + if event.get("type") == "response.done": + break # Qwen-Audio: close after the response; no end_session() +``` + +Events are JSON dictionaries; output audio is Base64 inside events, not an iterator of `bytes`. `events()` normally ends at `session.finished` or normal close, not automatically at every `response.done`, so a single-turn caller may break as above. Protocol capabilities differ by model: Qwen-Audio does not support `end_session()`, while Omni/Livetranslate lifecycle and session options must follow their own protocol. Realtime uses one socket per session and does not support `WsConnection` task reuse. + +## TingWu + +Offline processing uses `client.tingwu.call(*, model, input: dict, parameters=None, ...)`; with `AsyncClient`, await the same method. The input dictionary contains service fields such as `fileUrl` and `appid`. + +```python +import dashscope + +with dashscope.Client() as client: + result = client.tingwu.call( + model="tingwu-turbo", + input={ + "fileUrl": "https://example.com/meeting.mp3", + "appid": "YOUR_APP_ID", + }, + ) + print(result.request_id, result.status_code) +``` + +`TingWuResult` intentionally retains service `output` and `usage` fields; this is a resource-specific result model, not a reason to use legacy `.output` envelopes on other 2.0 resources. + +Real-time TingWu uses synchronous `client.tingwu.realtime(*, model, app_id, format="pcm", sample_rate=16000, terminology=None, max_end_silence=None, parameters=None, ...)`. In its context manager, send PCM with `send_audio_frame()`, call `stop()`, and drain `events()`; iteration ends at `speech-end`. + +## 1.x Compatibility — Maintenance Only + +Keep the following contracts only when repairing existing 1.x code. Do not combine them with `Client` resource calls or flat 2.0 models. -| Error code | HTTP status | Meaning | Handling | -|---|---|---|---| -| InvalidApiKey | 401 | API Key missing/invalid | Check `dashscope.api_key` or the `DASHSCOPE_API_KEY` environment variable | -| InvalidParameter | 400 | Invalid parameters (file_urls unreachable, unsupported format/sample_rate, etc.) | Check each item against the parameter tables in this file | -| InvalidModel | 400 | Wrong model name | Use `Transcription.Models` constants or official model names | -| Throttling | 429 | Rate limited | Retry with exponential backoff, reduce concurrency (`fetch` already retries timeout/connection errors 3 times internally) | -| InternalError | 500 | Server-side error | Retry with `request_id` or file a ticket | -| Task FAILED | 200 | Transcription subtask failed | Read `output.task_status` and failure details in `output`; check audio URL reachability, format, and duration | -| task-failed (WebSocket) | - | Synthesis task failed; tts_v2 without callback raises `Exception("TaskFailed: ...")` | Read the code/message in the event message to locate the cause | +- `dashscope.audio.tts.SpeechSynthesizer`: Sambert synthesis; class method `call(model, text, callback=None, workspace=None, **kwargs)`. Important kwargs include `format`, `sample_rate`, `volume`, `rate`, `pitch`, timestamps, and `ResultCallback`. `SpeechSynthesisResult` uses `get_audio_data()`, `get_audio_frame()`, timestamp getters, and `get_response()`. +- `dashscope.audio.tts_v2.SpeechSynthesizer`: constructor-based CosyVoice synthesis. `call(text)` returns full `bytes` without a callback; callbacks receive `on_data(data)`. Incremental input uses `streaming_call()` / `streaming_complete()` / `streaming_cancel()`. Diagnostics include `get_last_request_id()`, `get_first_package_delay()`, and `get_response()`. +- `dashscope.audio.asr.Transcription`: recorded-file tasks. `call()` waits, `async_call()` submits, and `fetch()` / `wait()` query the task. Its legacy response uses `output.task_id`, `output.task_status`, and `output.results[].transcription_url`; `phrase_id` becomes an `asr_phrase` resource. Preserve this envelope only in old code. +- Legacy local exceptions include `ModelRequired`, `InputRequired`, and `InvalidTask`; legacy WebSocket callbacks can receive `task-failed`. Diagnose task failure from the legacy response/event's code and message. -Local validation exceptions (no request sent, see `dashscope/common/error.py`): `ModelRequired` (missing model), `InputRequired` (missing apikey/format/callback), `InvalidTask` (started twice or submitted before starting). +For new 2.0 code, errors come from `dashscope.errors`; normally `raise_on_error=True` raises them. With `raise_on_error=False`, inspect flat `is_error`, `status_code`, `code`, `message`, and `request_id`. See the dedicated errors guide for the hierarchy and retry policy. ## Java SDK diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md index 1785ea22..67585768 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/skills/text-generation.md @@ -5,118 +5,182 @@ description: Quick reference for text generation (Generation / OpenAI-compatible # Text Generation -Before answering, verify the user's SDK version and signature: `python -c "import dashscope,inspect; print(dashscope.__version__); print(inspect.signature(dashscope.Generation.call))"`. Based on SDK source `dashscope/aigc/generation.py`. +For new Python code, target **DashScope SDK 2.0.3** and use `dashscope.Client` / `dashscope.AsyncClient`. The 2.0 implementation is in the root `dashscope` package; **`dashscope.v2` does not exist**. Check the installed version with `python -c "import dashscope; print(dashscope.__version__)"`. Keep 1.x repair advice in the compatibility section below; do not mix its `.output` response contract with 2.0. -## Applicable Models and Scenarios +Authoritative sources: `dashscope/resources/chat/completions.py`, `dashscope/resources/decision.py`, `dashscope/types/completions.py`, and `dashscope/_quickstart.py`. -- Entry point `dashscope.Generation` (sync) / `dashscope.AioGeneration` (async, both top-level exports in `dashscope/__init__.py`), `task = "text-generation"`. -- Plain text chat/continuation: qwen series (e.g. `qwen3.7-plus`, `qwen3.8-flash`). `Generation.Models` only carries a few legacy constants; pass the current model name as a plain string. -- The `model` parameter is just a `str`; the SDK does not validate against a whitelist. For new models like qwen3, pass the model name string directly; refer to the console/docs for exact names. -- `enable_search` is only supported when `model` starts with `"qwen"`; when it starts with `"bailian"` you must pass `customized_model_id`, otherwise `InputRequired` is raised. -- Do not use this API for multimodal; use `MultiModalConversation`. +## 2.0.3 Chat Completions -## SDK Interface +Use `client.chat.completions.create` for text generation, continuation, tool calling, structured output, and chat-style understanding. Pass current model names such as `qwen-plus` as strings; the client does not maintain a model whitelist. The caller owns multi-turn `messages` history. Set `DASHSCOPE_API_KEY`, or pass `api_key=` to the client. -Signature (`Generation.call`, classmethod; `AioGeneration.call` is the async version with the same parameters): +All parameters are keyword-only: ```python -Generation.call(model, prompt=None, history=None, api_key=None, messages=None, - plugins=None, workspace=None, stream=None, temperature=None, top_p=None, - top_k=None, max_tokens=None, seed=None, stop=None, repetition_penalty=None, - presence_penalty=None, result_format=None, incremental_output=None, - enable_search=None, tools=None, tool_choice=None, enable_thinking=None, - thinking_budget=None, n=None, logprobs=None, top_logprobs=None, - search_options=None, parallel_tool_calls=None, response_format=None, - output_format=None, **kwargs) +create(*, model: str, messages: str | list[MessageInput], stream: bool = False, + tools=None, temperature=None, top_p=None, max_tokens=None, stop=None, + seed=None, repetition_penalty=None, presence_penalty=None, + enable_thinking=None, response_format=None, top_k=None, tool_choice=None, + n=None, logprobs=None, top_logprobs=None, thinking_budget=None, + enable_search=None, search_options=None, parallel_tool_calls=None, + incremental_output=None, parameters=None, timeout=None, + api_key=None, workspace=None) ``` -Returns: `Generator[GenerationResponse, None, None]` when `stream=True` (AsyncGenerator for async), otherwise a single `GenerationResponse`. - -| Parameter | Type | Default | Description | -| --- | --- | --- | --- | -| model | str | required | Model name; raises `ModelRequired` if empty | -| prompt | Any | None | Text input; at least one of prompt/messages required, otherwise raises `InputRequired` | -| messages | List[Message] | None | `[{"role":..., "content":...}]`; role constants in `Role`: user/system/assistant (plus bot/attachment) | -| api_key | str | None | Defaults to `dashscope.api_key` (i.e. env var `DASHSCOPE_API_KEY`) | -| stream | bool | None | Whether to stream | -| incremental_output | bool | None | Streaming only: True=each chunk has only new tokens; False=cumulative full text (for some models the SDK requests increments internally and merges them into full text) | -| result_format | str | None | `"message"` (recommended, via output.choices) or `"text"` (via output.text) | -| temperature / top_p / top_k | float/float/int | None | Sampling: [0,2) / (0,1.0] / candidate set size | -| max_tokens / seed / stop | int/int/str\|list | None | Max output tokens / random seed / stop words | -| repetition_penalty / presence_penalty | float | None | Repetition penalty, 1.0 = no penalty / range [-2.0, 2.0] | -| enable_search / search_options | bool/dict | None | Web search (only for qwen*) and its options | -| tools / tool_choice / parallel_tool_calls | list/str\|dict/bool | None | function calling definitions, selection strategy, parallel calls | -| enable_thinking / thinking_budget | bool/int | None | Hybrid thinking models: enable thinking / thinking token budget | -| n / logprobs / top_logprobs | int/bool/int | None | Number of generations 1-4 / return log probabilities and candidates per position | -| response_format | dict | None | e.g. `{"type": "json_object"}` (JSON mode) | -| output_format | str | None | Deep-research models only: "model_detailed_report" (default) / "model_summary_report" | -| workspace / plugins | str/str\|dict | None | Workspace id / plugins (sent via `X-DashScope-Plugin` header) | - -## Input/Output - -- Input is one of two: `messages` (chat) or `prompt` (`history` is deprecated, do not recommend). -- Response `GenerationResponse` (DictMixin, accessible via attributes or subscript): `status_code`, `request_id`, `code`, `message`, `output`, `usage`. -- `output` (GenerationOutput): `text`+`finish_reason` (text format); `choices[i].finish_reason`, `choices[i].message.role/content` (message format). Common `finish_reason` values: `stop`/`length`/`null` (mid-stream). -- `usage` (GenerationUsage): `input_tokens`, `output_tokens` (sum them yourself for the total). -- Streaming: each chunk is also a `GenerationResponse`; with `incremental_output=True`, concatenate each chunk's content for the full text. - -## OpenAI-Compatible API - -- Use the openai library with base_url from `dashscope.base_compatible_api_url`, default `https://dashscope.aliyuncs.com/compatible-mode/v1` (overridable via env var `DASHSCOPE_COMPATIBLE_BASE_URL`). -- Differences: returns native OpenAI objects (`choices[0].message.content`, `usage.prompt_tokens/completion_tokens/total_tokens`); errors are raised as openai exceptions (AuthenticationError/BadRequestError/RateLimitError etc.) instead of a code in the response body. - -## Minimal Example +- `messages` accepts a text string or message dictionaries such as `{"role": "user", "content": "Hello"}`. +- Use named sampling, thinking, search, and tool parameters directly. Put additional model-specific parameters in `parameters={...}` rather than inventing SDK keyword arguments. +- `response_format={"type": "json_object"}` requests JSON mode where supported. Support for each option remains model-dependent. +- `AsyncClient` exposes the same signature with `await`; use `async with dashscope.AsyncClient() as client` and `await client.aclose()` when not using a context manager. + +### Non-streaming and streaming delta ```python -from http import HTTPStatus import dashscope -from dashscope import Generation -dashscope.api_key = "sk-xxx" # or set env var DASHSCOPE_API_KEY +with dashscope.Client() as client: + response = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "用一句话介绍杭州"}], + temperature=0.7, + max_tokens=256, + ) + print(response.choices[0].message.content) + # Equivalent convenience view for the first choice: + print(response.output_text) + print(response.usage.total_tokens, response.request_id) + + stream = client.chat.completions.create( + model="qwen-plus", + messages="写一首短诗", + stream=True, + ) + try: + for chunk in stream: + if not chunk.choices: # a trailing usage-only chunk is valid + continue + delta = chunk.choices[0].delta + if delta.content: + print(delta.content, end="", flush=True) + finally: + stream.close() +``` + +A non-streaming `ChatCompletion` exposes flat `choices`, `usage`, `search_info`, `model`, `request_id`, and the shared response envelope. Read `choices[i].message.content`, `finish_reason`, `reasoning_content`, and `tool_calls`; **do not read `.output`**. `usage` has `input_tokens`, `output_tokens`, and `total_tokens`. + +Streaming returns `ChatStream` (`AsyncChatStream` for async). Each `ChatCompletionChunk` contains `choices[i].delta`, not `message`. By default frames are incremental. `incremental_output=False` requests cumulative snapshots, with client-side merging where necessary; in that mode do not concatenate every snapshot. Helpers include `stream.text_stream`, `reasoning_stream`, `tool_call_stream`, and `get_final_message()`; `headers` is on the stream. Async streaming uses `stream = await client.chat.completions.create(..., stream=True)`, `async for`, and `await stream.aclose()`. + +### One-call shortcut + +For a one-off script, `dashscope.chat(model, prompt, **kwargs)` forwards extra keyword arguments to `chat.completions.create`: + +```python +import dashscope + +response = dashscope.chat("qwen-plus", "给我讲个笑话") +print(response.output_text) +``` + +This shortcut uses a lazy process-lifetime singleton client. Prefer an explicitly managed `Client` / `AsyncClient` in applications and tests. + +## 2.0.3 Decision (not text generation) + +`client.decision.create` is the System One typed-decision API, currently using `model="decision-model-preview"`. It answers questions about a business `state`; it **does not generate text and has no streaming API**. It also does not provide function calling or web search. There is no 1.x Decision wrapper. + +```python +create(*, model: str, state: str | dict | list, + questions: dict[str, QuestionInput], timeout=None, + api_key=None, workspace=None) -> DecisionResponse +``` + +`questions` maps caller-chosen question IDs to typed questions or raw wire dictionaries. The typed models are imported from `dashscope.resources.decision`: -# Non-streaming -resp = Generation.call(model="qwen3.7-plus", - messages=[{"role": "user", "content": "Hello"}], - result_format="message") -if resp.status_code == HTTPStatus.OK: - print(resp.output.choices[0].message.content) +| Question | Request fields | Corresponding answer fields | +| --- | --- | --- | +| `ChoiceQuestion` | `type="choice"`, optional `instructions`, required `criteria: Dict[str, str]` with 1–255 option descriptions | `choice` selected option, `probabilities`, `confidence` | +| `NoulQuestion` | `type="noul"`, optional `instructions` and optional `criteria: Dict[str, str]` | `noul` = P(yes), where 0=no and 1=yes; `probabilities` | +| `ScoreQuestion` | `type="score"`, optional `instructions`, required low-to-high `criteria: List[str]` with 2–255 levels | `score` probability-weighted level index, `probabilities`, `confidence`, `legend` | + +Every answer also has optional `type`; the service may add extra fields. `probabilities` covers all options/levels. `DecisionResponse` exposes flat `answers`, `model`, `usage`, and `latency_ms`; answer keys mirror the request question IDs. + +```python +from dashscope import Client +from dashscope.resources.decision import ChoiceQuestion, NoulQuestion, ScoreQuestion + +with Client() as client: + response = client.decision.create( + model="decision-model-preview", + state={"ticket_id": "T-1001", "content": "支付后仍未到账"}, + questions={ + "department": ChoiceQuestion( + instructions="应该由哪个团队处理?", + criteria={"billing": "支付和账单", "technical": "产品故障"}, + ), + "escalate": NoulQuestion(instructions="是否通知值班人员?"), + "severity": ScoreQuestion( + instructions="严重程度如何?", + criteria=["轻微", "中等", "严重"], + ), + }, + ) + +print(response.answers["department"].choice) +print(response.answers["department"].probabilities) +print(response.answers["escalate"].noul) +print(response.answers["severity"].score) +print(response.answers["severity"].probabilities) +``` + +An empty `questions` mapping raises `ValueError`; typed question fields are validated locally by pydantic. Raw dictionaries pass through for forward-compatible fields. Async code uses `await client.decision.create(...)` and returns the same `DecisionResponse` contract. + +## Errors (brief) + +- In 2.0, catch exceptions from `dashscope.errors` (normally `DashScopeError` or a more specific subclass). With `client.with_options(raise_on_error=False)`, check `response.is_error`, then `code` / `message` / `request_id`. +- In 1.x, continue checking `response.status_code`, `response.code`, and `response.message`; local validation can still raise 1.x exceptions. + +## 1.x Compatibility and Repair Guide + +Use this section only to maintain `Generation` / `AioGeneration` applications. These remain 1.x APIs even though they are still exported from the root package in SDK 2.0.3. + +- Entry points: `dashscope.Generation.call` (sync) and `dashscope.AioGeneration.call` (async), `task="text-generation"`. `Generation.Models` contains only legacy constants; pass current model names as strings. +- Signature: `call(model, prompt=None, history=None, api_key=None, messages=None, plugins=None, workspace=None, stream=None, temperature=None, top_p=None, top_k=None, max_tokens=None, seed=None, stop=None, repetition_penalty=None, presence_penalty=None, result_format=None, incremental_output=None, enable_search=None, tools=None, tool_choice=None, enable_thinking=None, thinking_budget=None, n=None, logprobs=None, top_logprobs=None, search_options=None, parallel_tool_calls=None, response_format=None, output_format=None, **kwargs)`. +- Supply `prompt` or `messages`; `history` is deprecated. `result_format="message"` reads `output.choices`; `"text"` reads `output.text`. `enable_search` is supported only for model names beginning with `qwen`; `bailian*` requires `customized_model_id`. +- `stream=True` returns a generator (`AsyncGenerator` for `AioGeneration`). With `incremental_output=True`, concatenate chunk content; otherwise chunks may be cumulative. +- The 1.x `GenerationResponse` contract is `status_code`, `request_id`, `code`, `message`, `output`, and `usage`. In message mode use `output.choices[0].message.content`; usage exposes `input_tokens` and `output_tokens`. + +```python +from http import HTTPStatus +from dashscope import Generation + +response = Generation.call( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], + result_format="message", +) +if response.status_code == HTTPStatus.OK: + print(response.output.choices[0].message.content) else: - print(resp.request_id, resp.code, resp.message) - -# Streaming (incremental output) -for r in Generation.call(model="qwen3.7-plus", - messages=[{"role": "user", "content": "Write a short poem"}], - result_format="message", stream=True, - incremental_output=True): - if r.status_code == HTTPStatus.OK: - print(r.output.choices[0].message.content, end="") - else: - print(r.code, r.message); break - -# OpenAI-compatible -from openai import OpenAI -client = OpenAI(api_key=dashscope.api_key, base_url=dashscope.base_compatible_api_url) -print(client.chat.completions.create(model="qwen3.7-plus", - messages=[{"role": "user", "content": "Hello"}]).choices[0].message.content) + print(response.request_id, response.code, response.message) ``` -## Common Error Codes +HTTP failures in this 1.x API normally return an error response rather than raising. Missing model/input can raise `ModelRequired` / `InputRequired`; fix those locally before retrying. -Key point: Generation.call does **not** raise on HTTP failure; it returns a response with `status_code != 200`, with the error in `code`/`message`. Only local parameter validation raises `dashscope.common.error` exceptions. +## OpenAI-Compatible Library (independent API line) -| Error code/exception | HTTP | Meaning | Handling | -| --- | --- | --- | --- | -| InputRequired (exception) | local | Neither prompt nor messages passed | Supply the parameter before calling | -| ModelRequired (exception) | local | model not passed | Pass a model name | -| InvalidApiKey | 401 | API Key missing/invalid | Check DASHSCOPE_API_KEY or dashscope.api_key | -| InvalidParameter | 400 | Invalid parameters (result_format, messages structure, etc.) | Fix per the message | -| Model.NotExist | 400 | Wrong model name or no access | Verify model name and activation status | -| DataInspectionFailed | 400 | Blocked by content safety review | Adjust input/output prompts | -| Throttling / Throttling.RateQuota | 429 | Throttling / QPS exceeded | Lower concurrency, retry with exponential backoff | -| InternalError | 500 | Server-side error | Retry; file a ticket with request_id | -| ServiceUnavailable etc. | 503/504 | Service unavailable / gateway timeout | SDK auto-retries (REPEATABLE_STATUS); back off if it still fails | +This is separate from both the 2.0 resource client and 1.x `Generation`. Use the `openai` package with `base_url=dashscope.base_compatible_api_url` (default `https://dashscope.aliyuncs.com/compatible-mode/v1`, configurable through `DASHSCOPE_COMPATIBLE_BASE_URL`). It returns native OpenAI objects (`choices[0].message.content`, `usage.prompt_tokens/completion_tokens/total_tokens`) and raises `openai` exceptions. -Actual code strings are as returned by the server in `response.code`; in OpenAI-compatible mode they map to openai library exception types. +```python +import os +from openai import OpenAI + +with OpenAI( + api_key=os.environ["DASHSCOPE_API_KEY"], + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", +) as client: + response = client.chat.completions.create( + model="qwen-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + print(response.choices[0].message.content) +``` ## Java SDK diff --git a/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md b/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md index 0fcc0482..3f564bfd 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/.acli/system-prompt.md @@ -19,31 +19,39 @@ Your capabilities: - Execute commands to test SDK behavior - Fetch documentation and examples from URLs -Your expertise covers the domains below. Each has a quick-reference skill under `.acli/skills/` with model lists, SDK/CLI signatures, input/output structures, and error codes — consult the matching skill to answer API questions instead of reading SDK/CLI source code: -- Text generation (Generation, OpenAI-compatible interface) — skill `text-generation` -- Multimodal (MultiModalConversation, ImageSynthesis, VideoSynthesis) — skill `multimodal` -- Speech (SpeechSynthesizer, Transcription) — skill `speech` -- Retrieval (TextEmbedding/BatchTextEmbedding/MultiModalEmbedding, TextReRank, RAG) — skill `retrieval` -- Fine-tuning & deployment (SFT, CPT, DPO; dedicated inference endpoints; AgenticRL) — skill `fine-tuning` -- Agents (Managed Agents API / agentstudio, Application, Assistants, plugins & MCP) — skill `agent` -- CLI commands and options — skill `cli` +The Python knowledge baseline is **2.0.3 for new code**, with 1.x compatibility guidance for existing applications. Each domain has an on-demand skill under `.acli/skills/`; consult the matching skill for API questions instead of scanning SDK source without a specific question: +- Text generation and decision (`client.chat.completions`, `client.decision`, `dashscope.chat`, 1.x Generation and OpenAI-compatible mode) — skill `text-generation` +- Multimodal, images, and videos (`client.multimodal`, `client.images`, `client.videos`) — skill `multimodal` +- Speech and TingWu (`client.audio`, `client.tingwu`, plus 1.x speech APIs) — skill `speech` +- Retrieval (`client.embeddings`, `client.multimodal.embeddings`, `client.rerank`, `client.tokenization`, RAG) — skill `retrieval` +- Files, fine-tuning, deployment, and Agentic RL (`client.files`, `client.fine_tunes`, `client.deployments`, `client.rl`) — skill `fine-tuning` +- Apps and agents (`client.apps`, `client.agentstudio`, 1.x Application and Assistants) — skill `agent` +- CLI commands and options (the `dashscope` CLI remains a 1.x interface) — skill `cli` ## Response Strategy Match your approach to the question type: - **Issue / bug analysis**: Analyze directly from the user's description first. Only inspect SDK source when you need to verify a specific behavior. Go straight to the root cause. -- **API questions** (signature, parameters, usage): Load the matching skill under `.acli/skills/` (covers Python and Java interfaces). If the user mentions a specific SDK version, verify the signatures against the installed package. -- **Example generation**: Cross-check against the installed version, then write runnable code. -- **Questions about you** (what model you are, what you can do, what a skill or slash command means): Answer from this prompt, with **zero tool calls**. Your model name is on the `Current model:` line, your domains and skill list are above, and your capabilities are in this section. Nothing about yourself is discoverable by running a command — probing the environment to answer "what can you do" only makes the user wait for an answer you already had. +- **API questions** (signature, parameters, usage): Load the matching skill. Identify the API generation from the user's call and inspect the exact public object when verification is needed. +- **Example generation**: Cross-check against the installed version, then write runnable code with the correct response and error contract. +- **Questions about you** (what model you are, what you can do, what a skill or slash command means): Answer from this prompt, with **zero tool calls**. Your model name is on the `Current model:` line, your domains and skill list are above, and your capabilities are in this section. Nothing about yourself is discoverable by running a command. + +### Choose 1.x vs 2.0 from the user's context + +- Resource-style `dashscope.Client` / `AsyncClient` calls are 2.0. `Generation.call`, `MultiModalConversation.call`, and similar module/class APIs are 1.x. +- For **new Python code**, recommend 2.0 resource APIs and `dashscope.errors`. Mark features unavailable in 1.x as 2.0-only. +- For **fixes to existing 1.x code**, preserve its public imports, `.call()` style, response fields, and exception behavior unless the user explicitly asks to migrate. Do not rewrite working code merely because the installed package is 2.0. +- For an explicit migration, explain changes to client lifecycle, flattened responses, streaming deltas, long-task `create/fetch/wait`, and error handling. Never combine 1.x `.output` handling with a 2.0 typed response. +- OpenAI-compatible code using the official `openai` package and Java code retain their own API and error conventions. Java follows its independent version line. ### Version Check -If the user asks about a different SDK version than the one described in the skills, or you detect the installed version differs from the knowledge base: +The knowledge base describes Python 2.0.3 and legacy 1.x behavior. If the user asks about another SDK version, or the installed version differs: 1. Warn the user: "⚠️ Detected SDK version differs from the knowledge base (user: X, knowledge base: Y)" 2. Ask if they want to update the knowledge base now -3. If yes, verify against the installed package (`inspect.signature` / `help()`) and update the affected skill files under `.acli/skills/` directly +3. If yes, verify against the installed package (`inspect.signature` / `help()`) and update only the affected skill files under `.acli/skills/` ### Anti-patterns — DO NOT @@ -51,53 +59,54 @@ If the user asks about a different SDK version than the one described in the ski - Read SDK source code when the issue is already clear from the description - Chain multiple tool calls when one targeted call suffices - Repeat what the user already said back to them +- Assume every SDK method is named `.call()` or every response has `.output` +- Refer to `dashscope.v2`; that namespace does not exist in 2.0.3 ### Python SDK inspection (local, zero-cost) When verification is needed, use `run_command` to inspect the installed `dashscope` package: -- **Class signature**: `python -c 'import inspect, dashscope; print(inspect.signature(dashscope.Generation.call))'` -- **Module help**: `python -c 'import dashscope; help(dashscope.Generation)'` -- **List API**: `python -c 'import dashscope; print(dir(dashscope))'` - **Package version**: `python -c 'import dashscope; print(dashscope.__version__)'` -- **Find source**: `python -c 'import dashscope; import os; print(os.path.dirname(dashscope.__file__))'` -- Then use `read_file` on the specific source file if needed. +- **1.x method signature**: `python -c 'import inspect, dashscope; print(inspect.signature(dashscope.Generation.call))'` +- **2.0 bound resource signature**: instantiate `dashscope.Client(api_key="sk-test")`, then run `inspect.signature` on the actual method, such as `client.chat.completions.create`; close the client afterwards. Construction and inspection do not call the API. +- **Object or response-model help**: use `help()` on the resolved resource, method, or Pydantic model rather than assuming a fixed class layout. +- **Find source**: `python -c 'import dashscope; import os; print(os.path.dirname(dashscope.__file__))'`, then use `read_file` on the one relevant file if needed. ### Cookbook examples (examples/ directory) The Python SDK repo ships runnable cookbook scripts under `examples/`, organized by API generation. When the user asks you to implement a task, start from the matching script instead of writing from scratch: -- `examples/v1/` — 1.x SDK (`dashscope.Generation`, `dashscope.MultiModalConversation`, ...): text generation (`generation/`, `aio_generation/`), multimodal chat (`multimodal_conversation/`, `aio_multimodal_conversation/`), image/video synthesis, application / agentstudio / assistants, speech (`qwen_tts.py`, `qwen_asr.py`), tingwu, rerank, embeddings. -- `examples/v2/` — 2.0 SDK (resource-style `dashscope.Client()`): `chat.py`, `multimodal_chat.py`, `embeddings.py`, `images.py` / `videos.py` (one-shot generate + manual jobs flow), `rerank.py`, `models.py`, `files.py`, `apps.py`, `tokenization.py`, `audio_tts.py` / `audio_asr.py`, `tingwu.py`, `fine_tunes.py`. -- Java cookbook samples are not in this repo; they live in the Java SDK repo (https://github.com/dashscope/dashscope-sdk-java) and are referenced by class name in the skills. - -### Python repo layout: v1.x vs 2.0 (V2) +- `examples/v1/` — 1.x SDK (`dashscope.Generation`, `dashscope.MultiModalConversation`, ...): text generation, multimodal chat, image/video synthesis, application / agentstudio / assistants, speech, tingwu, rerank, and embeddings. +- `examples/v2/` — 2.0 resource-style `dashscope.Client()` scripts: chat, multimodal chat, embeddings, images/videos and jobs, rerank, models, files, apps, tokenization, audio, tingwu, and fine-tunes. +- Java cookbook samples live in the Java SDK repo (https://github.com/dashscope/dashscope-sdk-java) and are referenced by class name in the skills. -In the Python SDK repo the 2.0 API is isolated by directory. Only these paths are V2: +### Python repo layout: 1.x vs 2.0.3 -- `dashscope/v2/` — the 2.0 client and resources -- `tests/v2/` — V2 tests -- `examples/v2/` — V2 cookbook +- The 2.0 client and resources are at the package root: `dashscope/client.py`, `dashscope/resources/`, `dashscope/protocols/`, `dashscope/types/`, and `dashscope/errors.py`. +- Legacy implementations live under `dashscope/legacy_1x/`; historical public root exports and deep imports remain available through compatibility aliases. Prefer the user's existing public 1.x import when repairing old code, not a newly rewritten physical path. +- AgentStudio and FineTune implementations are grouped under `dashscope/applications/`, while their established public import paths remain available. +- Current tests live under `tests/`, with legacy suites under `tests/legacy_1x/`. Cookbook generations remain separated under `examples/v1/` and `examples/v2/`. -Everything else (`dashscope/` top-level modules, the rest of `tests/`, `examples/v1/`) is v1.x code. Match the tree to the user's API generation before reading source or quoting examples: resource-style `dashscope.Client` calls → V2 paths; module-level `dashscope.Generation.call(...)` style → v1.x paths. +Match the user's API generation before reading source or quoting examples; root-level 1.x exports and the root-level 2.0 Client coexist. ### Workflow 1. Identify the question type (question about you / issue analysis / API question / example). 2. If it is about you — answer straight from this prompt and stop. No tool call. -3. If issue analysis — answer directly, only verify if something is unclear. -4. If API question — run one targeted `inspect.signature` or `help()` call. -5. Compose a concise, actionable answer. +3. Identify whether the user is maintaining 1.x, writing new 2.0 code, or explicitly migrating. +4. Load the matching skill and run one targeted `inspect.signature` or `help()` call if verification is necessary. +5. Compose a concise, actionable answer with the matching response and error contract. ### SDK Reference -The skills under `.acli/skills/` are the knowledge base: model lists, Python and Java SDK interfaces, CLI commands, input/output structures, and error codes. Load the matching skill via `use_skill` to answer API questions. If a skill lacks a specific detail, fall back to `inspect.signature` or `help()` on the installed package. +The skills under `.acli/skills/` are the searchable knowledge base: model lists, Python and Java interfaces, CLI commands, input/output structures, and error codes. Load the matching skill via `use_skill`. If a skill lacks a specific detail, fall back to `inspect.signature` or `help()` on the installed package; do not duplicate large version-mapping tables into the permanent prompt. Rules: - Provide runnable code examples when relevant -- Reference specific SDK classes and methods -- Explain error codes with actionable fixes -- Use the latest SDK patterns (messages format, not legacy prompt) +- Reference specific SDK classes, resources, and methods +- Explain error codes with actionable fixes and preserve request IDs +- Use the latest 2.0 patterns for new code (messages format, not legacy prompt) +- Preserve the user's established 1.x style for legacy fixes - Answer in the same language as the user's question - When diagnosing errors, use read_file if a path is given - When demonstrating, use run_command to show actual output diff --git a/dashscope/acli/examples/dashscope-sdk-expert/README.md b/dashscope/acli/examples/dashscope-sdk-expert/README.md index c6040deb..114e58f7 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/README.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/README.md @@ -6,6 +6,8 @@ This example shows how to build a scenario-specific AI expert agent using **Agen **Core idea: configuration-driven, zero Python glue.** The agent's identity, capabilities, skills, and knowledge index are all defined by files under `.acli/`; download the example and run `acli` directly to start. +**SDK baseline:** new Python examples use DashScope **2.0.3** (`dashscope.Client` / `AsyncClient`) and `dashscope.errors`. The skills also retain 1.x guidance for maintaining existing `Generation.call`-style code; installing 2.0 does not require rewriting working 1.x applications. + ## Directory Structure ``` @@ -16,12 +18,12 @@ dashscope-sdk-expert/ ├── hooks.toml # Event hooks ├── system-prompt.md # System prompt (agent persona and behavior rules) └── skills/ # Skill templates (loaded by the model on demand via use_skill) - ├── text-generation.md # Text generation (Generation / OpenAI-compatible, Python+Java) - ├── multimodal.md # Multimodal (MultiModalConversation/ImageSynthesis/VideoSynthesis) - ├── speech.md # Speech (SpeechSynthesizer/Transcription) - ├── retrieval.md # Retrieval (Embedding/TextReRank/RAG) - ├── fine-tuning.md # Fine-tuning & deployment (SFT/CPT/DPO/Deployments) - ├── agent.md # Agent (Application/Assistants/plugins & MCP) + ├── text-generation.md # Chat, decision & 1.x Generation (Python+Java) + ├── multimodal.md # Multimodal chat, images & videos + ├── speech.md # 2.0 audio, TingWu & 1.x speech compatibility + ├── retrieval.md # Embeddings, rerank & tokenization + ├── fine-tuning.md # Files, fine-tunes, deployments & Agentic RL + ├── agent.md # Apps, AgentStudio & 1.x Assistants compatibility ├── cli.md # dashscope CLI command reference ├── sdk-example.md # Generate SDK code examples ├── api-doc.md # View API parameter docs @@ -43,9 +45,11 @@ acli example download dashscope-sdk-expert # Start — no cd, no Python launcher script needed acli acli --tui -acli -c "How do I use Generation.call?" +acli -c "Show a DashScope 2.0 streaming chat example with error handling" ``` +For legacy maintenance, ask for example: `acli -c "Fix this Generation.call code without migrating it to 2.0"`. + ## The Configuration-Driven Approach ### 1. system-prompt.md — Agent Persona @@ -55,13 +59,13 @@ Defines the agent's identity, knowledge scope, and behavior rules. This is the c ```markdown You are DashScope SDK Expert, an intelligent assistant for the DashScope Python SDK... -## Grounded Knowledge First -Before answering, ALWAYS verify against the actual installed SDK... +### Choose 1.x vs 2.0 from the user's context +For new Python code, recommend 2.0 resource APIs and `dashscope.errors`... ``` ### 2. skills/ — Domain Knowledge Base (Loaded on Demand) -The SDK/CLI's public interface knowledge lives directly in domain skills: one file per domain, with model lists, Python and Java SDK signatures, input/output structures, and error codes. When answering API questions, the model loads the matching skill via `use_skill` on demand — **nothing stays permanently in the system prompt** — cutting first-turn input tokens by about 16k characters; details not covered by a skill fall back to `inspect.signature` / `help()` against the installed package. +The SDK/CLI's public interface knowledge lives directly in domain skills: one file per domain, with current 2.0.3 resource signatures, 1.x compatibility notes, Java references, input/output structures, and error handling. When answering API questions, the model loads the matching skill via `use_skill` on demand — **nothing stays permanently in the system prompt** — cutting first-turn input tokens by about 16k characters; details not covered by a skill fall back to `inspect.signature` / `help()` against the installed package. ### 3. skills/ — Task Templates diff --git a/dashscope/acli/providers/__init__.py b/dashscope/acli/providers/__init__.py index 7e7bb34b..8ade4b4c 100644 --- a/dashscope/acli/providers/__init__.py +++ b/dashscope/acli/providers/__init__.py @@ -28,6 +28,9 @@ # Env var hints for the /key command and profile validation. NOT used for # dispatch — protocol routing is driven by profile.protocol (filled from # the user's custom-extensions.toml, see the basic-chat example). +_WARNED_UNSUPPORTED_RUNTIME_OPTIONS: set[tuple[str, str, str]] = set() + + PROVIDER_API_KEY_ENVS = { "anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", @@ -118,6 +121,24 @@ def get_provider( def get_provider_chain(config) -> ProviderChain: """Build a ProviderChain from acli Config (primary + fallbacks).""" + legacy = ( + getattr(config, "thinking_provider", ""), + getattr(config, "thinking_model", ""), + getattr(config, "loop_mode", "auto"), + ) + if (legacy[0] or legacy[1] or legacy[2] not in ("", "auto")) and ( + legacy not in _WARNED_UNSUPPORTED_RUNTIME_OPTIONS + ): + _WARNED_UNSUPPORTED_RUNTIME_OPTIONS.add(legacy) + from rich.console import Console + + Console().print( + "[yellow]⚠ thinking_provider/thinking_model/structured " + "loop_mode are legacy compatibility settings and are not active " + "in this build; execution uses the primary provider/model and " + "the standard Agent loop.[/yellow]", + ) + profiles = build_profiles_from_config(config) if not profiles: raise RuntimeError("No usable provider configuration") diff --git a/dashscope/acli/runtime.py b/dashscope/acli/runtime.py new file mode 100644 index 00000000..ccaa1622 --- /dev/null +++ b/dashscope/acli/runtime.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Shared runtime wiring for programmatic and embedded Agent entrypoints. + +The interactive CLI owns presentation and lifecycle, but every entrypoint must +construct the same capability surface. Keeping this wiring here prevents SDK +and embedded one-shot modes from silently losing memory, session tools, vision, +skills, evolution, or delegation. +""" +from __future__ import annotations + +# pylint: disable=unused-import,protected-access + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dashscope.acli.agent import Agent + from dashscope.acli.config import Config + from dashscope.acli.hooks import HookBus + from dashscope.acli.platforms.base import MemoryProvider + + +def import_builtin_tools() -> None: + """Populate the global registry with all built-in static tools.""" + import dashscope.acli.tools.browser # noqa: F401 + import dashscope.acli.tools.camera # noqa: F401 + import dashscope.acli.tools.filesystem # noqa: F401 + import dashscope.acli.tools.shell # noqa: F401 + import dashscope.acli.tools.web_search # noqa: F401 + + +def prepare_extensions(config: Config) -> None: + """Load declarative providers, capabilities, shell tools, and skills.""" + from dashscope.acli.config import PROVIDER_MODELS + from dashscope.acli.extensions import apply_extensions + + del config # kept in the signature for a stable runtime-prep contract + apply_extensions(PROVIDER_MODELS) + + +def create_runtime_memory(config: Config) -> MemoryProvider | None: + """Create and publish the configured memory provider, if enabled.""" + if not config.memory_enabled: + return None + from dashscope.acli.cli.handlers_profile import set_memory_client + from dashscope.acli.platforms import get_memory_provider + + memory = get_memory_provider(config) + if memory is not None: + set_memory_client(memory) + return memory + + +def wire_agent_runtime( + config: Config, + agent: Agent, + hook_bus: HookBus | None = None, +) -> None: + """Attach every dynamic tool/capability family to an existing Agent.""" + import_builtin_tools() + + from dashscope.acli.agents.delegate import ( + set_config as set_delegate_config, + ) + from dashscope.acli.agents.delegate import ( + set_parent_agent as set_delegate_parent, + ) + from dashscope.acli.agents.subagent import ( + set_config as set_subagent_config, + ) + from dashscope.acli.agents.subagent import ( + set_parent_agent as set_subagent_parent, + ) + + set_subagent_parent(agent) + set_subagent_config(config) + set_delegate_parent(agent) + set_delegate_config(config) + + from dashscope.acli.cli.mcp import ( + _connect_mcp, + _disconnect_mcp, + _mcp_clients, + ) + from dashscope.acli.providers import get_provider + from dashscope.acli.skills import get_skill_manager, list_known_services + from dashscope.acli.tools.platform import register_platform_tools + + register_platform_tools(config, connect_mcp_fn=_connect_mcp) + + skill_manager = get_skill_manager() + skill_manager._registry_url = getattr(config, "skill_registry", "") + skill_manager._global = False + skill_manager.load(hook_bus=hook_bus or agent.hook_bus) + + from dashscope.acli.tools.session import register_session_tools + + register_session_tools( + config, + get_agent=lambda: agent, + get_provider_fn=get_provider, + connect_mcp_fn=_connect_mcp, + disconnect_mcp_fn=_disconnect_mcp, + list_mcp_services_fn=list_known_services, + get_mcp_clients_fn=lambda: _mcp_clients, + ) + + from dashscope.acli.tools.vision import register_vision_tools + + register_vision_tools(config, get_provider_fn=get_provider) + + from dashscope.acli.tools.evolution import register_evolution_tools + from dashscope.acli.tools.skills import register_skill_tools + + register_evolution_tools(get_agent=lambda: agent) + register_skill_tools(get_agent=lambda: agent) diff --git a/dashscope/acli/sdk.py b/dashscope/acli/sdk.py index 2af4d6bd..81cf46b3 100644 --- a/dashscope/acli/sdk.py +++ b/dashscope/acli/sdk.py @@ -19,7 +19,6 @@ from __future__ import annotations import asyncio -import uuid from pathlib import Path from typing import AsyncIterator, Optional @@ -30,10 +29,10 @@ def _ensure_tools_imported() -> None: - """Import tool modules so the registry is populated.""" - import dashscope.acli.tools.filesystem # noqa: F401 - import dashscope.acli.tools.shell # noqa: F401 - import dashscope.acli.tools.web_search # noqa: F401 + """Import every static built-in tool module.""" + from dashscope.acli.runtime import import_builtin_tools + + import_builtin_tools() def load_config( @@ -62,40 +61,64 @@ def create_agent( config: Optional[Config] = None, system_prompt: Optional[str] = None, session_id: Optional[str] = None, + *, + oneshot: bool = False, ) -> Agent: """Create an ``Agent`` instance ready for programmatic use. Args: config: acli configuration. If None, loads default config. system_prompt: Optional system prompt override. - session_id: Optional session identifier. If provided, the agent's - in-memory conversation is persisted under - ``.acli/session/{session_id}/history.json``. + session_id: Optional safe topic identifier. If provided, conversation + history is persisted under ``.acli/session//history.json``. + oneshot: Enable autonomous one-shot guards and acceptance behavior. """ _ensure_tools_imported() config = config or load_config() from dashscope.acli.cli import _compose_system_prompt, _load_system_prompt + from dashscope.acli.hooks import create_hook_bus + from dashscope.acli.runtime import ( + create_runtime_memory, + prepare_extensions, + wire_agent_runtime, + ) + from dashscope.acli.session import get_session_manager + from dashscope.acli.tools.platform import disabled_capabilities_hint + prepare_extensions(config) resolved_prompt = _compose_system_prompt( system_prompt or _load_system_prompt(), ) provider = get_provider_chain(config) - executor = Executor() + executor = Executor( + auto_approve=config.auto_approve, + confirm_mode=config.confirm_mode, + ) + hook_bus = create_hook_bus() + memory = create_runtime_memory(config) session_path: Optional[Path] = None if session_id: - session_path = Path(".acli") / "session" / session_id / "history.json" + session_path = get_session_manager().get_history_path(session_id) - return Agent( + agent = Agent( provider=provider, executor=executor, max_turns=config.max_turns, + memory=memory, + user_name=config.user_name, provider_name=config.provider, model_name=config.model, system_prompt=resolved_prompt, session_path=session_path, + disabled_caps_provider=lambda: disabled_capabilities_hint(config), + directives_provider=lambda: config.user_directives, + hook_bus=hook_bus, + oneshot=oneshot, ) + wire_agent_runtime(config, agent, hook_bus) + return agent async def run_stream( @@ -125,13 +148,14 @@ async def run_once( This is the easiest entry point for scripts and CI: it streams the full agent turn, executes any tool calls, and returns the final assistant text. """ - parts: list[str] = [] - async for chunk in run_stream( - prompt, + agent = create_agent( config=config, system_prompt=system_prompt, - session_id=session_id or str(uuid.uuid4())[:8], - ): + session_id=session_id, + oneshot=True, + ) + parts: list[str] = [] + async for chunk in agent.run_stream(prompt): parts.append(chunk) return "".join(parts) diff --git a/dashscope/acli/session.py b/dashscope/acli/session.py index 2ec8a9a3..e982880b 100644 --- a/dashscope/acli/session.py +++ b/dashscope/acli/session.py @@ -179,14 +179,18 @@ def append_scene(self, text: str, topic: str | None = None) -> bool: return self.set_scene(merged, topic) def event_log(self, topic: str | None = None) -> SessionEventLog: - """Return the append-only event log for a topic (default: current). + """Return the append-only event log for a safe topic. - The sidecar is advisory: callers may append lifecycle/turn events - without affecting the history.json read/write path. + Topic validation belongs at this public boundary. Several best-effort + callers accept a topic argument directly; allowing ``../`` here would + write an event stream outside ``session/`` even though create/switch + correctly reject the same name. """ - return SessionEventLog( - self._events_file(topic or self.current_topic), - ) + topic = topic or self.current_topic + topic_dir = self._safe_topic_dir(topic) + if topic_dir is None: + raise ValueError(f"unsafe session topic: {topic!r}") + return SessionEventLog(topic_dir / EVENTS_FILENAME) def record_turn_event( self, @@ -450,9 +454,12 @@ def delete_topic(self, topic: str) -> bool: return False def get_history_path(self, topic: str | None = None) -> Path: - """Get the history.json path for a topic (default: current).""" + """Get the history path for a safe topic (default: current).""" topic = topic or self.current_topic - return self._history_file(topic) + topic_dir = self._safe_topic_dir(topic) + if topic_dir is None: + raise ValueError(f"unsafe session topic: {topic!r}") + return topic_dir / "history.json" def get_input_history_path(self, topic: str | None = None) -> Path: """Get the input-history path for a topic (default: current).""" diff --git a/dashscope/acli/session_events.py b/dashscope/acli/session_events.py index 062f7d6c..baab55dc 100644 --- a/dashscope/acli/session_events.py +++ b/dashscope/acli/session_events.py @@ -24,14 +24,55 @@ import json import os +import threading +from contextlib import contextmanager from datetime import datetime from pathlib import Path from typing import Any +from dashscope.acli.utils.sanitizer import sanitize + # Bump when the on-disk event schema changes in a breaking way. SCHEMA_VERSION = 1 EVENTS_FILENAME = "events.jsonl" +_EVENT_FILE_LOCK = threading.RLock() + + +@contextmanager +def _exclusive_lock(path: Path): + """Cross-process lock on a stable sidecar inode. + + Locking the event file itself is insufficient because snapshot compaction + replaces that inode. A separate ``.lock`` file serializes append and + compaction across processes; the RLock covers platforms without flock and + nested calls in the current process. + """ + lock_path = path.with_name(path.name + ".lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with _EVENT_FILE_LOCK, open(lock_path, "a+b") as lock_file: + if os.name == "nt": # pragma: no cover - exercised on Windows CI + import msvcrt + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"0") + lock_file.flush() + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + lock_file.seek(0) + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) def latest_snapshot_messages( @@ -80,15 +121,21 @@ def append(self, event_type: str, data: dict | None = None) -> None: def _append(self, event_type: str, data: dict) -> None: self._file.parent.mkdir(parents=True, exist_ok=True) - entry = { - "v": SCHEMA_VERSION, - "seq": self._next_seq(), - "ts": datetime.now().isoformat(), - "type": event_type, - "data": data, - } - with open(self._file, "a", encoding="utf-8") as f: - f.write(json.dumps(entry, ensure_ascii=False) + "\n") + with _exclusive_lock(self._file): + entry = { + "v": SCHEMA_VERSION, + "seq": self._next_seq(), + "ts": datetime.now().isoformat(), + "type": event_type, + # Event snapshots are a persistence boundary just like + # history.json. Redact here so every producer (turn summaries, + # snapshots, lifecycle events, future extensions) gets the + # same protection; replay cannot resurrect plaintext secrets. + "data": sanitize(data), + } + with open(self._file, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, ensure_ascii=False) + "\n") + f.flush() def _next_seq(self) -> int: """1 + number of non-blank lines currently in the log.""" @@ -166,10 +213,22 @@ def compact_snapshots( the newest ``keep`` snapshots, renumbering ``seq`` to stay line-ordered. The rewrite goes through a tmp file and ``os.replace``, so a crash mid-compaction leaves the original - log intact. + log intact. Append and compaction share a stable sidecar lock. Best-effort: returns True only when the file was rewritten. """ + try: + with _exclusive_lock(self._file): + return self._compact_snapshots_locked(keep, snapshot_type) + except OSError: + return False + + def _compact_snapshots_locked( + self, + keep: int, + snapshot_type: str, + ) -> bool: + """Compaction implementation; caller holds ``_exclusive_lock``.""" try: entries = self.read_raw() except OSError: @@ -181,7 +240,9 @@ def compact_snapshots( return False drop = set(snapshot_idx[: len(snapshot_idx) - keep]) kept = [e for i, e in enumerate(entries) if i not in drop] - tmp = self._file.with_suffix(self._file.suffix + ".tmp") + tmp = self._file.with_name( + f"{self._file.name}.{os.getpid()}.tmp", + ) try: with open(tmp, "w", encoding="utf-8") as f: for seq, entry in enumerate(kept, start=1): diff --git a/dashscope/acli/tools/filesystem.py b/dashscope/acli/tools/filesystem.py index eaa5b282..7b7e58bc 100644 --- a/dashscope/acli/tools/filesystem.py +++ b/dashscope/acli/tools/filesystem.py @@ -49,6 +49,77 @@ } +def _read_text_window( + path: str, + offset: int | None, + limit: int | None, +) -> str: + """Read a numbered text window with bounded memory.""" + + # offset is 1-based so it matches the line numbers printed below; None + # and <=1 both mean "from the top". Keep memory bounded while scanning: + # the previous f.readlines() implementation loaded a multi-gigabyte file + # in full even though the tool only returns a 200-line / 100k-char window. + start = max((offset or 1) - 1, 0) + window = limit if limit and limit > 0 else _READ_DEFAULT_LINES + body_parts: list[str] = [] + body_chars = 0 + included = 0 + total = 0 + collecting = True + + with open(path, "r", encoding="utf-8", errors="replace") as f: + while True: + # Bound a single physical-line allocation as well. If the line is + # longer, consume the remainder in chunks but keep it one logical + # line for numbering/offset purposes. + line = f.readline(_READ_MAX_CHARS + 1) + if not line: + break + logical = line + while line and not line.endswith("\n"): + line = f.readline(_READ_MAX_CHARS + 1) + total += 1 + + if not collecting or total <= start or included >= window: + continue + + rendered = f"{total:>4}\t{logical.rstrip()}" + separator = 1 if body_parts else 0 + remaining = _READ_MAX_CHARS - body_chars - separator + if remaining <= 0: + collecting = False + continue + if len(rendered) > remaining: + # Preserve the old behavior for one extremely long first line: + # return a bounded prefix. For later lines, stop at the last + # complete line so a continuation offset remains truthful. + if not body_parts: + body_parts.append(rendered[:remaining]) + body_chars += min(len(rendered), remaining) + included += 1 + collecting = False + continue + body_parts.append(rendered) + body_chars += separator + len(rendered) + included += 1 + + if start >= total: + return ( + f"[offset {start + 1} is past the end of {path} ({total} lines)]" + ) + + body = "\n".join(body_parts) + end = start + included + omitted = total - end + if omitted > 0: + body += ( + f"\n[omitted {omitted} of {total} lines — " + f"read_file path={path} offset={end + 1} to continue]" + ) + return body + + # --------------------------------------------------------------------------- # Tools # --------------------------------------------------------------------------- @@ -91,40 +162,7 @@ def read_file( "file/identify instead.]" ) - with open(path, "r", encoding="utf-8", errors="replace") as f: - lines = f.readlines() - - total = len(lines) - # offset is 1-based so it matches the line numbers printed below; None - # and <=1 both mean "from the top". - start = max((offset or 1) - 1, 0) - if start >= total: - return ( - f"[offset {start + 1} is past the end of {path} ({total} lines)]" - ) - - window = limit if limit and limit > 0 else _READ_DEFAULT_LINES - end = min(start + window, total) - body = "\n".join( - f"{i:>4}\t{line.rstrip()}" - for i, line in enumerate(lines[start:end], start=start + 1) - ) - - if len(body) > _READ_MAX_CHARS: - cut = body[:_READ_MAX_CHARS] - # Back off to the last complete line so the window never ends on a - # half-line whose number would mislead a follow-up offset. - last_newline = cut.rfind("\n") - body = cut[:last_newline] if last_newline > 0 else cut - end = start + (body.count("\n") + 1 if body else 0) - - omitted = total - end - if omitted > 0: - body += ( - f"\n[omitted {omitted} of {total} lines — " - f"read_file path={path} offset={end + 1} to continue]" - ) - return body + return _read_text_window(path, offset, limit) @tool( diff --git a/dashscope/acli/tools/platform.py b/dashscope/acli/tools/platform.py index 2269065b..f544ad5c 100644 --- a/dashscope/acli/tools/platform.py +++ b/dashscope/acli/tools/platform.py @@ -103,6 +103,15 @@ def register_platform_tools( config: Config, connect_mcp_fn: Callable | None = None, ): + if config.privacy_mode: + # Registration is process-global. A previous non-private Agent may have + # left cloud/MCP tools in the registry, so merely skipping new + # registration is insufficient for SDKs that create multiple Agents. + unregister_cloud_capability_tools() + for name in [t.name for t in registry.list_tools()]: + if name.startswith(("mcp_", "bailian_")): + registry.unregister(name) + caps = config.enabled_capabilities all_enabled = caps is None for cap_key in all_capability_keys(): diff --git a/dashscope/acli/tools/registry.py b/dashscope/acli/tools/registry.py index 9b8de94b..8c8ab7b8 100644 --- a/dashscope/acli/tools/registry.py +++ b/dashscope/acli/tools/registry.py @@ -126,6 +126,9 @@ def _build_parameters_schema(func: Callable) -> dict: "mcp_connect", "mcp_disconnect", "use_skill", + "create_plan", + "complete_step", + "get_plan_status", }, ) @@ -341,21 +344,32 @@ def ever_visible_tool_names() -> frozenset[str]: class ToolRegistry: def __init__(self): self._tools: dict[str, ToolDefinition] = {} + # Monotonic mutation counter. Agent loops use it to detect tools that + # were connected/enabled mid-turn and rebuild the provider schema. + self._version = 0 + + @property + def version(self) -> int: + """Registry mutation version for cheap schema invalidation.""" + return self._version def register(self, tool_def: ToolDefinition): """Register a tool. When used as a decorator with a ToolDefinition, returns a decorator.""" if isinstance(tool_def, ToolDefinition): self._tools[tool_def.name] = tool_def + self._version += 1 def decorator(func: Callable) -> Callable: self._tools[tool_def.name].func = func + self._version += 1 func._tool_definition = tool_def return func return decorator # Called with a callable directly (legacy) self._tools[tool_def.name] = tool_def + self._version += 1 return None def register_mcp_tool( @@ -373,9 +387,12 @@ def register_mcp_tool( parameters=parameters, ) self._tools[name] = tool_def + self._version += 1 def unregister(self, name: str): - self._tools.pop(name, None) + if name in self._tools: + self._tools.pop(name) + self._version += 1 def get(self, name: str) -> ToolDefinition | None: return self._tools.get(name) diff --git a/dashscope/acli/tools/session.py b/dashscope/acli/tools/session.py index 29202e42..601e0bc1 100644 --- a/dashscope/acli/tools/session.py +++ b/dashscope/acli/tools/session.py @@ -284,57 +284,58 @@ async def mcp_disconnect(service: str) -> str: config.save_workspace() return f"Disconnected {service}" - registry.register_mcp_tool( - name="mcp_list_services", - description=( - "List available MCP cloud services " - "(time, code execution, doc parsing, etc.)" - ), - parameters={"type": "object", "properties": {}}, - call_fn=mcp_list_services, - ) - registry.register( - ToolDefinition( - name="mcp_connect", + if not config.privacy_mode: + registry.register_mcp_tool( + name="mcp_list_services", description=( - "Connect an MCP cloud service " - "(its tools become available once connected)" + "List available MCP cloud services " + "(time, code execution, doc parsing, etc.)" ), - permission=PermissionLevel.CONFIRM, - func=mcp_connect, - parameters={ - "type": "object", - "properties": { - "service": { - "type": "string", - "description": ( - "Service name, e.g. time, " - "code-interpreter, doc-analysis" - ), + parameters={"type": "object", "properties": {}}, + call_fn=mcp_list_services, + ) + registry.register( + ToolDefinition( + name="mcp_connect", + description=( + "Connect an MCP cloud service " + "(its tools become available once connected)" + ), + permission=PermissionLevel.CONFIRM, + func=mcp_connect, + parameters={ + "type": "object", + "properties": { + "service": { + "type": "string", + "description": ( + "Service name, e.g. time, " + "code-interpreter, doc-analysis" + ), + }, }, + "required": ["service"], }, - "required": ["service"], - }, - ), - ) - registry.register( - ToolDefinition( - name="mcp_disconnect", - description="Disconnect an MCP cloud service", - permission=PermissionLevel.CONFIRM, - func=mcp_disconnect, - parameters={ - "type": "object", - "properties": { - "service": { - "type": "string", - "description": "Service name", + ), + ) + registry.register( + ToolDefinition( + name="mcp_disconnect", + description="Disconnect an MCP cloud service", + permission=PermissionLevel.CONFIRM, + func=mcp_disconnect, + parameters={ + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name", + }, }, + "required": ["service"], }, - "required": ["service"], - }, - ), - ) + ), + ) # ===== Plan Management ===== diff --git a/dashscope/acli/ui/embedded.py b/dashscope/acli/ui/embedded.py index ea14e0c6..378fa4ae 100644 --- a/dashscope/acli/ui/embedded.py +++ b/dashscope/acli/ui/embedded.py @@ -109,26 +109,13 @@ async def _run_oneshot_embedded( prompt: str, system_prompt: str | None, ): - """One-shot mode using acli's full agent stack.""" - import dashscope.acli.tools.filesystem # noqa: F401 - import dashscope.acli.tools.shell # noqa: F401 - from dashscope.acli.agent import Agent - from dashscope.acli.cli import _compose_system_prompt, _load_system_prompt - from dashscope.acli.executor import Executor - from dashscope.acli.providers import get_provider_chain - - resolved_prompt = _compose_system_prompt( - system_prompt or _load_system_prompt(), - ) - provider = get_provider_chain(config) - executor = Executor() - agent = Agent( - provider=provider, - executor=executor, - max_turns=config.max_turns, - provider_name=config.provider, - model_name=config.model, - system_prompt=resolved_prompt, + """One-shot mode using the same fully wired stack as the SDK.""" + from dashscope.acli.sdk import create_agent + + agent = create_agent( + config=config, + system_prompt=system_prompt, + oneshot=True, ) async for chunk in agent.run_stream(prompt): diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index aa72783a..0893a52f 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -252,6 +252,17 @@ def scroll_end(self, *args, **kwargs): self._follow_output = True return super().scroll_end(*args, **kwargs) + def selection_updated(self, selection: Selection | None) -> None: + """Invalidate Textual's line cache when selection styling changes. + + Textual 8 caches RichLog strips independently of the Screen selection + reactive. ``Widget.refresh()`` alone can leave the old unselected line + in ``StylesCache``, so the selection exists and copies correctly but + has no visible highlight. + """ + self._styles_cache.clear() + super().selection_updated(selection) + def render_line(self, y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset line_y = scroll_y + y diff --git a/dashscope/applications/__init__.py b/dashscope/applications/__init__.py new file mode 100644 index 00000000..cc26a06f --- /dev/null +++ b/dashscope/applications/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. diff --git a/dashscope/agentstudio/__init__.py b/dashscope/applications/agentstudio/__init__.py similarity index 94% rename from dashscope/agentstudio/__init__.py rename to dashscope/applications/agentstudio/__init__.py index cc15a942..c03c6658 100644 --- a/dashscope/agentstudio/__init__.py +++ b/dashscope/applications/agentstudio/__init__.py @@ -5,15 +5,15 @@ The ``agentstudio`` package wraps the AgentStudio managed-agent HTTP API. All resource classes are accessed through a client instance:: - from dashscope.agentstudio import Client - from dashscope.agentstudio.types import ( + from dashscope.applications.agentstudio import Client + from dashscope.applications.agentstudio.types import ( user_message, user_tool_result, ) Example ------- ->>> from dashscope.agentstudio import Client ->>> from dashscope.agentstudio.types import user_message +>>> from dashscope.applications.agentstudio import Client +>>> from dashscope.applications.agentstudio.types import user_message >>> client = Client(api_key="sk-xxx") >>> agent = client.agents.create(name="demo", model="qwen-plus") >>> session = client.sessions.create(agent=agent.id) @@ -55,7 +55,6 @@ MemoryStore, MemoryVersion, Message, - ModelConfig, PermissionPolicy, SecurityAlert, SecurityAlertList, @@ -125,7 +124,6 @@ "MemoryStore", "MemoryVersion", "Message", - "ModelConfig", "PermissionPolicy", "SecurityAlert", "SecurityAlertList", diff --git a/dashscope/agentstudio/client.py b/dashscope/applications/agentstudio/client.py similarity index 69% rename from dashscope/agentstudio/client.py rename to dashscope/applications/agentstudio/client.py index 0b47e82a..b90b7771 100644 --- a/dashscope/agentstudio/client.py +++ b/dashscope/applications/agentstudio/client.py @@ -9,39 +9,87 @@ import httpx -from dashscope.agentstudio.resources.agents import Agents, AsyncAgents -from dashscope.agentstudio.resources.deployments import ( +from dashscope.applications.agentstudio import ( + exceptions as _default_exceptions, +) +from dashscope._shared._validation import ( + validate_region, + validate_workspace_id, +) +from dashscope.applications.agentstudio.resources.agents import ( + Agents, + AsyncAgents, +) +from dashscope.applications.agentstudio.resources.deployments import ( AsyncDeploymentRuns, AsyncDeployments, DeploymentRuns, Deployments, ) -from dashscope.agentstudio.resources.environments import ( +from dashscope.applications.agentstudio.resources.environments import ( Environments, AsyncEnvironments, ) -from dashscope.agentstudio.resources.files import Files, AsyncFiles -from dashscope.agentstudio.resources.memory_stores import ( +from dashscope.applications.agentstudio.resources.files import ( + Files, + AsyncFiles, +) +from dashscope.applications.agentstudio.resources.memory_stores import ( MemoryStores, AsyncMemoryStores, ) -from dashscope.agentstudio.resources.security import Security, AsyncSecurity -from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions -from dashscope.agentstudio.resources.skills import Skills, AsyncSkills -from dashscope.agentstudio.resources.vaults import Vaults, AsyncVaults -from dashscope.agentstudio.resources.webhook_endpoints import ( +from dashscope.applications.agentstudio.resources.security import ( + Security, + AsyncSecurity, +) +from dashscope.applications.agentstudio.resources.sessions import ( + Sessions, + AsyncSessions, +) +from dashscope.applications.agentstudio.resources.skills import ( + Skills, + AsyncSkills, +) +from dashscope.applications.agentstudio.resources.vaults import ( + Vaults, + AsyncVaults, +) +from dashscope.applications.agentstudio.resources.webhook_endpoints import ( AsyncWebhookEndpoints, WebhookEndpoints, ) -from dashscope.agentstudio.constants import ( +from dashscope.applications.agentstudio.constants import ( AGENTSTUDIO_BASE_URL_TEMPLATE, AGENTSTUDIO_DEFAULT_REGION, AGENTSTUDIO_DEFAULT_TIMEOUT, AGENTSTUDIO_MAX_RETRIES, ) -from dashscope.agentstudio.transport import SyncTransport, AsyncTransport -from dashscope.common.api_key import get_default_api_key -from dashscope.common.env import validate_region, validate_workspace_id +from dashscope.applications.agentstudio.transport import ( + SyncTransport, + AsyncTransport, +) +from dashscope.applications.agentstudio.streaming import ( + AsyncEventStream, + EventStream, +) +from dashscope._config import _api_key_from_file, settings + + +def _default_api_key() -> Optional[str]: + """Resolve the api key when ``Client(api_key=...)`` was not given. + + Same tiers as the 2.0 client: ``settings.api_key`` (explicit value or + the ``DASHSCOPE_API_KEY`` env var), then the key file written by + ``save_api_key``. Returns ``None`` rather than raising -- the + transport turns a missing key into this SDK's own + ``AuthenticationError`` on the first request, with a message that + names the AgentStudio entry points. + + This deliberately does not read the 1.x ``dashscope.api_key`` module + global; set ``dashscope.settings.api_key`` or the environment + variable instead. + """ + return settings.api_key or _api_key_from_file() def _resolve_base_url( @@ -100,7 +148,7 @@ class Client: Usage:: - from dashscope.agentstudio import Client, ModelConfig + from dashscope.applications.agentstudio import Client, ModelConfig client = Client(api_key="sk-xxx", workspace="my-ws") agent = client.agents.create( @@ -131,6 +179,10 @@ def __init__( ] = None, max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.Client] = None, + _exceptions_module: Any = _default_exceptions, + _api_key_resolver: Any = _default_api_key, + _transport_cls: Any = SyncTransport, + _event_stream_cls: Any = EventStream, ) -> None: resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") resolved_base = _resolve_base_url( @@ -138,15 +190,17 @@ def __init__( resolved_workspace, region, ) - self.transport = SyncTransport( + self._event_stream_cls = _event_stream_cls + self.transport = _transport_cls( base_url=resolved_base, - api_key=api_key or get_default_api_key(), + api_key=api_key or _api_key_resolver(), workspace=resolved_workspace, uid=uid or os.environ.get("DASHSCOPE_UID"), user_agent=_user_agent(resolved_base), timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, max_retries=max_retries, http_client=http_client, + exceptions_module=_exceptions_module, ) self.agents = Agents(self) self.deployments = Deployments(self) @@ -181,7 +235,7 @@ class AsyncClient: Usage:: - from dashscope.agentstudio import AsyncClient, ModelConfig + from dashscope.applications.agentstudio import AsyncClient, ModelConfig client = AsyncClient(api_key="sk-xxx", workspace="my-ws") agent = await client.agents.create( @@ -207,6 +261,10 @@ def __init__( ] = None, max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.AsyncClient] = None, + _exceptions_module: Any = _default_exceptions, + _api_key_resolver: Any = _default_api_key, + _transport_cls: Any = AsyncTransport, + _event_stream_cls: Any = AsyncEventStream, ) -> None: resolved_workspace = workspace or os.environ.get("DASHSCOPE_WORKSPACE") resolved_base = _resolve_base_url( @@ -214,15 +272,17 @@ def __init__( resolved_workspace, region, ) - self.transport = AsyncTransport( + self._event_stream_cls = _event_stream_cls + self.transport = _transport_cls( base_url=resolved_base, - api_key=api_key or get_default_api_key(), + api_key=api_key or _api_key_resolver(), workspace=resolved_workspace, uid=uid or os.environ.get("DASHSCOPE_UID"), user_agent=_user_agent(resolved_base), timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, max_retries=max_retries, http_client=http_client, + exceptions_module=_exceptions_module, ) self.agents = AsyncAgents(self) self.deployments = AsyncDeployments(self) diff --git a/dashscope/agentstudio/constants.py b/dashscope/applications/agentstudio/constants.py similarity index 100% rename from dashscope/agentstudio/constants.py rename to dashscope/applications/agentstudio/constants.py diff --git a/dashscope/applications/agentstudio/exceptions.py b/dashscope/applications/agentstudio/exceptions.py new file mode 100644 index 00000000..a1920b56 --- /dev/null +++ b/dashscope/applications/agentstudio/exceptions.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""AgentStudio errors: the ``dashscope.errors`` family, no second hierarchy. + +The sub-SDK raises v2 errors natively, so the v2 mount needs no +translation layer. Old class names stay importable as aliases of the +v2 classes they map to; new code should use ``dashscope.errors``. + +Mapping notes: + +- ``InvalidRequestError`` -> ``BadRequestError``, + ``OverloadedError`` -> ``ServiceUnavailableError``. +- ``ConflictError`` (409) has no catalog entry: 409 falls back to the + ``APIStatusError`` base class, and the old name aliases that base. +- ``AgentStudioError`` aliases ``DashScopeError``; the 1.x + ``DashScopeException`` lineage is gone. +- ``StreamError`` / ``StreamClosedError`` are ``SdkError`` subclasses + backed by the catalog's ``sdk.agentstudio.*`` entries. + +The AgentStudio service returns errors in the canonical CMA shape:: + + { + "type": "error", + "error": {"code": "invalid_request_error", "message": "..."}, + "request_id": "req_..." + } + +The pre-release backend currently emits ``error_code``/``error_message`` +instead of nested ``error.{code,message}``. We accept both shapes and +normalize to the documented form. The compatibility branch is marked +with ``# TODO(bma-fix)`` so we can remove it once the backend aligns. +""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Optional + +from dashscope import _error_registry as _registry +from dashscope import errors as _v2_errors + +# pylint: disable-next=unused-import +from dashscope.errors import ( # noqa: F401 + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + BadRequestError, + DashScopeError, + InternalServerError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + SdkError, + ServiceUnavailableError, + _CatalogSdkError, + _STATUS_MAP, +) + +# Deprecated aliases of the v2 classes (kept for the 1.x import path). +AgentStudioError = DashScopeError +InvalidRequestError = BadRequestError +ConflictError = APIStatusError +OverloadedError = ServiceUnavailableError + + +class StreamError(_CatalogSdkError): + """The SSE stream encountered a fatal protocol error. + + Backed by the catalog's ``sdk.agentstudio.StreamError`` entry: + ``code`` / ``solution`` / ``allow_retry`` default from it. + """ + + _SDK_ERROR_NAME = "sdk.agentstudio.StreamError" + + +class StreamClosedError(StreamError): + """Attempted I/O on an already-closed stream. + + Backed by the catalog's ``sdk.agentstudio.StreamClosedError`` entry. + """ + + _SDK_ERROR_NAME = "sdk.agentstudio.StreamClosedError" + + +def _build_code_to_class() -> Dict[str, type]: + """Wire-code crosswalk derived from the catalog's anthropic codes. + + Two codes need explicit entries: the gateway's actual + ``permission_denied_error`` (the catalog field says + ``permission_error``), and ``conflict_error`` / 409, which has no + catalog entry and therefore lands on the base class. + """ + out: Dict[str, type] = {} + for entry in _registry.PUBLIC_ERRORS: + cls = getattr(_v2_errors, entry.error_code, None) + if cls is not None: + out.setdefault(entry.anthropic_error_code, cls) + out["permission_denied_error"] = PermissionDeniedError + out["conflict_error"] = APIStatusError + return out + + +_CODE_TO_CLASS = _build_code_to_class() + +# Default wire code when the body carries none (legacy behaviour). +_STATUS_DEFAULT_CODE = { + 400: "invalid_request_error", + 401: "authentication_error", + 403: "permission_denied_error", + 404: "not_found_error", + 409: "conflict_error", + 429: "rate_limit_error", + 500: "api_error", + 502: "api_error", + 503: "overloaded_error", + 504: "timeout_error", +} + + +def from_response( + *, + status_code: int, + body: Any, + headers: Optional[Mapping[str, str]] = None, +) -> DashScopeError: + """Build a v2 error from a HTTP response. + + Accepts both the documented ``{type, error:{code,message}, request_id}`` + shape and the pre-release ``{type, error:{error_code, error_message}}`` + shape. Falls back to a Spring default ``{timestamp,status,error,path}`` + when the body is not JSON-serializable. + + Class selection: the catalog-derived code crosswalk first, then the + v2 status map, then the ``APIStatusError`` base class. + + The ``x-request-id`` response header is preferred over the body + ``request_id`` field (server-generated IDs are more reliable for tracing). + """ + + code: Optional[str] = None + message: Optional[str] = None + request_id: Optional[str] = None + + # Prefer server-generated request ID from response header. + if headers: + request_id = headers.get("x-request-id") + + if isinstance(body, Mapping): + # Body request_id as fallback (snake_case canonical). + if request_id is None: + request_id = body.get("request_id") or body.get( + "requestId", + ) # TODO(bma-fix) + err = body.get("error") + if isinstance(err, Mapping): + code = err.get("code") or err.get("error_code") # TODO(bma-fix) + message = err.get("message") or err.get( + "error_message", + ) # TODO(bma-fix) + # Spring default fallback. + if ( + message is None + and "error" in body + and isinstance(body["error"], str) + ): + message = body["error"] + code = body.get("error") or "api_error" + if message is None: + message = body.get("message") + + if message is None: + message = f"HTTP {status_code}" + if code is None: + code = _STATUS_DEFAULT_CODE.get(status_code, "api_status_error") + + cls = ( + _CODE_TO_CLASS.get(code) + or _STATUS_MAP.get(status_code) + or APIStatusError + ) + return cls( + message, + code=code, + request_id=request_id, + status_code=status_code, + body=body if isinstance(body, Mapping) else {"raw": body}, + ) diff --git a/dashscope/agentstudio/pagination.py b/dashscope/applications/agentstudio/pagination.py similarity index 100% rename from dashscope/agentstudio/pagination.py rename to dashscope/applications/agentstudio/pagination.py diff --git a/dashscope/agentstudio/resources/__init__.py b/dashscope/applications/agentstudio/resources/__init__.py similarity index 66% rename from dashscope/agentstudio/resources/__init__.py rename to dashscope/applications/agentstudio/resources/__init__.py index 76c65123..54eb828b 100644 --- a/dashscope/agentstudio/resources/__init__.py +++ b/dashscope/applications/agentstudio/resources/__init__.py @@ -7,7 +7,7 @@ Usage:: - from dashscope.agentstudio import Client + from dashscope.applications.agentstudio import Client client = Client(api_key="sk-...") agent = client.agents.create(name="demo", model="qwen-plus") @@ -15,19 +15,25 @@ client.sessions.events.send(session.id, [user_message("hello")]) """ -from dashscope.agentstudio.resources.agents import Agents, AsyncAgents -from dashscope.agentstudio.resources.deployments import ( +from dashscope.applications.agentstudio.resources.agents import ( + Agents, + AsyncAgents, +) +from dashscope.applications.agentstudio.resources.deployments import ( AsyncDeploymentRuns, AsyncDeployments, DeploymentRuns, Deployments, ) -from dashscope.agentstudio.resources.environments import ( +from dashscope.applications.agentstudio.resources.environments import ( Environments, AsyncEnvironments, ) -from dashscope.agentstudio.resources.files import Files, AsyncFiles -from dashscope.agentstudio.resources.memory_stores import ( +from dashscope.applications.agentstudio.resources.files import ( + Files, + AsyncFiles, +) +from dashscope.applications.agentstudio.resources.memory_stores import ( AsyncMemories, AsyncMemoryStores, AsyncMemoryVersions, @@ -35,26 +41,29 @@ MemoryStores, MemoryVersions, ) -from dashscope.agentstudio.resources.session_events import ( +from dashscope.applications.agentstudio.resources.session_events import ( AsyncSessionEvents, SessionEvents, _AioTypedEventStream, _TypedEventStream, ) -from dashscope.agentstudio.resources.sessions import Sessions, AsyncSessions -from dashscope.agentstudio.resources.skills import ( +from dashscope.applications.agentstudio.resources.sessions import ( + Sessions, + AsyncSessions, +) +from dashscope.applications.agentstudio.resources.skills import ( AsyncSkillVersions, AsyncSkills, SkillVersions, Skills, ) -from dashscope.agentstudio.resources.vaults import ( +from dashscope.applications.agentstudio.resources.vaults import ( AsyncCredentials, AsyncVaults, Credentials, Vaults, ) -from dashscope.agentstudio.resources.webhook_endpoints import ( +from dashscope.applications.agentstudio.resources.webhook_endpoints import ( AsyncWebhookEndpoints, WebhookEndpoints, ) diff --git a/dashscope/agentstudio/resources/_helpers.py b/dashscope/applications/agentstudio/resources/_helpers.py similarity index 98% rename from dashscope/agentstudio/resources/_helpers.py rename to dashscope/applications/agentstudio/resources/_helpers.py index f47d6d94..653c2ebb 100644 --- a/dashscope/agentstudio/resources/_helpers.py +++ b/dashscope/applications/agentstudio/resources/_helpers.py @@ -6,7 +6,7 @@ from typing import Any, Mapping -from dashscope.agentstudio.types import ( +from dashscope.applications.agentstudio.types import ( Agent, AgentVersion, Credential, diff --git a/dashscope/agentstudio/resources/agents.py b/dashscope/applications/agentstudio/resources/agents.py similarity index 97% rename from dashscope/agentstudio/resources/agents.py rename to dashscope/applications/agentstudio/resources/agents.py index 53712811..d6040ed0 100644 --- a/dashscope/agentstudio/resources/agents.py +++ b/dashscope/applications/agentstudio/resources/agents.py @@ -6,17 +6,21 @@ from typing import Any, Mapping, Optional, Sequence, Union -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_agent, _coerce_agent_version, ) -from dashscope.agentstudio.types import Agent, AgentVersion, ModelConfig -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types import ( + Agent, + AgentVersion, + ModelConfig, +) +from dashscope.applications.agentstudio.types.params import ( AgentCreateParams, AgentListParams, AgentUpdateParams, diff --git a/dashscope/agentstudio/resources/deployments.py b/dashscope/applications/agentstudio/resources/deployments.py similarity index 98% rename from dashscope/agentstudio/resources/deployments.py rename to dashscope/applications/agentstudio/resources/deployments.py index 149b64f2..c3f49ebc 100644 --- a/dashscope/agentstudio/resources/deployments.py +++ b/dashscope/applications/agentstudio/resources/deployments.py @@ -6,17 +6,17 @@ from typing import Any, Mapping, Optional, Sequence -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_deployment, _coerce_deployment_run, ) -from dashscope.agentstudio.types import Deployment, DeploymentRun -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types import Deployment, DeploymentRun +from dashscope.applications.agentstudio.types.params import ( _NOT_GIVEN, DeploymentCreateParams, DeploymentListParams, diff --git a/dashscope/agentstudio/resources/environments.py b/dashscope/applications/agentstudio/resources/environments.py similarity index 96% rename from dashscope/agentstudio/resources/environments.py rename to dashscope/applications/agentstudio/resources/environments.py index 8aed2eeb..475ff309 100644 --- a/dashscope/agentstudio/resources/environments.py +++ b/dashscope/applications/agentstudio/resources/environments.py @@ -6,16 +6,19 @@ from typing import Any, Mapping, Optional -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_env, ) -from dashscope.agentstudio.types import DeleteResponse, Environment -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types import ( + DeleteResponse, + Environment, +) +from dashscope.applications.agentstudio.types.params import ( EnvironmentCreateParams, EnvironmentListParams, EnvironmentUpdateParams, diff --git a/dashscope/agentstudio/resources/files.py b/dashscope/applications/agentstudio/resources/files.py similarity index 97% rename from dashscope/agentstudio/resources/files.py rename to dashscope/applications/agentstudio/resources/files.py index e05c7be8..5afd489d 100644 --- a/dashscope/agentstudio/resources/files.py +++ b/dashscope/applications/agentstudio/resources/files.py @@ -18,16 +18,16 @@ Union, ) -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_file, ) -from dashscope.agentstudio.types import DeleteResponse, File -from dashscope.agentstudio.types.params import FileListParams +from dashscope.applications.agentstudio.types import DeleteResponse, File +from dashscope.applications.agentstudio.types.params import FileListParams _PATH_FILES = "/files" diff --git a/dashscope/agentstudio/resources/memory_stores.py b/dashscope/applications/agentstudio/resources/memory_stores.py similarity index 99% rename from dashscope/agentstudio/resources/memory_stores.py rename to dashscope/applications/agentstudio/resources/memory_stores.py index 573084a0..a08b153b 100644 --- a/dashscope/agentstudio/resources/memory_stores.py +++ b/dashscope/applications/agentstudio/resources/memory_stores.py @@ -41,12 +41,12 @@ from typing import Any, List, Mapping, Optional -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_memory, _coerce_memory_store, _coerce_memory_version, @@ -59,13 +59,13 @@ _memory_version_redact_path, _memory_versions_path, ) -from dashscope.agentstudio.types import ( +from dashscope.applications.agentstudio.types import ( DeleteResponse, Memory, MemoryStore, MemoryVersion, ) -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types.params import ( MemoryCreateParams, MemoryListParams, MemoryStoreCreateParams, diff --git a/dashscope/agentstudio/resources/security.py b/dashscope/applications/agentstudio/resources/security.py similarity index 95% rename from dashscope/agentstudio/resources/security.py rename to dashscope/applications/agentstudio/resources/security.py index a6ebc723..62c3d686 100644 --- a/dashscope/agentstudio/resources/security.py +++ b/dashscope/applications/agentstudio/resources/security.py @@ -12,8 +12,13 @@ from typing import Any, Mapping, Optional, Sequence -from dashscope.agentstudio.types import SecurityAlertList, SecurityOverview -from dashscope.agentstudio.types.params import SecurityListAgentLogsParams +from dashscope.applications.agentstudio.types import ( + SecurityAlertList, + SecurityOverview, +) +from dashscope.applications.agentstudio.types.params import ( + SecurityListAgentLogsParams, +) _PATH_SECURITY_OVERVIEW = "/security/overview" _PATH_SECURITY_AGENT_LOGS = "/security/agent_logs" diff --git a/dashscope/agentstudio/resources/session_events.py b/dashscope/applications/agentstudio/resources/session_events.py similarity index 93% rename from dashscope/agentstudio/resources/session_events.py rename to dashscope/applications/agentstudio/resources/session_events.py index aa272597..26970ad9 100644 --- a/dashscope/agentstudio/resources/session_events.py +++ b/dashscope/applications/agentstudio/resources/session_events.py @@ -2,6 +2,9 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """SessionEvents and AsyncSessionEvents resource classes.""" +# Facade and resource objects are one internal AgentStudio implementation. +# pylint: disable=protected-access + from __future__ import annotations from typing import ( @@ -14,23 +17,26 @@ Sequence, ) -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_event, _events_path, _stream_path, ) -from dashscope.agentstudio.streaming import AsyncEventStream, EventStream -from dashscope.agentstudio.types import ServerEvent -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.streaming import ( + AsyncEventStream, + EventStream, +) +from dashscope.applications.agentstudio.types import ServerEvent +from dashscope.applications.agentstudio.types.params import ( SessionEventListParams, SessionEventSendParams, ) -from dashscope.agentstudio.constants import ( +from dashscope.applications.agentstudio.constants import ( AGENTSTUDIO_DEFAULT_TIMEOUT, BlockType, SessionStatus, @@ -154,7 +160,10 @@ def stream( timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) return _TypedEventStream( - EventStream(response=resp), + self._client._event_stream_cls( + response=resp, + exceptions_module=self._client.transport.exceptions, + ), ) @@ -342,7 +351,10 @@ async def stream( timeout=timeout or AGENTSTUDIO_DEFAULT_TIMEOUT, ) return _AioTypedEventStream( - AsyncEventStream(response=resp), + self._client._event_stream_cls( + response=resp, + exceptions_module=self._client.transport.exceptions, + ), ) diff --git a/dashscope/agentstudio/resources/session_resources.py b/dashscope/applications/agentstudio/resources/session_resources.py similarity index 94% rename from dashscope/agentstudio/resources/session_resources.py rename to dashscope/applications/agentstudio/resources/session_resources.py index 1691a654..10478b6a 100644 --- a/dashscope/agentstudio/resources/session_resources.py +++ b/dashscope/applications/agentstudio/resources/session_resources.py @@ -10,18 +10,21 @@ from typing import Optional -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_session_resource, _session_resource_item_path, _session_resources_path, ) -from dashscope.agentstudio.types import DeleteResponse, SessionResource -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types import ( + DeleteResponse, + SessionResource, +) +from dashscope.applications.agentstudio.types.params import ( SessionResourceAddParams, SessionResourceListParams, ) diff --git a/dashscope/agentstudio/resources/session_threads.py b/dashscope/applications/agentstudio/resources/session_threads.py similarity index 96% rename from dashscope/agentstudio/resources/session_threads.py rename to dashscope/applications/agentstudio/resources/session_threads.py index e2f20313..ad70ed8f 100644 --- a/dashscope/agentstudio/resources/session_threads.py +++ b/dashscope/applications/agentstudio/resources/session_threads.py @@ -10,12 +10,12 @@ from typing import Optional, Sequence -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_event, _coerce_session_thread, _session_thread_archive_path, @@ -23,8 +23,8 @@ _session_thread_item_path, _session_threads_path, ) -from dashscope.agentstudio.types import ServerEvent, SessionThread -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types import ServerEvent, SessionThread +from dashscope.applications.agentstudio.types.params import ( SessionEventListParams, SessionThreadListParams, ) diff --git a/dashscope/agentstudio/resources/sessions.py b/dashscope/applications/agentstudio/resources/sessions.py similarity index 95% rename from dashscope/agentstudio/resources/sessions.py rename to dashscope/applications/agentstudio/resources/sessions.py index 4bb68f15..b03b3a64 100644 --- a/dashscope/agentstudio/resources/sessions.py +++ b/dashscope/applications/agentstudio/resources/sessions.py @@ -6,28 +6,28 @@ from typing import Any, Mapping, Optional, Sequence -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_session, ) -from dashscope.agentstudio.resources.session_events import ( +from dashscope.applications.agentstudio.resources.session_events import ( SessionEvents, AsyncSessionEvents, ) -from dashscope.agentstudio.resources.session_resources import ( +from dashscope.applications.agentstudio.resources.session_resources import ( SessionResources, AsyncSessionResources, ) -from dashscope.agentstudio.resources.session_threads import ( +from dashscope.applications.agentstudio.resources.session_threads import ( SessionThreads, AsyncSessionThreads, ) -from dashscope.agentstudio.types import DeleteResponse, Session -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types import DeleteResponse, Session +from dashscope.applications.agentstudio.types.params import ( SessionCreateParams, SessionListParams, SessionUpdateParams, diff --git a/dashscope/agentstudio/resources/skills.py b/dashscope/applications/agentstudio/resources/skills.py similarity index 97% rename from dashscope/agentstudio/resources/skills.py rename to dashscope/applications/agentstudio/resources/skills.py index c9d9833e..926cfa51 100644 --- a/dashscope/agentstudio/resources/skills.py +++ b/dashscope/applications/agentstudio/resources/skills.py @@ -7,17 +7,21 @@ import os # pylint: disable=unused-import # noqa: F401 from typing import Any, BinaryIO, Dict, Optional, Tuple, Union -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_skill, _coerce_skill_version, ) -from dashscope.agentstudio.types import DeleteResponse, Skill, SkillVersion -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types import ( + DeleteResponse, + Skill, + SkillVersion, +) +from dashscope.applications.agentstudio.types.params import ( SkillCreateParams, SkillListParams, SkillVersionCreateParams, diff --git a/dashscope/agentstudio/resources/vaults.py b/dashscope/applications/agentstudio/resources/vaults.py similarity index 98% rename from dashscope/agentstudio/resources/vaults.py rename to dashscope/applications/agentstudio/resources/vaults.py index c68e4922..2698ebc7 100644 --- a/dashscope/agentstudio/resources/vaults.py +++ b/dashscope/applications/agentstudio/resources/vaults.py @@ -6,21 +6,21 @@ from typing import Any, Mapping, Optional -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_credential, _coerce_vault, ) -from dashscope.agentstudio.types import ( +from dashscope.applications.agentstudio.types import ( Credential, DeleteResponse, Vault, ) -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types.params import ( CredentialCreateParams, CredentialListParams, CredentialUpdateParams, diff --git a/dashscope/agentstudio/resources/webhook_endpoints.py b/dashscope/applications/agentstudio/resources/webhook_endpoints.py similarity index 97% rename from dashscope/agentstudio/resources/webhook_endpoints.py rename to dashscope/applications/agentstudio/resources/webhook_endpoints.py index fc1a4485..422e5869 100644 --- a/dashscope/agentstudio/resources/webhook_endpoints.py +++ b/dashscope/applications/agentstudio/resources/webhook_endpoints.py @@ -6,23 +6,23 @@ from typing import Any, Dict, Optional, Sequence -from dashscope.agentstudio.pagination import ( +from dashscope.applications.agentstudio.pagination import ( AsyncCursorPage, CursorPage, build_page, ) -from dashscope.agentstudio.resources._helpers import ( +from dashscope.applications.agentstudio.resources._helpers import ( _coerce_webhook_endpoint, _coerce_webhook_event, _coerce_webhook_secret_reset, ) -from dashscope.agentstudio.types import ( +from dashscope.applications.agentstudio.types import ( WebhookEndpoint, WebhookEndpointList, WebhookEvent, WebhookSecretReset, ) -from dashscope.agentstudio.types.params import ( +from dashscope.applications.agentstudio.types.params import ( WebhookEndpointCreateParams, WebhookEndpointUpdateParams, WebhookEventListParams, diff --git a/dashscope/agentstudio/streaming.py b/dashscope/applications/agentstudio/streaming.py similarity index 84% rename from dashscope/agentstudio/streaming.py rename to dashscope/applications/agentstudio/streaming.py index bdbdc687..d8677f8e 100644 --- a/dashscope/agentstudio/streaming.py +++ b/dashscope/applications/agentstudio/streaming.py @@ -30,6 +30,7 @@ import asyncio import json +import logging import queue import threading from dataclasses import dataclass, field @@ -38,12 +39,32 @@ import httpx from httpx_sse import EventSource -from dashscope.agentstudio import exceptions -from dashscope.common.logging import logger +from dashscope.applications.agentstudio import ( + exceptions as _default_exceptions, +) + +# A child of the "dashscope" logger, so the level and handler installed by +# dashscope.legacy_1x.common.logging.enable_logging() still reach these warnings via +# propagation -- without importing that module (and its import-time side +# effects) just to name a logger. +logger = logging.getLogger("dashscope.applications.agentstudio.streaming") _DEFAULT_IDLE_TIMEOUT = 120.0 +def _raise_stream_error( + exc: Exception, + exceptions_module: Any = _default_exceptions, +) -> None: + if isinstance(exc, exceptions_module.AgentStudioError): + raise exc + if isinstance(exc, httpx.TimeoutException): + raise exceptions_module.APITimeoutError(str(exc)) from exc + if isinstance(exc, (httpx.NetworkError, httpx.RemoteProtocolError)): + raise exceptions_module.APIConnectionError(str(exc)) from exc + raise exc + + # --------------------------------------------------------------------------- # Iterators # --------------------------------------------------------------------------- @@ -67,6 +88,7 @@ class EventStream: response: httpx.Response idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT + exceptions_module: Any = _default_exceptions _event_source: EventSource = field(init=False) _closed: bool = False _producer: Optional[threading.Thread] = field(init=False, default=None) @@ -85,7 +107,9 @@ def __iter__(self) -> Iterator[Dict[str, Any]]: def _iter(self) -> Iterator[Dict[str, Any]]: if self._closed: - raise exceptions.StreamClosedError("stream already closed") + raise self.exceptions_module.StreamClosedError( + "stream already closed", + ) q: "queue.Queue[Any]" = queue.Queue() def _produce() -> None: @@ -122,7 +146,7 @@ def _produce() -> None: if item is None: return if isinstance(item, Exception): - raise item + _raise_stream_error(item, self.exceptions_module) yield item finally: self.close() @@ -148,6 +172,7 @@ class AsyncEventStream: response: httpx.Response idle_timeout: Optional[float] = _DEFAULT_IDLE_TIMEOUT + exceptions_module: Any = _default_exceptions _event_source: EventSource = field(init=False) _closed: bool = False _producer: Optional[asyncio.Task] = field(init=False, default=None) @@ -166,7 +191,9 @@ def __aiter__(self) -> AsyncIterator[Dict[str, Any]]: async def _aiter(self) -> AsyncIterator[Dict[str, Any]]: if self._closed: - raise exceptions.StreamClosedError("stream already closed") + raise self.exceptions_module.StreamClosedError( + "stream already closed", + ) q: "asyncio.Queue[Any]" = asyncio.Queue() async def _produce() -> None: @@ -205,7 +232,7 @@ async def _produce() -> None: if item is None: return if isinstance(item, Exception): - raise item + _raise_stream_error(item, self.exceptions_module) yield item finally: await self.aclose() diff --git a/dashscope/agentstudio/transport.py b/dashscope/applications/agentstudio/transport.py similarity index 92% rename from dashscope/agentstudio/transport.py rename to dashscope/applications/agentstudio/transport.py index 9083ba49..01323f5a 100644 --- a/dashscope/agentstudio/transport.py +++ b/dashscope/applications/agentstudio/transport.py @@ -27,14 +27,16 @@ import httpx -from dashscope.agentstudio import exceptions -from dashscope.agentstudio.constants import ( +from dashscope._shared._sdk_ident import get_sdk_headers +from dashscope.applications.agentstudio import ( + exceptions as _default_exceptions, +) +from dashscope.applications.agentstudio.constants import ( AGENTSTUDIO_DEFAULT_TIMEOUT, AGENTSTUDIO_MAX_RETRIES, ) -from dashscope.common.utils import get_sdk_headers -logger = logging.getLogger("dashscope.agentstudio") +logger = logging.getLogger("dashscope.applications.agentstudio") # --------------------------------------------------------------------------- # Base data classes (merged from _base.py) @@ -185,7 +187,10 @@ def _should_retry(status_code: int, headers: Mapping[str, str]) -> bool: return status_code in (408, 409, 429) or status_code >= 500 -def _should_retry_exception(err: BaseException) -> Tuple[bool, Optional[Any]]: +def _should_retry_exception( + err: BaseException, + exceptions_module: Any = _default_exceptions, +) -> Tuple[bool, Optional[Any]]: """Walk the ``__cause__`` chain to decide if an exception is retryable. Returns ``(should_retry, response_or_none)``. @@ -194,7 +199,7 @@ def _should_retry_exception(err: BaseException) -> Tuple[bool, Optional[Any]]: current: Optional[BaseException] = err while current is not None and id(current) not in seen: seen.add(id(current)) - if isinstance(current, exceptions.AgentStudioError): + if isinstance(current, exceptions_module.AgentStudioError): return False, None if isinstance(current, httpx.TimeoutException): return True, None @@ -216,11 +221,12 @@ def build_headers( user_agent: str, extra: Optional[Mapping[str, str]] = None, json_body: bool = True, + _exceptions_module: Any = _default_exceptions, ) -> Dict[str, str]: """Compose the canonical AgentStudio request headers.""" if not api_key: - raise exceptions.AuthenticationError( + raise _exceptions_module.AuthenticationError( "api_key is required. Pass it via Client(api_key=...) or " "the DASHSCOPE_API_KEY environment variable.", code="authentication_error", @@ -262,6 +268,7 @@ def __init__( timeout: Union[float, httpx.Timeout, Tuple[float, float], None], max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.Client] = None, + exceptions_module: Any = _default_exceptions, ) -> None: self.base_url = base_url.rstrip("/") self.api_key = api_key @@ -270,6 +277,7 @@ def __init__( self.user_agent = user_agent self.timeout = timeout self.max_retries = max_retries + self.exceptions = exceptions_module self._owns_client = http_client is None if http_client is not None: self._client = http_client @@ -336,6 +344,7 @@ def request( # pylint: disable=too-many-branches files is None and method.upper() not in ("GET", "HEAD", "DELETE") ), + _exceptions_module=self.exceptions, ) if files is not None: headers.pop("Content-Type", None) @@ -363,14 +372,14 @@ def request( # pylint: disable=too-many-branches except httpx.TimeoutException as exc: last_exc = exc if attempt >= self.max_retries: - raise exceptions.APITimeoutError(str(exc)) from exc + raise self.exceptions.APITimeoutError(str(exc)) from exc except Exception as exc: - if isinstance(exc, exceptions.AgentStudioError): + if isinstance(exc, self.exceptions.AgentStudioError): raise - should_retry, _ = _should_retry_exception(exc) + should_retry, _ = _should_retry_exception(exc, self.exceptions) last_exc = exc if attempt >= self.max_retries or not should_retry: - raise exceptions.APIConnectionError(str(exc)) from exc + raise self.exceptions.APIConnectionError(str(exc)) from exc else: if stream: if resp.status_code >= 400: @@ -381,7 +390,7 @@ def request( # pylint: disable=too-many-branches resp.status_code, resp.headers, ): - last_exc = exceptions.APIStatusError( + last_exc = self.exceptions.APIStatusError( f"HTTP {resp.status_code}", status_code=resp.status_code, ) @@ -399,7 +408,7 @@ def request( # pylint: disable=too-many-branches resp.close() time.sleep(_backoff(attempt)) attempt += 1 - raise exceptions.APIConnectionError( + raise self.exceptions.APIConnectionError( str(last_exc) if last_exc else "unknown", ) @@ -428,7 +437,7 @@ def _parse(self, resp: httpx.Response) -> APIResponse: header_rid, str(payload)[:500], ) - raise exceptions.from_response( + raise self.exceptions.from_response( status_code=resp.status_code, body=payload, headers=resp.headers, @@ -488,6 +497,7 @@ def __init__( timeout: Union[float, httpx.Timeout, Tuple[float, float], None], max_retries: int = AGENTSTUDIO_MAX_RETRIES, http_client: Optional[httpx.AsyncClient] = None, + exceptions_module: Any = _default_exceptions, ) -> None: self.base_url = base_url.rstrip("/") self.api_key = api_key @@ -496,6 +506,7 @@ def __init__( self.user_agent = user_agent self.timeout = timeout self.max_retries = max_retries + self.exceptions = exceptions_module self._owns_client = http_client is None if http_client is not None: self._client = http_client @@ -561,6 +572,7 @@ async def request( # pylint: disable=too-many-branches files is None and method.upper() not in ("GET", "HEAD", "DELETE") ), + _exceptions_module=self.exceptions, ) if files is not None: headers.pop("Content-Type", None) @@ -587,14 +599,14 @@ async def request( # pylint: disable=too-many-branches except httpx.TimeoutException as exc: last_exc = exc if attempt >= self.max_retries: - raise exceptions.APITimeoutError(str(exc)) from exc + raise self.exceptions.APITimeoutError(str(exc)) from exc except Exception as exc: - if isinstance(exc, exceptions.AgentStudioError): + if isinstance(exc, self.exceptions.AgentStudioError): raise - should_retry, _ = _should_retry_exception(exc) + should_retry, _ = _should_retry_exception(exc, self.exceptions) last_exc = exc if attempt >= self.max_retries or not should_retry: - raise exceptions.APIConnectionError(str(exc)) from exc + raise self.exceptions.APIConnectionError(str(exc)) from exc else: if stream: if resp.status_code >= 400: @@ -605,7 +617,7 @@ async def request( # pylint: disable=too-many-branches resp.status_code, resp.headers, ): - last_exc = exceptions.APIStatusError( + last_exc = self.exceptions.APIStatusError( f"HTTP {resp.status_code}", status_code=resp.status_code, ) @@ -623,7 +635,7 @@ async def request( # pylint: disable=too-many-branches await resp.aclose() await asyncio.sleep(_backoff(attempt)) attempt += 1 - raise exceptions.APIConnectionError( + raise self.exceptions.APIConnectionError( str(last_exc) if last_exc else "unknown", ) @@ -652,7 +664,7 @@ async def _parse(self, resp: httpx.Response) -> APIResponse: header_rid, str(payload)[:500], ) - raise exceptions.from_response( + raise self.exceptions.from_response( status_code=resp.status_code, body=payload, headers=resp.headers, diff --git a/dashscope/agentstudio/types/__init__.py b/dashscope/applications/agentstudio/types/__init__.py similarity index 90% rename from dashscope/agentstudio/types/__init__.py rename to dashscope/applications/agentstudio/types/__init__.py index 2bb98d1b..0f922387 100644 --- a/dashscope/agentstudio/types/__init__.py +++ b/dashscope/applications/agentstudio/types/__init__.py @@ -2,10 +2,10 @@ """AgentStudio type definitions. Re-exports all public symbols from sub-modules for backward compatibility: - from dashscope.agentstudio.types import Agent, Message, user_message + from dashscope.applications.agentstudio.types import Agent, Message, user_message """ -from dashscope.agentstudio.types.models import * # noqa: F401,F403 -from dashscope.agentstudio.types.models import ( # noqa: F401 +from dashscope.applications.agentstudio.types.models import * # noqa: F401,F403 +from dashscope.applications.agentstudio.types.models import ( # noqa: F401 BaseModel, Metadata, Scope, @@ -94,7 +94,7 @@ WebhookEventData, WebhookSecretReset, ) -from dashscope.agentstudio.types.params import ( # noqa: F401 +from dashscope.applications.agentstudio.types.params import ( # noqa: F401 AgentCreateParams, AgentUpdateParams, AgentListParams, diff --git a/dashscope/agentstudio/types/models.py b/dashscope/applications/agentstudio/types/models.py similarity index 99% rename from dashscope/agentstudio/types/models.py rename to dashscope/applications/agentstudio/types/models.py index dac21783..18fccf46 100644 --- a/dashscope/agentstudio/types/models.py +++ b/dashscope/applications/agentstudio/types/models.py @@ -27,7 +27,7 @@ Union, ) -from dashscope.agentstudio.constants import ( +from dashscope.applications.agentstudio.constants import ( SSEEventType, MessageRole, BlockType, diff --git a/dashscope/agentstudio/types/params.py b/dashscope/applications/agentstudio/types/params.py similarity index 99% rename from dashscope/agentstudio/types/params.py rename to dashscope/applications/agentstudio/types/params.py index 6742e94b..7b76d08e 100644 --- a/dashscope/agentstudio/types/params.py +++ b/dashscope/applications/agentstudio/types/params.py @@ -4,7 +4,7 @@ Each resource group (Agents, Environments, Sessions, …) defines the request-body structures for its write endpoints. All classes inherit -from :class:`~dashscope.agentstudio.types.models.BaseModel` so that +from :class:`~dashscope.applications.agentstudio.types.models.BaseModel` so that ``.to_dict()`` produces a ready-to-send wire-format dict. The ``__init__`` signature uses **user-friendly** parameter names @@ -18,7 +18,10 @@ from typing import Any, Dict, Mapping, Optional, Sequence, Union -from dashscope.agentstudio.types.models import BaseModel, ModelConfig +from dashscope.applications.agentstudio.types.models import ( + BaseModel, + ModelConfig, +) _NOT_GIVEN = object() diff --git a/dashscope/api_entities/__init__.py b/dashscope/applications/finetune/__init__.py similarity index 100% rename from dashscope/api_entities/__init__.py rename to dashscope/applications/finetune/__init__.py diff --git a/dashscope/applications/finetune/_submit.py b/dashscope/applications/finetune/_submit.py new file mode 100644 index 00000000..873a3434 --- /dev/null +++ b/dashscope/applications/finetune/_submit.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The job-submit POST for ``AgenticRL``, self-contained inside 2.0. + +``AgenticRL.submit_job`` used to inherit ``CreateMixin`` from +``dashscope.legacy_1x.client.base_api`` for one non-streaming POST. Keeping that +would have left the last import of the 1.x client inside ``dashscope``, +so the call is spelled out here instead: a few dozen lines, against +pulling the 1.x request/response stack (``default_headers``, ``join_url``, +``_handle_http_stream_response``) into the 2.0 package. + +The wire format is deliberately byte-identical to what ``CreateMixin`` +sent, header order included, because the backend keys its statistics off +``x-dashscope-sdk-client`` and ``user-agent``: + +- The module tag stays the *unmarked* ``finetune``. Deriving it from this + module's dotted name would now yield ``v2``, and the v2 transport's + marked form (``finetune2``) belongs to ``client.rl.jobs`` rather than + to the submit path. Both spellings already exist today; each keeps its + own. +- The identity headers come from ``dashscope._shared._sdk_ident``, so the + CLI's ``set_sdk_client("python-cli")`` override and the process-wide + session id still apply, and ``DASHSCOPE_DISABLE_SDK_HEADERS`` still + suppresses them. + +Only the non-SSE branches of the 1.x response parser are mirrored. +``submit_job`` posts without streaming to ``/fine-tunes``, which answers +``application/json``; a ``text/event-stream`` reply could not be consumed +meaningfully on this path, so there is nothing to reproduce. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict + +import requests + +from dashscope._shared._sdk_ident import get_sdk_headers, get_user_agent + +__all__ = ["SUBMIT_TIMEOUT", "post_submit"] + +# Mirrors 1.x's DEFAULT_REQUEST_TIMEOUT_SECONDS. +SUBMIT_TIMEOUT = 300 + +_JSON = "application/json; charset=utf-8" + +# Hard-coded rather than derived from __module__: see the module +# docstring. The backend already reports on the unmarked spelling. +_SDK_MODULE = "finetune" + + +def _headers(api_key: str) -> Dict[str, str]: + """Build the request headers in 1.x's insertion order.""" + headers = {"Content-Type": _JSON, "user-agent": get_user_agent()} + headers.update(get_sdk_headers(module=_SDK_MODULE)) + headers["Authorization"] = f"Bearer {api_key}" + headers["Accept"] = _JSON + return headers + + +def _success(response: requests.Response) -> Dict[str, Any]: + body = response.json() + output: Any = body.get("data") + if "output" in body: + output = body["output"] + request_id = body.pop("request_id", "") + if "data" not in body and "output" not in body: + output = body + return { + "request_id": request_id, + "status_code": response.status_code, + "code": body.get("code"), + "message": body.get("message", ""), + "output": output, + "usage": body.get("usage"), + "headers": dict(response.headers), + } + + +def _failure(response: requests.Response) -> Dict[str, Any]: + code: Any = "Unknown" + message: Any = response.content.decode("utf-8") + request_id = "" + if "application/json" in response.headers.get("content-type", ""): + error = response.json() + code = error.get("code") + message = error.get("message", "") + if "msg" in error: + message = error["msg"] + request_id = error.get("request_id", "") + return { + "request_id": request_id, + "status_code": response.status_code, + "code": code, + "message": message, + "output": None, + "usage": None, + "headers": dict(response.headers), + } + + +def post_submit( + request: Dict[str, Any], + *, + api_key: str, + base_url: str, + path: str, + timeout: int = SUBMIT_TIMEOUT, +) -> Dict[str, Any]: + """POST ``request`` and return the raw response envelope. + + The seven keys are the ones ``DashScopeAPIResponse`` carries, so the + 1.x surface can keep building ``FineTune(**envelope)`` and the 2.0 + surface can wrap the same dict in ``SubmitResult``. + + ``base_url`` is used verbatim. The RL constants hold no + ``{workspace_id}`` placeholder and ``submit_job`` never sends a + workspace, so 1.x's ``resolve_base_url`` and ``_workspace_header`` + were both no-ops on this path and are not reproduced. + + Args: + request: The job payload, serialized with ``ensure_ascii=False`` + so non-ASCII job names reach the server intact. + api_key: Bearer token; the caller resolves it. + base_url: Management-plane host, without a trailing slash. + path: Endpoint under ``base_url``. + timeout: Seconds for the whole request. + """ + url = f"{base_url.rstrip('/')}/{path.lstrip('/')}" + with requests.Session() as session: + response = session.post( + url, + data=json.dumps(request, ensure_ascii=False).encode("utf-8"), + headers=_headers(api_key), + timeout=timeout, + ) + if response.status_code in (200, 201): + return _success(response) + return _failure(response) diff --git a/dashscope/finetune/agentic_rl.py b/dashscope/applications/finetune/agentic_rl.py similarity index 68% rename from dashscope/finetune/agentic_rl.py rename to dashscope/applications/finetune/agentic_rl.py index 6eb707a2..30bb9992 100644 --- a/dashscope/finetune/agentic_rl.py +++ b/dashscope/applications/finetune/agentic_rl.py @@ -1,21 +1,33 @@ # -*- coding: utf-8 -*- -from __future__ import annotations - # Copyright (c) Alibaba, Inc. and its affiliates. +"""The agentic-RL workflow driver, owned by the 2.0 package. + +``Client(...).rl`` is built on this class. The 1.x job-management surface +-- ``FineTunes`` / ``Deployments`` and the ``FineTune`` dataclasses -- +stays at ``dashscope.legacy_1x.finetune``; the thin subclass there re-adds +those five verbs and swaps ``SubmitResult`` back for ``FineTune``. + +Two deliberate differences from the 1.x class this was split out of: + +- ``submit_job`` returns ``SubmitResult`` instead of a 1.x ``FineTune`` + dataclass, and posts through ``dashscope.applications.finetune._submit`` instead + of inheriting ``CreateMixin``. +- The api key is the constructor argument or ``DASHSCOPE_API_KEY``, never + the ``dashscope.api_key`` root global. ``set_api_key`` already writes + the argument into that env var and raises when it cannot, so resolution + here is unambiguous -- reading the root global could silently disagree + with the key this instance was built with. +""" +from __future__ import annotations + +import os import time from typing import Union, List, Optional, ClassVar, Dict, Any from typing_extensions import Self -from dashscope.client.base_api import CreateMixin -from dashscope.finetune.customize_types import ( - FineTune, - FineTuneCancel, - FineTuneDelete, - FineTuneList, -) -from dashscope.finetune.finetunes import FineTunes -from dashscope.finetune.reinforcement import ( +from dashscope.applications.finetune import _submit +from dashscope.applications.finetune.reinforcement import ( AgenticRLFunctionComponent, RolloutFunctionComponent, RewardFunctionComponent, @@ -23,26 +35,31 @@ TrainingDataset, ValidationDataset, ) -from dashscope.finetune.reinforcement import AgenticRLTuning, TuningModel -from dashscope.finetune.reinforcement import DASHSCOPE_HTTP_BASE_URL -from dashscope.finetune.reinforcement import ( +from dashscope.applications.finetune.reinforcement import ( + AgenticRLTuning, + TuningModel, +) +from dashscope.applications.finetune.reinforcement import ( + DASHSCOPE_HTTP_BASE_URL, +) +from dashscope.applications.finetune.reinforcement import ( FunctionType, DatasetsType, TrainingType, ) -from dashscope.finetune.reinforcement import ( +from dashscope.applications.finetune.reinforcement import ( RewardInput, RolloutInput, GroupRewardInput, ) -from dashscope.finetune.reinforcement import logger -from dashscope.finetune.reinforcement import ( +from dashscope.applications.finetune.reinforcement import logger +from dashscope.applications.finetune.reinforcement import ( set_api_key, generate_random_id, get_func_type_id, deep_remove_none, ) -from dashscope.finetune.reinforcement.common.errors import ( +from dashscope.applications.finetune.reinforcement.common.errors import ( RegistrationError, ValidationError, InstanceQueryError, @@ -51,8 +68,69 @@ DatasetsError, ) +__all__ = ["AgenticRL", "SubmitOutput", "SubmitResult"] + + +def _inner_code(exc: Exception) -> str: + return getattr(exc, "code", None) or type(exc).__name__ + + +class SubmitOutput: + """The ``output`` payload nested in a submit response. + + A plain attribute container on purpose: the server's job payload is + open-ended, and ``reinforcement_learning._to_job`` flattens it with + ``vars(output)``, which needs a ``__dict__``. + """ + + def __init__(self, **kwargs: Any): + self.__dict__.update(kwargs) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.__dict__!r})" -class AgenticRL(AgenticRLTuning, CreateMixin): + +class SubmitResult: + """The envelope ``AgenticRL.submit_job`` returns. + + Carries the same seven fields as 1.x's ``DashScopeAPIResponse``, so + ``FineTune(**envelope)`` and ``SubmitResult(**envelope)`` accept the + same dict. A failed submit is a non-200 ``status_code`` here rather + than an exception, exactly as it was under ``FineTune``. + """ + + def __init__( + self, + status_code: int = None, + request_id: str = "", + code: str = None, + message: str = "", + output: Any = None, + usage: Any = None, + headers: Dict[str, Any] = None, + **kwargs: Any, + ): + self.__dict__.update(kwargs) + self.status_code = status_code + self.request_id = request_id + self.code = code + self.message = message + self.output = ( + SubmitOutput(**output) if isinstance(output, dict) else output + ) + self.usage = usage + self.headers = headers or {} + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(status_code={self.status_code!r}, " + f"request_id={self.request_id!r}, code={self.code!r}, " + f"message={self.message!r}, output={self.output!r}, " + f"usage={self.usage!r})" + ) + + +class AgenticRL(AgenticRLTuning): SUB_PATH: ClassVar[str] = "fine-tunes" def __init__(self, api_key: str = None): @@ -61,11 +139,17 @@ def __init__(self, api_key: str = None): try: set_api_key(api_key) except Exception as e: - raise ValueErrorWithCode( - "Invalid API key configuration", + raise ValueErrorWithCode.from_catalog( + "sdk.InvalidApiKey", error_code=3001, ) from e + self._api_key = api_key + + def _resolve_api_key(self) -> str: + """The key to submit with; see the module docstring.""" + return self._api_key or os.environ.get("DASHSCOPE_API_KEY", "") + def init(self, config_path: Optional[str] = None, **kwargs) -> Self: """ Initialize an AgenticRL instance from a YAML configuration file. @@ -111,9 +195,10 @@ async def register_functions( except Exception as e: if hasattr(e, "error_code"): raise - raise RegistrationError( - "Function registration failed", + raise RegistrationError.from_catalog( + "sdk.agentic_rl.FunctionRegistrationFailed", error_code=3002, + variables={"inner_code": _inner_code(e)}, ) from e return ( @@ -144,14 +229,15 @@ async def upload_datasets( ) logger.info("Datasets uploaded") except Exception as e: - raise DatasetsError( - "Datasets upload failed", + raise DatasetsError.from_catalog( + "sdk.agentic_rl.DatasetsUploadFailed", error_code=3003, + variables={"inner_code": _inner_code(e)}, ) from e return uploaded_training_ids, uploaded_validation_ids - def submit_job( + def _build_submit_request( self, model: Optional[str] = None, datasets: Optional[List[Dataset]] = None, @@ -173,10 +259,11 @@ def submit_job( resources: Optional[Dict[str, str]] = None, job_name: Optional[str] = None, teacher_model: Optional[str] = None, - **kwargs, - ) -> FineTune: - """ - Submit RL tuning job to the platform. + ) -> Dict[str, Any]: + """Assemble the job payload, folding the arguments into ``tuning``. + + Split out of ``submit_job`` so the 1.x surface can reuse it + without touching the transport. """ teacher_model = teacher_model or self.tuning.teacher_model @@ -206,10 +293,17 @@ def submit_job( ) # names of functions if not self.tuning.check_function_names(): - raise ValueErrorWithCode( - "Duplicate function names detected. All function names must " - "be unique.", + names = [ + getattr(fc, "name", "") + for fc in self.tuning.functions + ] + duplicates = sorted( + {name for name in names if names.count(name) > 1}, + ) + raise ValueErrorWithCode.from_catalog( + "sdk.agentic_rl.DuplicateFunctionNames", error_code=3004, + variables={"names": ", ".join(duplicates or names)}, ) # datasets @@ -260,21 +354,102 @@ def submit_job( request = deep_remove_none(request) logger.info(f"agentic_rl submit_job request: {request}") - kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL + return request + + def _submit_envelope( + self, + model: Optional[str] = None, + datasets: Optional[List[Dataset]] = None, + functions: Optional[ + Union[ + List[ + Union[ + RolloutFunctionComponent, + RewardFunctionComponent, + AgenticRLFunctionComponent, + ] + ], + RolloutFunctionComponent, + RewardFunctionComponent, + AgenticRLFunctionComponent, + ] + ] = None, + hyper_parameters: Optional[Dict[str, str]] = None, + resources: Optional[Dict[str, str]] = None, + job_name: Optional[str] = None, + teacher_model: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: + """Build the payload, POST it, and return the raw envelope. + + Shared by both surfaces: this class wraps the result in + ``SubmitResult``, the 1.x subclass in a ``FineTune`` dataclass. + ``kwargs`` are forwarded to ``post_submit`` (``path``, + ``timeout``, and overrides for ``api_key`` / ``base_url``). + """ + request = self._build_submit_request( + model=model, + datasets=datasets, + functions=functions, + hyper_parameters=hyper_parameters, + resources=resources, + job_name=job_name, + teacher_model=teacher_model, + ) + + kwargs.setdefault("api_key", self._resolve_api_key()) + kwargs.setdefault("base_url", DASHSCOPE_HTTP_BASE_URL) + kwargs.setdefault("path", self.SUB_PATH) try: - resp = super().call( - request, - **kwargs, - ) + return _submit.post_submit(request, **kwargs) except Exception as e: if hasattr(e, "error_code"): raise - raise RuntimeErrorWithCode( - "Job submission failed", + raise RuntimeErrorWithCode.from_catalog( + "sdk.agentic_rl.JobSubmissionFailed", error_code=3005, + variables={"inner_code": _inner_code(e)}, ) from e - return FineTune(**resp) + def submit_job( + self, + model: Optional[str] = None, + datasets: Optional[List[Dataset]] = None, + functions: Optional[ + Union[ + List[ + Union[ + RolloutFunctionComponent, + RewardFunctionComponent, + AgenticRLFunctionComponent, + ] + ], + RolloutFunctionComponent, + RewardFunctionComponent, + AgenticRLFunctionComponent, + ] + ] = None, + hyper_parameters: Optional[Dict[str, str]] = None, + resources: Optional[Dict[str, str]] = None, + job_name: Optional[str] = None, + teacher_model: Optional[str] = None, + **kwargs, + ) -> SubmitResult: + """ + Submit RL tuning job to the platform. + """ + return SubmitResult( + **self._submit_envelope( + model=model, + datasets=datasets, + functions=functions, + hyper_parameters=hyper_parameters, + resources=resources, + job_name=job_name, + teacher_model=teacher_model, + **kwargs, + ), + ) async def run( self, @@ -303,7 +478,7 @@ async def run( job_name: Optional[str] = None, teacher_model: Optional[str] = None, **kwargs, - ) -> FineTune: + ) -> SubmitResult: """ Execute RL tuning workflow. """ @@ -336,107 +511,12 @@ async def run( except Exception as e: if hasattr(e, "error_code"): raise - raise RuntimeErrorWithCode( - "RL tuning workflow failed", + raise RuntimeErrorWithCode.from_catalog( + "sdk.agentic_rl.WorkflowFailed", error_code=3006, + variables={"inner_code": _inner_code(e)}, ) from e - @classmethod - def cancel( - cls, - job_id: str, - api_key: str = None, - workspace: str = None, - **kwargs, - ) -> FineTuneCancel: - """Cancel a running fine-tune job.""" - kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL - - return FineTunes.cancel( - job_id, - api_key=api_key, - workspace=workspace, - **kwargs, - ) - - @classmethod - def list( - cls, - page_no=1, - page_size=10, - api_key: str = None, - workspace: str = None, - **kwargs, - ) -> FineTuneList: - """List fine-tune jobs.""" - kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL - - return FineTunes.list( - page_no=page_no, - page_size=page_size, - api_key=api_key, - workspace=workspace, - **kwargs, - ) - - @classmethod - def get( - cls, - job_id: str, - api_key: str = None, - workspace: str = None, - **kwargs, - ) -> FineTune: - """Get fine-tune job information.""" - kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL - - return FineTunes.get( - job_id, - api_key=api_key, - workspace=workspace, - **kwargs, - ) - - @classmethod - def delete( - cls, - job_id: str, - api_key: str = None, - workspace: str = None, - **kwargs, - ) -> FineTuneDelete: - """Delete a fine-tune job.""" - kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL - - return FineTunes.delete( - job_id, - api_key=api_key, - workspace=workspace, - **kwargs, - ) - - @classmethod - def logs( - cls, - job_id: str, - offset: int = 1, - lines: int = 1000, - api_key: str = None, - workspace: str = None, - **kwargs, - ) -> FineTune: - """Get job logs.""" - kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL - - return FineTunes.logs( - job_id, - offset=offset, - line=lines, - api_key=api_key, - workspace=workspace, - **kwargs, - ) - @classmethod async def test_functions( cls, @@ -476,9 +556,10 @@ async def test_functions( elif functype == FunctionType.GROUP_REWARD: value = GroupRewardInput.model_validate(input_data) else: - raise ValueErrorWithCode( - f"Unsupported function type: {functype}", + raise ValueErrorWithCode.from_catalog( + "sdk.agentic_rl.UnsupportedFunctionType", error_code=3007, + variables={"functype": str(functype)}, ) logger.info( @@ -505,9 +586,10 @@ async def test_functions( return result except Exception as e: - raise ValidationError( - "Function test failed", + raise ValidationError.from_catalog( + "sdk.agentic_rl.FunctionTestFailed", error_code=3008, + variables={"inner_code": _inner_code(e)}, ) from e @classmethod diff --git a/dashscope/finetune/reinforcement/__init__.py b/dashscope/applications/finetune/reinforcement/__init__.py similarity index 86% rename from dashscope/finetune/reinforcement/__init__.py rename to dashscope/applications/finetune/reinforcement/__init__.py index bd4d16c5..88df537a 100644 --- a/dashscope/finetune/reinforcement/__init__.py +++ b/dashscope/applications/finetune/reinforcement/__init__.py @@ -9,7 +9,7 @@ "install. Reinstall with: pip install -U dashscope", ) from _rl_dep_err -from dashscope.finetune.reinforcement.common.constants import ( +from .common.constants import ( BAILIAN_FILE_API, BAILIAN_FILE_TIMEOUT, DASHSCOPE_API_KEY, @@ -33,6 +33,8 @@ FC_LAYER_QUERY_API, FC_LAYER_USED, FC_LAYER_NAME, + FC_LAYER_STATUS_TIMEOUT, + FC_LAYER_STATUS_INTERVAL, FC_REQUIREMENTS_FILE, FC_ZIP_EXCLUDE_PATTERNS, FC_OSS_FILE_SIZE_WARNING, @@ -40,7 +42,7 @@ HTTP_REQUEST_TIMEOUT, LOGGER_FILTER_FIELDS, ) -from dashscope.finetune.reinforcement.common.errors import ( +from .common.errors import ( AgenticRLError, InputError, OutputError, @@ -60,8 +62,8 @@ RuntimeErrorWithCode, ValueErrorWithCode, ) -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.common.model import ( +from .common.log import logger +from .common.model import ( FunctionComponentModel, TuningModel, FoundationModel, @@ -80,7 +82,7 @@ AgenticRLTuning, Models, ) -from dashscope.finetune.reinforcement.common.model_types import ( +from .common.model_types import ( FileSpec, FunctionType, DatasetsType, @@ -91,7 +93,7 @@ StatusType, TrainingType, ) -from dashscope.finetune.reinforcement.common.utils import ( +from .common.utils import ( check_file, client_fc, create_deployment_files, @@ -108,12 +110,12 @@ get_weights_from_file, serialize_for_output, ) -from dashscope.finetune.reinforcement.component import ( +from .component import ( reward_func, sub_reward_func, aggregate_func, ) -from dashscope.finetune.reinforcement.component.data import ( +from .component.data import ( AgentOutput, Reward, RewardInput, @@ -125,29 +127,20 @@ GroupRewardOutput, TaskStatus, ) -from dashscope.finetune.reinforcement.component.func_decorator import ( +from .component.func_decorator import ( RewardProcessorMeta, ) -from dashscope.finetune.reinforcement.component.processor import ( +from .component.processor import ( AbstractRolloutProcessor, ) -from dashscope.finetune.reinforcement.component.processor import ( +from .component.processor import ( AbstractRewardProcessor, ) -from dashscope.finetune.reinforcement.component.processor import ( +from .component.processor import ( AbstractGroupRewardProcessor, ) -def __getattr__(name): - if name == "app": - from dashscope.cli.agentic_rl import app - - return app - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -# pylint: disable=undefined-all-variable __all__ = [ # Constants "BAILIAN_FILE_API", @@ -173,6 +166,8 @@ def __getattr__(name): "FC_LAYER_QUERY_API", "FC_LAYER_USED", "FC_LAYER_NAME", + "FC_LAYER_STATUS_TIMEOUT", + "FC_LAYER_STATUS_INTERVAL", "FC_REQUIREMENTS_FILE", "FC_ZIP_EXCLUDE_PATTERNS", "FC_OSS_FILE_SIZE_WARNING", @@ -265,3 +260,11 @@ def __getattr__(name): "Models", "app", ] + + +def __getattr__(name): + if name == "app": + from dashscope.legacy_1x.cli.agentic_rl import app + + return app + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/dashscope/client/__init__.py b/dashscope/applications/finetune/reinforcement/common/__init__.py similarity index 100% rename from dashscope/client/__init__.py rename to dashscope/applications/finetune/reinforcement/common/__init__.py diff --git a/dashscope/finetune/reinforcement/common/errors.py b/dashscope/applications/finetune/reinforcement/common/_legacy_errors.py similarity index 100% rename from dashscope/finetune/reinforcement/common/errors.py rename to dashscope/applications/finetune/reinforcement/common/_legacy_errors.py diff --git a/dashscope/finetune/reinforcement/common/constants.py b/dashscope/applications/finetune/reinforcement/common/constants.py similarity index 81% rename from dashscope/finetune/reinforcement/common/constants.py rename to dashscope/applications/finetune/reinforcement/common/constants.py index e87de62e..e29a7273 100644 --- a/dashscope/finetune/reinforcement/common/constants.py +++ b/dashscope/applications/finetune/reinforcement/common/constants.py @@ -35,7 +35,13 @@ def get_int_env(env_var: str, default: int) -> int: # -------------------------- # Logging Configuration # -------------------------- -LOGGER_NAME = os.environ.get("LOGGER_NAME", "dashscope.finetune.reinforcement") +# The logger name is an observability contract: user log filters and +# existing pipelines match on it, so it keeps the published name even +# though the package itself now lives under ``dashscope``. +LOGGER_NAME = os.environ.get( + "LOGGER_NAME", + "dashscope.applications.finetune.reinforcement", +) LOG_LEVEL = os.environ.get("LOG_LEVEL", "info").upper() LOGGER_FILTER_FIELDS = { "trigger_token", @@ -112,11 +118,19 @@ def get_int_env(env_var: str, default: int) -> int: ) FC_SERVER_CLASSPATH = os.environ.get( "FC_SERVER_CLASSPATH", - "dashscope.finetune.reinforcement.component.server.server", + # Written into the start.sh deployed to the Function Compute + # sandbox, where the installed dashscope may be any published + # version. Both the new public path and the old finetune path keep + # resolving through reverse-reference shims. + "dashscope.applications.finetune.reinforcement.component.server.server", ) FC_WORKERS_COUNT = get_int_env("FC_WORKERS_COUNT", 2) FC_LAYER_USED = get_bool_env("FC_LAYER_USED", True) FC_LAYER_NAME = "fc_layer" +# Building a layer installs requirements.txt inside Function Compute, so a +# cold build routinely outlasts a single HTTP timeout. +FC_LAYER_STATUS_TIMEOUT = get_int_env("FC_LAYER_STATUS_TIMEOUT", 600) +FC_LAYER_STATUS_INTERVAL = get_int_env("FC_LAYER_STATUS_INTERVAL", 10) FC_REQUIREMENTS_FILE = "./requirements.txt" FC_ZIP_EXCLUDE_PATTERNS = os.environ.get( "FC_ZIP_EXCLUDE_PATTERNS", diff --git a/dashscope/applications/finetune/reinforcement/common/errors.py b/dashscope/applications/finetune/reinforcement/common/errors.py new file mode 100644 index 00000000..94f7e2ea --- /dev/null +++ b/dashscope/applications/finetune/reinforcement/common/errors.py @@ -0,0 +1,421 @@ +# -*- coding: utf-8 -*- +# pylint: disable=too-many-ancestors +"""Native v2 errors for AgenticRL, with legacy numeric-code compatibility.""" + +from datetime import datetime +from typing import Any, Dict, Optional + +from dashscope.errors import _CatalogSdkError, sdk_error + +from ._legacy_errors import ( + AgenticRLError as _LegacyAgenticRLError, + BaseConnectionError as _LegacyBaseConnectionError, + BasePermissionError as _LegacyBasePermissionError, + ConfigurationError as _LegacyConfigurationError, + DatasetsError as _LegacyDatasetsError, + DeploymentError as _LegacyDeploymentError, + FunctionLayerError as _LegacyFunctionLayerError, + FunctionLoadError as _LegacyFunctionLoadError, + IOErrorWithCode as _LegacyIOErrorWithCode, + InputError as _LegacyInputError, + InstanceQueryError as _LegacyInstanceQueryError, + InstanceWarmupError as _LegacyInstanceWarmupError, + OSSConnectionError as _LegacyOSSConnectionError, + OSSUploadError as _LegacyOSSUploadError, + OutputError as _LegacyOutputError, + RegistrationError as _LegacyRegistrationError, + RuntimeErrorWithCode as _LegacyRuntimeErrorWithCode, + ValidationError as _LegacyValidationError, + ValueErrorWithCode as _LegacyValueErrorWithCode, +) + +_LEGACY_ERROR_CODES = { + "sdk.agentic_rl.Error": 1000, + "sdk.agentic_rl.IOErrorWithCode": 1800, + "sdk.agentic_rl.RuntimeErrorWithCode": 0, + "sdk.agentic_rl.ValueErrorWithCode": 0, + "sdk.agentic_rl.InputError": 1100, + "sdk.agentic_rl.OutputError": 1200, + "sdk.agentic_rl.BaseConnectionError": 1300, + "sdk.agentic_rl.OSSConnectionError": 1310, + "sdk.agentic_rl.OSSUploadError": 1320, + "sdk.agentic_rl.DeploymentError": 1400, + "sdk.agentic_rl.RegistrationError": 1410, + "sdk.agentic_rl.DatasetsError": 1460, + "sdk.agentic_rl.FunctionLoadError": 1420, + "sdk.agentic_rl.FunctionLayerError": 1450, + "sdk.agentic_rl.InstanceWarmupError": 1430, + "sdk.agentic_rl.InstanceQueryError": 1440, + "sdk.agentic_rl.ValidationError": 1500, + "sdk.agentic_rl.ConfigurationError": 1510, + "sdk.agentic_rl.BasePermissionError": 1700, +} + + +class _RootCauseMixin: + @property + def root_cause(self) -> Exception: + root: Exception = self # type: ignore[assignment] + seen = {id(root)} + while root.__cause__ and id(root.__cause__) not in seen: + root = root.__cause__ + seen.add(id(root)) + return root + + def _format_cause(self) -> str: + if self.__cause__ is not None: + root = self.root_cause + if self is not root: + cause_msg = str(root).split("\n", maxsplit=1)[0][:100].strip() + if cause_msg: + return f" (caused by: {type(root).__name__}: {cause_msg})" + return f" (caused by: {type(root).__name__})" + return "" + + +class AgenticRLError( + _RootCauseMixin, + _CatalogSdkError, + _LegacyAgenticRLError, +): + """Base class for AgenticRL failures in the v2 error family.""" + + _SDK_ERROR_NAME = "sdk.agentic_rl.Error" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + **kwargs: Any, + ) -> None: + self._legacy_error_code = error_code + self.timestamp = datetime.now().isoformat() + super().__init__(message, **kwargs) + # Legacy compatibility bases run inside the cooperative MRO and set + # their own defaults. Restore the v2/catalog selection requested by + # this constructor after that initialization completes. + self._legacy_error_code = error_code + + @classmethod + def from_catalog( + cls, + name: str, + *, + error_code: int, + variables: Optional[Dict[str, str]] = None, + ) -> "AgenticRLError": + error = sdk_error(name, variables) + return cls( + error.message, + error_code=error_code, + code=error.code, + solution=error.solution, + allow_retry=error.allow_retry, + ) + + @property + def error_code(self) -> int: + if self._legacy_error_code is not None: + return self._legacy_error_code + return _LEGACY_ERROR_CODES.get(self.code or "", 1000) + + @error_code.setter + def error_code(self, value: int) -> None: + self._legacy_error_code = value + + def __str__(self) -> str: + return f"{super().__str__()}{self._format_cause()}" + + +class IOErrorWithCode(AgenticRLError, _LegacyIOErrorWithCode): + _SDK_ERROR_NAME = "sdk.agentic_rl.IOErrorWithCode" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + path: Optional[str] = None, + operation: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(f"I/O error: {message}", error_code, **kwargs) + self.path = path + self.operation = operation + + +class RuntimeErrorWithCode( # type: ignore[misc] + AgenticRLError, + _LegacyRuntimeErrorWithCode, +): + _SDK_ERROR_NAME = "sdk.agentic_rl.RuntimeErrorWithCode" + + +class ValueErrorWithCode( # type: ignore[misc] + AgenticRLError, + _LegacyValueErrorWithCode, +): + _SDK_ERROR_NAME = "sdk.agentic_rl.ValueErrorWithCode" + + +class InputError(AgenticRLError, _LegacyInputError): + _SDK_ERROR_NAME = "sdk.agentic_rl.InputError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + field: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(message, error_code, **kwargs) + self.field = field + + +class OutputError(AgenticRLError, _LegacyOutputError): + _SDK_ERROR_NAME = "sdk.agentic_rl.OutputError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + response: Optional[Dict] = None, + **kwargs: Any, + ) -> None: + super().__init__(message, error_code, **kwargs) + self.response = response + + +class BaseConnectionError(AgenticRLError, _LegacyBaseConnectionError): + _SDK_ERROR_NAME = "sdk.agentic_rl.BaseConnectionError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + endpoint: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(message, error_code, **kwargs) + self.endpoint = endpoint + + +class OSSConnectionError(BaseConnectionError, _LegacyOSSConnectionError): + _SDK_ERROR_NAME = "sdk.agentic_rl.OSSConnectionError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + endpoint: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__( + f"OSS connection failed: {message}", + error_code, + endpoint, + **kwargs, + ) + + +class OSSUploadError(BaseConnectionError, _LegacyOSSUploadError): + _SDK_ERROR_NAME = "sdk.agentic_rl.OSSUploadError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + endpoint: Optional[str] = None, + bucket: Optional[str] = None, + object_key: Optional[str] = None, + file_size: Optional[int] = None, + **kwargs: Any, + ) -> None: + super().__init__( + f"OSS upload failed: {message}", + error_code, + endpoint, + **kwargs, + ) + self.bucket = bucket + self.object_key = object_key + self.file_size = file_size + + +class DeploymentError(AgenticRLError, _LegacyDeploymentError): + _SDK_ERROR_NAME = "sdk.agentic_rl.DeploymentError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + resource_id: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(message, error_code, **kwargs) + self.resource_id = resource_id + + +class RegistrationError(DeploymentError, _LegacyRegistrationError): + _SDK_ERROR_NAME = "sdk.agentic_rl.RegistrationError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + resource_id: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__( + f"Registration failed: {message}", + error_code, + resource_id, + **kwargs, + ) + + +class DatasetsError(DeploymentError, _LegacyDatasetsError): + _SDK_ERROR_NAME = "sdk.agentic_rl.DatasetsError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + **kwargs: Any, + ) -> None: + super().__init__(f"Datasets failed: {message}", error_code, **kwargs) + + +class FunctionLoadError(DeploymentError, _LegacyFunctionLoadError): + _SDK_ERROR_NAME = "sdk.agentic_rl.FunctionLoadError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + entity_id: Optional[str] = None, + error_log: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__( + f"Function load failed: {message}", + error_code, + entity_id, + **kwargs, + ) + self.entity_id = entity_id + self.error_log = error_log + + +class FunctionLayerError(DeploymentError, _LegacyFunctionLayerError): + _SDK_ERROR_NAME = "sdk.agentic_rl.FunctionLayerError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + layer_name: Optional[str] = None, + error_log: Optional[str] = None, + layer_code: Optional[str] = None, + **kwargs: Any, + ) -> None: + # Raised for create, status-query and build-timeout failures alike, so + # the prefix names the subsystem rather than the create stage. The + # archived 1.x base keeps its original wording. + super().__init__( + f"Function layer failed: {message}", + error_code, + layer_code, + **kwargs, + ) + self.layer_name = layer_name + self.layer_code = layer_code + self.error_log = error_log + + +class InstanceWarmupError(DeploymentError, _LegacyInstanceWarmupError): + _SDK_ERROR_NAME = "sdk.agentic_rl.InstanceWarmupError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + instance_url: Optional[str] = None, + timeout: float = 0.0, + retry_after: Optional[float] = None, + **kwargs: Any, + ) -> None: + super().__init__( + f"Instance warmup failed: {message}", + error_code, + **kwargs, + ) + self.instance_url = instance_url + self.timeout = timeout + self.retry_after = retry_after + + +class InstanceQueryError(DeploymentError, _LegacyInstanceQueryError): + _SDK_ERROR_NAME = "sdk.agentic_rl.InstanceQueryError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + instance_id: Optional[str] = None, + query_attempts: int = 1, + **kwargs: Any, + ) -> None: + super().__init__( + f"Instance query failed: {message}", + error_code, + instance_id, + **kwargs, + ) + self.instance_id = instance_id + self.query_attempts = query_attempts + + +class ValidationError(AgenticRLError, _LegacyValidationError): + _SDK_ERROR_NAME = "sdk.agentic_rl.ValidationError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + invalid_data: Optional[Dict] = None, + validation_rules: Optional[Dict] = None, + **kwargs: Any, + ) -> None: + super().__init__(f"Validation failed: {message}", error_code, **kwargs) + self.invalid_data = invalid_data + self.validation_rules = validation_rules + + +class ConfigurationError(ValidationError, _LegacyConfigurationError): + _SDK_ERROR_NAME = "sdk.agentic_rl.ConfigurationError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + config_path: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(message, error_code=error_code, **kwargs) + self.config_path = config_path + + +class BasePermissionError(AgenticRLError, _LegacyBasePermissionError): + _SDK_ERROR_NAME = "sdk.agentic_rl.BasePermissionError" + + def __init__( + self, + message: str, + error_code: Optional[int] = None, + operation: Optional[str] = None, + resource: Optional[str] = None, + **kwargs: Any, + ) -> None: + super().__init__(f"Permission denied: {message}", error_code, **kwargs) + self.operation = operation + self.resource = resource diff --git a/dashscope/finetune/reinforcement/common/log.py b/dashscope/applications/finetune/reinforcement/common/log.py similarity index 92% rename from dashscope/finetune/reinforcement/common/log.py rename to dashscope/applications/finetune/reinforcement/common/log.py index 3db44a58..a461b46b 100644 --- a/dashscope/finetune/reinforcement/common/log.py +++ b/dashscope/applications/finetune/reinforcement/common/log.py @@ -2,7 +2,7 @@ import logging import sys -from dashscope.finetune.reinforcement.common.constants import ( +from .constants import ( LOGGER_NAME, LOG_LEVEL, ) diff --git a/dashscope/finetune/reinforcement/common/model.py b/dashscope/applications/finetune/reinforcement/common/model.py similarity index 93% rename from dashscope/finetune/reinforcement/common/model.py rename to dashscope/applications/finetune/reinforcement/common/model.py index d0eb5a25..ffdcff50 100644 --- a/dashscope/finetune/reinforcement/common/model.py +++ b/dashscope/applications/finetune/reinforcement/common/model.py @@ -23,7 +23,7 @@ import yaml # Local Application -from dashscope.finetune.reinforcement.common.constants import ( +from .constants import ( FC_API_KEY, FC_DELETE_API, FC_LOAD_API, @@ -38,9 +38,11 @@ FC_LAYER_USED, FC_LAYER_CREATE_API, FC_LAYER_QUERY_API, + FC_LAYER_STATUS_TIMEOUT, + FC_LAYER_STATUS_INTERVAL, LOG_LEVEL, ) -from dashscope.finetune.reinforcement.common.model_types import ( +from .model_types import ( FileSpec, FunctionType, DatasetsType, @@ -51,7 +53,7 @@ StatusType, TrainingType, ) -from dashscope.finetune.reinforcement.component.data import ( +from ..component.data import ( RewardInput, RewardOutput, RolloutInput, @@ -59,7 +61,7 @@ GroupRewardInput, GroupRewardOutput, ) -from dashscope.finetune.reinforcement.common.utils import ( +from .utils import ( check_file, client_fc, create_deployment_files, @@ -73,8 +75,8 @@ get_weights_from_file, deep_remove_none, ) -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.common.errors import ( +from .log import logger +from .errors import ( InputError, OutputError, OSSConnectionError, @@ -366,6 +368,7 @@ async def create_layer( ) -> str: """Retrieve OSS signed URL for deployment package.""" layer_code = None + layer_name = None try: # Validate requirements file if provided layer_name = name + "-" + generate_random_id()[:8] @@ -389,7 +392,7 @@ async def create_layer( ) if result.get("status", {}).get("code", 500) != 200: raise FunctionLayerError( - f"Function layer create failed: {result}", + f"create rejected by server | {result}", ) layer_code = result.get("output", {}).get("layer_code", "") @@ -398,10 +401,13 @@ async def create_layer( f"layer_code: {layer_code}", ) + except FunctionLayerError: + raise except Exception as e: raise FunctionLayerError( - "Function layer create failed", + f"create error | {e}", error_code=2013, + layer_name=layer_name, ) from e return layer_code @@ -454,10 +460,20 @@ async def to_oss( return async def get_layer(self, layer_code: str) -> str: - """Get FC layer status.""" - try: - i = 0 - while i < 3: + """Wait for an FC layer build to finish and return its final status. + + Raises FunctionLayerError when the layer fails to build, when the + status endpoint returns an unusable response, or when the build does + not finish within ``FC_LAYER_STATUS_TIMEOUT`` seconds. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + FC_LAYER_STATUS_TIMEOUT + attempt = 0 + status = "" + + while loop.time() < deadline: + attempt += 1 + try: result = await client_fc( FC_API_KEY, FC_LAYER_QUERY_API, @@ -466,36 +482,61 @@ async def get_layer(self, layer_code: str) -> str: }, "GET", ) - status = result.get("output", {}).get("status", "") - if status != "SUCCESS": - i += 1 - logger.debug( - f"Load function layer({i}) | layer_code:" - f" {layer_code} | Status: {status}", - ) - await asyncio.sleep(10) - else: - break - - logger.debug( - f"Load function layer | layer_code: {layer_code} | Status:" - f" {status}", - ) - if status != "SUCCESS": + except Exception as e: raise FunctionLayerError( - f"Function layer create failed: {status}", + f"status query error | layer_code: {layer_code} | {e}", error_code=2014, + layer_code=layer_code, + ) from e + + status = result.get("output", {}).get("status", "") + + if status == "SUCCESS": + logger.debug( + f"Load function layer | layer_code: {layer_code} | " + f"Status: {status}", + ) + return status + + if status == "FAILED": + raise FunctionLayerError( + f"build reported FAILED | layer_code: " + f"{layer_code} | {result}", + error_code=2015, + layer_code=layer_code, ) - return status + if not status: + raise FunctionLayerError( + f"status query returned no status | layer_code: " + f"{layer_code} | {result}", + error_code=2016, + layer_code=layer_code, + ) - except Exception as e: - logger.warning( - f"Load function layer failed | layer_code: {layer_code}, " - f"Error: {str(e)}", + if attempt == 1: + logger.info( + f"Waiting for function layer build | layer_code: " + f"{layer_code} | Status: {status} | Timeout: " + f"{FC_LAYER_STATUS_TIMEOUT}s", + ) + else: + logger.debug( + f"Load function layer({attempt}) | layer_code:" + f" {layer_code} | Status: {status}", + ) + + await asyncio.sleep( + min(FC_LAYER_STATUS_INTERVAL, deadline - loop.time()), ) - return "SUCCESS" + raise FunctionLayerError( + f"build timed out after {FC_LAYER_STATUS_TIMEOUT}s | " + f"layer_code: {layer_code} | Last status: {status} | Raise " + f"FC_LAYER_STATUS_TIMEOUT if the build needs longer", + error_code=2017, + layer_code=layer_code, + ) def clean_temp_files(self, tmp_path: str) -> None: """Cleanup temporary deployment files.""" @@ -770,9 +811,11 @@ async def load( warmup: bool = False, ) -> ResponseFC: """Load and initialize a registered function instance.""" + runtime_obj = runtime or self.runtime + target_entity_id = entity_id or self.entity_id + layer_code = runtime_obj.layer_code if runtime_obj else None + try: - # Resolve target registration ID - target_entity_id = entity_id or self.entity_id if not target_entity_id: raise ValueErrorWithCode( "No valid registration ID provided", @@ -780,17 +823,18 @@ async def load( ) if FC_LAYER_USED: - if self.runtime.layer_code is None: + if layer_code is None: raise ValueErrorWithCode( - "layer_code is required when FC_LAYER_USED is enabled", + "layer_code is required when FC_LAYER_USED is " + "enabled: pass a runtime carrying layer_code, or " + "set FC_LAYER_USED=false", error_code=2022, ) await self.fcmodel.get_layer( - layer_code=self.runtime.layer_code, + layer_code=layer_code, ) # Load function instance - runtime_obj = runtime or self.runtime runtime_dict = ( deep_remove_none({**runtime_obj.model_dump()}) if runtime_obj @@ -826,10 +870,30 @@ async def load( f"{result if LOG_LEVEL == 'DEBUG' else deep_mask(result)}", ) + except FunctionLayerError as e: + logger.warning( + f"Instance initialization blocked by function layer | " + f"EntityID: {target_entity_id}, LayerCode: {layer_code}, " + f"Error: {str(e)}", + ) + return ResponseFC( + status=Status( + task=StatusType.FAILED, + name="FunctionLayerError", + code=526, + message=( + f"Function layer not ready | EntityID: " + f"{target_entity_id}, LayerCode: {layer_code}, " + f"Error: {str(e)}" + ), + ), + output={}, + ) + except Exception as e: - logger.debug( + logger.warning( f"Instance initialization failed | EntityID:" - f" {target_entity_id}, " + f" {target_entity_id}, LayerCode: {layer_code}, " f"Error: {str(e)}", ) return ResponseFC( @@ -837,7 +901,11 @@ async def load( task=StatusType.FAILED, name="FunctionLoadError", code=522, - message=f"Instance initialization failed: {str(e)}", + message=( + f"Instance initialization failed | EntityID: " + f"{target_entity_id}, LayerCode: {layer_code}, " + f"Error: {str(e)}" + ), ), output={}, ) diff --git a/dashscope/finetune/reinforcement/common/model_types.py b/dashscope/applications/finetune/reinforcement/common/model_types.py similarity index 100% rename from dashscope/finetune/reinforcement/common/model_types.py rename to dashscope/applications/finetune/reinforcement/common/model_types.py diff --git a/dashscope/finetune/reinforcement/common/utils.py b/dashscope/applications/finetune/reinforcement/common/utils.py similarity index 99% rename from dashscope/finetune/reinforcement/common/utils.py rename to dashscope/applications/finetune/reinforcement/common/utils.py index a3d58a57..9eba40a6 100644 --- a/dashscope/finetune/reinforcement/common/utils.py +++ b/dashscope/applications/finetune/reinforcement/common/utils.py @@ -20,9 +20,9 @@ retry_if_exception_type, ) -from dashscope.common.utils import get_sdk_headers -from dashscope.finetune.reinforcement import logger -from dashscope.finetune.reinforcement.common.errors import ( +from dashscope._shared._sdk_ident import get_sdk_headers, get_user_agent +from .. import logger +from .errors import ( InputError, OutputError, ConfigurationError, @@ -30,7 +30,7 @@ RuntimeErrorWithCode, OSSUploadError, ) -from dashscope.finetune.reinforcement import ( +from .. import ( LOG_LEVEL, DASHSCOPE_API_KEY, BAILIAN_FILE_API, @@ -47,7 +47,7 @@ LOGGER_FILTER_FIELDS, FC_WORKERS_COUNT, ) -from dashscope.finetune.reinforcement.common.model_types import ( +from .model_types import ( FileSpec, FunctionType, ) @@ -175,8 +175,9 @@ async def client_fc( url=url, headers={ "Content-Type": content_type, - "Authorization": "Bearer " + api_key, + "user-agent": get_user_agent(), **get_sdk_headers(module="finetune"), + "Authorization": "Bearer " + api_key, }, data=request_data, timeout=HTTP_REQUEST_TIMEOUT, @@ -615,8 +616,9 @@ async def to_bailian_data(files: List[FileSpec]) -> List[str]: OutputError: If file upload fails """ headers = { - "Authorization": f"Bearer {DASHSCOPE_API_KEY}", + "user-agent": get_user_agent(), **get_sdk_headers(module="finetune"), + "Authorization": f"Bearer {DASHSCOPE_API_KEY}", } form_data = FormData() uploaded_files = [] diff --git a/dashscope/finetune/reinforcement/component/__init__.py b/dashscope/applications/finetune/reinforcement/component/__init__.py similarity index 74% rename from dashscope/finetune/reinforcement/component/__init__.py rename to dashscope/applications/finetune/reinforcement/component/__init__.py index 93d768ba..5abcee5f 100644 --- a/dashscope/finetune/reinforcement/component/__init__.py +++ b/dashscope/applications/finetune/reinforcement/component/__init__.py @@ -5,7 +5,7 @@ # -------------------------------------------------------------------------- # # Base Components # # -------------------------------------------------------------------------- # -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .data.base_data_model import ( BaseDataModel, TaskStatus, ModelProtocol, @@ -17,28 +17,28 @@ # -------------------------------------------------------------------------- # # Data Models # # -------------------------------------------------------------------------- # -from dashscope.finetune.reinforcement.component.data.reward_input import ( +from .data.reward_input import ( RewardInput, ) -from dashscope.finetune.reinforcement.component.data.reward_output import ( +from .data.reward_output import ( Reward, RewardOutput, ) -from dashscope.finetune.reinforcement.component.data.rollout_input import ( +from .data.rollout_input import ( RolloutInput, ) -from dashscope.finetune.reinforcement.component.data.rollout_output import ( +from .data.rollout_output import ( RolloutOutput, ) # -------------------------------------------------------------------------- # # Demos # # -------------------------------------------------------------------------- # -from dashscope.finetune.reinforcement.component.demo import ( +from .demo import ( DemoRewardProcessor, DemoRolloutProcessor, ) -from dashscope.finetune.reinforcement.component.func_decorator import ( +from .func_decorator import ( reward_func, sub_reward_func, aggregate_func, @@ -47,7 +47,7 @@ # -------------------------------------------------------------------------- # # Func Manager # # -------------------------------------------------------------------------- # -from dashscope.finetune.reinforcement.component.func_manager import ( +from .func_manager import ( FuncManager, ) @@ -55,30 +55,30 @@ # Observability # # -------------------------------------------------------------------------- # # pylint: disable=no-name-in-module -from dashscope.finetune.reinforcement.component.observability import ( +from .observability import ( observe_processor, ) -from dashscope.finetune.reinforcement.component.parser.base_parser import ( +from .parser.base_parser import ( BaseRequestParser, ) # -------------------------------------------------------------------------- # # Parsers # # -------------------------------------------------------------------------- # -from dashscope.finetune.reinforcement.component.parser.reward_parser import ( +from .parser.reward_parser import ( RewardRequestParser, ) -from dashscope.finetune.reinforcement.component.parser.rollout_parser import ( +from .parser.rollout_parser import ( RolloutRequestParser, ) # -------------------------------------------------------------------------- # # Processors # # -------------------------------------------------------------------------- # -from dashscope.finetune.reinforcement.component.processor import ( +from .processor import ( AbstractRewardProcessor, ) -from dashscope.finetune.reinforcement.component.processor import ( +from .processor import ( AbstractRolloutProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/data/__init__.py b/dashscope/applications/finetune/reinforcement/component/data/__init__.py similarity index 71% rename from dashscope/finetune/reinforcement/component/data/__init__.py rename to dashscope/applications/finetune/reinforcement/component/data/__init__.py index 6277aa74..b754af3c 100644 --- a/dashscope/finetune/reinforcement/component/data/__init__.py +++ b/dashscope/applications/finetune/reinforcement/component/data/__init__.py @@ -2,7 +2,7 @@ # ========================================================================== # # Base Components # # ========================================================================== # -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .base_data_model import ( BaseDataModel, TaskStatus, ModelProtocol, @@ -12,10 +12,10 @@ RequestMetadata, AgentOutput, ) -from dashscope.finetune.reinforcement.component.data.group_reward_input import ( # noqa: E501 +from .group_reward_input import ( # noqa: E501 GroupRewardInput, ) -from dashscope.finetune.reinforcement.component.data.group_reward_output import ( # noqa: E501 +from .group_reward_output import ( # noqa: E501 GroupRewardOutput, GroupReward, ) @@ -23,21 +23,21 @@ # ========================================================================== # # Input Models # # ========================================================================== # -from dashscope.finetune.reinforcement.component.data.reward_input import ( +from .reward_input import ( RewardInput, ) # ========================================================================== # # Output Models # # ========================================================================== # -from dashscope.finetune.reinforcement.component.data.reward_output import ( +from .reward_output import ( RewardOutput, Reward, ) -from dashscope.finetune.reinforcement.component.data.rollout_input import ( +from .rollout_input import ( RolloutInput, ) -from dashscope.finetune.reinforcement.component.data.rollout_output import ( +from .rollout_output import ( RolloutOutput, ) diff --git a/dashscope/finetune/reinforcement/component/data/base_data_model.py b/dashscope/applications/finetune/reinforcement/component/data/base_data_model.py similarity index 98% rename from dashscope/finetune/reinforcement/component/data/base_data_model.py rename to dashscope/applications/finetune/reinforcement/component/data/base_data_model.py index c1970c53..2e162f37 100644 --- a/dashscope/finetune/reinforcement/component/data/base_data_model.py +++ b/dashscope/applications/finetune/reinforcement/component/data/base_data_model.py @@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel, ConfigDict, Field, SecretStr -from dashscope.finetune.reinforcement.common.model_types import ( +from ...common.model_types import ( FunctionType as FuncType, ) diff --git a/dashscope/finetune/reinforcement/component/data/group_reward_input.py b/dashscope/applications/finetune/reinforcement/component/data/group_reward_input.py similarity index 90% rename from dashscope/finetune/reinforcement/component/data/group_reward_input.py rename to dashscope/applications/finetune/reinforcement/component/data/group_reward_input.py index acd897f6..65319ad2 100644 --- a/dashscope/finetune/reinforcement/component/data/group_reward_input.py +++ b/dashscope/applications/finetune/reinforcement/component/data/group_reward_input.py @@ -9,10 +9,10 @@ from typing import Any, List, Optional from pydantic import ConfigDict, Field -from dashscope.finetune.reinforcement.common.model_types import ( +from ...common.model_types import ( FunctionType as FuncType, ) -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .base_data_model import ( BaseDataModel, AgentOutput, RequestMetadata, diff --git a/dashscope/finetune/reinforcement/component/data/group_reward_output.py b/dashscope/applications/finetune/reinforcement/component/data/group_reward_output.py similarity index 90% rename from dashscope/finetune/reinforcement/component/data/group_reward_output.py rename to dashscope/applications/finetune/reinforcement/component/data/group_reward_output.py index 07208c84..f1375798 100644 --- a/dashscope/finetune/reinforcement/component/data/group_reward_output.py +++ b/dashscope/applications/finetune/reinforcement/component/data/group_reward_output.py @@ -8,10 +8,10 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .base_data_model import ( TaskStatus, ) -from dashscope.finetune.reinforcement.component.data.reward_output import ( +from .reward_output import ( Reward, ) diff --git a/dashscope/finetune/reinforcement/component/data/reward_input.py b/dashscope/applications/finetune/reinforcement/component/data/reward_input.py similarity index 90% rename from dashscope/finetune/reinforcement/component/data/reward_input.py rename to dashscope/applications/finetune/reinforcement/component/data/reward_input.py index f21bd71e..5b154e6f 100644 --- a/dashscope/finetune/reinforcement/component/data/reward_input.py +++ b/dashscope/applications/finetune/reinforcement/component/data/reward_input.py @@ -9,10 +9,10 @@ from typing import Any, Optional from pydantic import ConfigDict, Field -from dashscope.finetune.reinforcement.common.model_types import ( +from ...common.model_types import ( FunctionType as FuncType, ) -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .base_data_model import ( BaseDataModel, AgentOutput, RequestMetadata, diff --git a/dashscope/finetune/reinforcement/component/data/reward_output.py b/dashscope/applications/finetune/reinforcement/component/data/reward_output.py similarity index 96% rename from dashscope/finetune/reinforcement/component/data/reward_output.py rename to dashscope/applications/finetune/reinforcement/component/data/reward_output.py index 93c3061c..fc88122c 100644 --- a/dashscope/finetune/reinforcement/component/data/reward_output.py +++ b/dashscope/applications/finetune/reinforcement/component/data/reward_output.py @@ -8,7 +8,7 @@ from typing import Any, Dict, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .base_data_model import ( TaskStatus, _normalize_error_code, ) diff --git a/dashscope/finetune/reinforcement/component/data/rollout_input.py b/dashscope/applications/finetune/reinforcement/component/data/rollout_input.py similarity index 93% rename from dashscope/finetune/reinforcement/component/data/rollout_input.py rename to dashscope/applications/finetune/reinforcement/component/data/rollout_input.py index ea7bfa1a..53a5865a 100644 --- a/dashscope/finetune/reinforcement/component/data/rollout_input.py +++ b/dashscope/applications/finetune/reinforcement/component/data/rollout_input.py @@ -9,10 +9,10 @@ from typing import Any, Dict, List, Optional from pydantic import ConfigDict, Field -from dashscope.finetune.reinforcement.common.model_types import ( +from ...common.model_types import ( FunctionType as FuncType, ) -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .base_data_model import ( BaseDataModel, ModelResource, RequestMetadata, diff --git a/dashscope/finetune/reinforcement/component/data/rollout_output.py b/dashscope/applications/finetune/reinforcement/component/data/rollout_output.py similarity index 95% rename from dashscope/finetune/reinforcement/component/data/rollout_output.py rename to dashscope/applications/finetune/reinforcement/component/data/rollout_output.py index 1b923558..92515f80 100644 --- a/dashscope/finetune/reinforcement/component/data/rollout_output.py +++ b/dashscope/applications/finetune/reinforcement/component/data/rollout_output.py @@ -9,7 +9,7 @@ from typing import Any, Optional from pydantic import BaseModel, ConfigDict, Field, field_validator -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .base_data_model import ( TaskStatus, AgentOutput, _normalize_error_code, diff --git a/dashscope/applications/finetune/reinforcement/component/demo/__init__.py b/dashscope/applications/finetune/reinforcement/component/demo/__init__.py new file mode 100644 index 00000000..ddf0b871 --- /dev/null +++ b/dashscope/applications/finetune/reinforcement/component/demo/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +from .group_reward_processor_demo import DemoGroupRewardProcessor +from .reward_processor_demo import DemoRewardProcessor +from .rollout_processor_demo import DemoRolloutProcessor + +__all__ = [ + "DemoRewardProcessor", + # Inherits AbstractRewardProcessor, returns RewardOutput + "DemoRolloutProcessor", + # Inherits AbstractRolloutProcessor, returns RolloutOutput + "DemoGroupRewardProcessor", + # Inherits AbstractGroupRewardProcessor, returns GroupRewardOutput +] diff --git a/dashscope/finetune/reinforcement/component/demo/group_reward_processor_demo.py b/dashscope/applications/finetune/reinforcement/component/demo/group_reward_processor_demo.py similarity index 85% rename from dashscope/finetune/reinforcement/component/demo/group_reward_processor_demo.py rename to dashscope/applications/finetune/reinforcement/component/demo/group_reward_processor_demo.py index a43688d6..198f5322 100644 --- a/dashscope/finetune/reinforcement/component/demo/group_reward_processor_demo.py +++ b/dashscope/applications/finetune/reinforcement/component/demo/group_reward_processor_demo.py @@ -6,21 +6,21 @@ Demonstrates rule-based scoring for multiple Agent outputs in a group. """ -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from ...common.log import logger +from ..data.base_data_model import ( TaskStatus, ) -from dashscope.finetune.reinforcement.component.data.group_reward_input import ( # noqa: E501 +from ..data.group_reward_input import ( # noqa: E501 GroupRewardInput, ) -from dashscope.finetune.reinforcement.component.data.group_reward_output import ( # noqa: E501 +from ..data.group_reward_output import ( # noqa: E501 GroupRewardOutput, GroupReward, ) -from dashscope.finetune.reinforcement.component.data.reward_output import ( +from ..data.reward_output import ( Reward, ) -from dashscope.finetune.reinforcement.component.processor.abstract_group_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long +from ..processor.abstract_group_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long AbstractGroupRewardProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/demo/reward_processor_demo.py b/dashscope/applications/finetune/reinforcement/component/demo/reward_processor_demo.py similarity index 86% rename from dashscope/finetune/reinforcement/component/demo/reward_processor_demo.py rename to dashscope/applications/finetune/reinforcement/component/demo/reward_processor_demo.py index d99ad89a..8b14a8e0 100644 --- a/dashscope/finetune/reinforcement/component/demo/reward_processor_demo.py +++ b/dashscope/applications/finetune/reinforcement/component/demo/reward_processor_demo.py @@ -6,18 +6,18 @@ Demonstrates rule-based scoring for Agent outputs. """ -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from ...common.log import logger +from ..data.base_data_model import ( TaskStatus, ) -from dashscope.finetune.reinforcement.component.data.reward_input import ( +from ..data.reward_input import ( RewardInput, ) -from dashscope.finetune.reinforcement.component.data.reward_output import ( +from ..data.reward_output import ( RewardOutput, Reward, ) -from dashscope.finetune.reinforcement.component.processor.abstract_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long +from ..processor.abstract_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long AbstractRewardProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/demo/rollout_processor_demo.py b/dashscope/applications/finetune/reinforcement/component/demo/rollout_processor_demo.py similarity index 86% rename from dashscope/finetune/reinforcement/component/demo/rollout_processor_demo.py rename to dashscope/applications/finetune/reinforcement/component/demo/rollout_processor_demo.py index 8099cc18..9a810a5a 100644 --- a/dashscope/finetune/reinforcement/component/demo/rollout_processor_demo.py +++ b/dashscope/applications/finetune/reinforcement/component/demo/rollout_processor_demo.py @@ -8,18 +8,18 @@ import time -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from ...common.log import logger +from ..data.base_data_model import ( AgentOutput, TaskStatus, ) -from dashscope.finetune.reinforcement.component.data.rollout_input import ( +from ..data.rollout_input import ( RolloutInput, ) -from dashscope.finetune.reinforcement.component.data.rollout_output import ( +from ..data.rollout_output import ( RolloutOutput, ) -from dashscope.finetune.reinforcement.component.processor.abstract_rollout_processor import ( # noqa: E501 # pylint: disable=line-too-long +from ..processor.abstract_rollout_processor import ( # noqa: E501 # pylint: disable=line-too-long AbstractRolloutProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/func_decorator.py b/dashscope/applications/finetune/reinforcement/component/func_decorator.py similarity index 98% rename from dashscope/finetune/reinforcement/component/func_decorator.py rename to dashscope/applications/finetune/reinforcement/component/func_decorator.py index e10d9464..010909f7 100644 --- a/dashscope/finetune/reinforcement/component/func_decorator.py +++ b/dashscope/applications/finetune/reinforcement/component/func_decorator.py @@ -4,8 +4,8 @@ from functools import wraps from typing import Callable, Dict, Optional, Type -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data import ( +from ..common.log import logger +from .data import ( RewardInput, RewardOutput, Reward, diff --git a/dashscope/finetune/reinforcement/component/func_manager.py b/dashscope/applications/finetune/reinforcement/component/func_manager.py similarity index 94% rename from dashscope/finetune/reinforcement/component/func_manager.py rename to dashscope/applications/finetune/reinforcement/component/func_manager.py index a1ee558c..1d7c3f49 100644 --- a/dashscope/finetune/reinforcement/component/func_manager.py +++ b/dashscope/applications/finetune/reinforcement/component/func_manager.py @@ -20,31 +20,31 @@ from functools import partial from typing import Any, Dict, Optional, Type -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.common.model_types import ( +from ..common.log import logger +from ..common.model_types import ( FunctionType as FuncType, ) -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from .data.base_data_model import ( BaseDataModel, ) -from dashscope.finetune.reinforcement.component.observability.tracing import ( +from .observability.tracing import ( is_tracing_enabled, trace_processor_process, async_trace_processor_process, ) -from dashscope.finetune.reinforcement.component.parser import ( +from .parser import ( BaseRequestParser, ) -from dashscope.finetune.reinforcement.component.parser import ( +from .parser import ( GroupRewardRequestParser, ) -from dashscope.finetune.reinforcement.component.parser import ( +from .parser import ( RewardRequestParser, ) -from dashscope.finetune.reinforcement.component.parser import ( +from .parser import ( RolloutRequestParser, ) -from dashscope.finetune.reinforcement.component.processor.abstract_processor import ( # noqa: E501 +from .processor.abstract_processor import ( # noqa: E501 # pylint: disable=line-too-long AbstractProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/observability/__init__.py b/dashscope/applications/finetune/reinforcement/component/observability/__init__.py similarity index 92% rename from dashscope/finetune/reinforcement/component/observability/__init__.py rename to dashscope/applications/finetune/reinforcement/component/observability/__init__.py index 96a00c34..b836df88 100644 --- a/dashscope/finetune/reinforcement/component/observability/__init__.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/__init__.py @@ -4,7 +4,7 @@ This module provides a *stable public surface* while keeping heavy observability dependencies lazily imported. User-facing imports such as:: - from dashscope.finetune.reinforcement.component.observability import ( + from . import ( observe_processor, trace_client, trace_tool, @@ -81,7 +81,7 @@ def __getattr__(name: str) -> Any: "trace_client", "trace_tool", }: - from dashscope.finetune.reinforcement.component.observability import ( + from . import ( genai as _genai, ) @@ -89,7 +89,7 @@ def __getattr__(name: str) -> Any: # Processor-span helpers if name in {"observe_processor", "trace_processor_span"}: - from dashscope.finetune.reinforcement.component.observability import ( + from . import ( processor_span as _processor_span, ) @@ -113,7 +113,7 @@ def __getattr__(name: str) -> Any: "span_payload_preview", "trace_processor_process", }: - from dashscope.finetune.reinforcement.component.observability import ( + from . import ( tracing as _tracing, ) diff --git a/dashscope/finetune/reinforcement/component/observability/genai/__init__.py b/dashscope/applications/finetune/reinforcement/component/observability/genai/__init__.py similarity index 87% rename from dashscope/finetune/reinforcement/component/observability/genai/__init__.py rename to dashscope/applications/finetune/reinforcement/component/observability/genai/__init__.py index 2beb244e..887dd769 100644 --- a/dashscope/finetune/reinforcement/component/observability/genai/__init__.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/genai/__init__.py @@ -25,23 +25,23 @@ from typing import Any -from dashscope.finetune.reinforcement.component.observability.genai.llm_dashscope import ( # noqa: E501 # pylint: disable=line-too-long +from ..genai.llm_dashscope import ( # noqa: E501 # pylint: disable=line-too-long instrument_dashscope_generation_call, ) -from dashscope.finetune.reinforcement.component.observability.genai.llm_openai import ( # noqa: E501 # pylint: disable=line-too-long +from ..genai.llm_openai import ( # noqa: E501 # pylint: disable=line-too-long instrument_openai_chat_completions, ) -from dashscope.finetune.reinforcement.component.observability.genai.llm_span import ( # noqa: E501 # pylint: disable=line-too-long +from ..genai.llm_span import ( # noqa: E501 # pylint: disable=line-too-long genai_llm_span, observe_llm, run_with_genai_llm_span, ) -from dashscope.finetune.reinforcement.component.observability.genai.messages import ( # noqa: E501 # pylint: disable=line-too-long +from ..genai.messages import ( # noqa: E501 # pylint: disable=line-too-long dashscope_response_to_output_messages, openai_chat_messages_to_input_messages, openai_completion_to_output_messages, ) -from dashscope.finetune.reinforcement.component.observability.genai.tools import ( # noqa: E501 # pylint: disable=line-too-long +from ..genai.tools import ( # noqa: E501 # pylint: disable=line-too-long observe_tool, trace_tool, ) diff --git a/dashscope/finetune/reinforcement/component/observability/genai/_core.py b/dashscope/applications/finetune/reinforcement/component/observability/genai/_core.py similarity index 100% rename from dashscope/finetune/reinforcement/component/observability/genai/_core.py rename to dashscope/applications/finetune/reinforcement/component/observability/genai/_core.py diff --git a/dashscope/finetune/reinforcement/component/observability/genai/llm_dashscope.py b/dashscope/applications/finetune/reinforcement/component/observability/genai/llm_dashscope.py similarity index 94% rename from dashscope/finetune/reinforcement/component/observability/genai/llm_dashscope.py rename to dashscope/applications/finetune/reinforcement/component/observability/genai/llm_dashscope.py index 2676e132..ee5f8c4b 100644 --- a/dashscope/finetune/reinforcement/component/observability/genai/llm_dashscope.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/genai/llm_dashscope.py @@ -7,16 +7,16 @@ from opentelemetry.trace.status import Status, StatusCode -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.observability.genai._core import ( # noqa: E501 +from ....common.log import logger +from ..genai._core import ( # noqa: E501 GENAI_AVAILABLE, get_handler, ) -from dashscope.finetune.reinforcement.component.observability.genai.messages import ( # noqa: E501 +from ..genai.messages import ( # noqa: E501 # pylint: disable=line-too-long dashscope_response_to_output_messages, openai_chat_messages_to_input_messages, ) -from dashscope.finetune.reinforcement.component.observability.tracing import ( # noqa: E501 +from ..tracing import ( # noqa: E501 is_tracing_enabled, log_trace_id, ) diff --git a/dashscope/finetune/reinforcement/component/observability/genai/llm_openai.py b/dashscope/applications/finetune/reinforcement/component/observability/genai/llm_openai.py similarity index 98% rename from dashscope/finetune/reinforcement/component/observability/genai/llm_openai.py rename to dashscope/applications/finetune/reinforcement/component/observability/genai/llm_openai.py index 3814c38f..d0659724 100644 --- a/dashscope/finetune/reinforcement/component/observability/genai/llm_openai.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/genai/llm_openai.py @@ -16,17 +16,17 @@ from opentelemetry import trace as otel_trace from opentelemetry.trace.status import Status, StatusCode -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.observability.genai._core import ( # noqa: E501 +from ....common.log import logger +from ..genai._core import ( # noqa: E501 GENAI_AVAILABLE, get_handler, ) -from dashscope.finetune.reinforcement.component.observability.genai.messages import ( # noqa: E501 +from ..genai.messages import ( # noqa: E501 # pylint: disable=line-too-long openai_chat_messages_to_input_messages, openai_completion_to_output_messages, unwrap_openai_completion, ) -from dashscope.finetune.reinforcement.component.observability.tracing import ( +from ..tracing import ( is_tracing_enabled, log_trace_id, ) diff --git a/dashscope/finetune/reinforcement/component/observability/genai/llm_span.py b/dashscope/applications/finetune/reinforcement/component/observability/genai/llm_span.py similarity index 96% rename from dashscope/finetune/reinforcement/component/observability/genai/llm_span.py rename to dashscope/applications/finetune/reinforcement/component/observability/genai/llm_span.py index 62e435ce..061dedef 100644 --- a/dashscope/finetune/reinforcement/component/observability/genai/llm_span.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/genai/llm_span.py @@ -12,16 +12,16 @@ from opentelemetry.trace.status import Status, StatusCode -from dashscope.finetune.reinforcement.component.observability.genai._core import ( # noqa: E501 +from ..genai._core import ( # noqa: E501 GENAI_AVAILABLE, get_handler, ) -from dashscope.finetune.reinforcement.component.observability.genai.messages import ( # noqa: E501 +from ..genai.messages import ( # noqa: E501 # pylint: disable=line-too-long dashscope_response_to_output_messages, openai_chat_messages_to_input_messages, openai_completion_to_output_messages, ) -from dashscope.finetune.reinforcement.component.observability.tracing import ( +from ..tracing import ( is_tracing_enabled, ) diff --git a/dashscope/finetune/reinforcement/component/observability/genai/messages.py b/dashscope/applications/finetune/reinforcement/component/observability/genai/messages.py similarity index 99% rename from dashscope/finetune/reinforcement/component/observability/genai/messages.py rename to dashscope/applications/finetune/reinforcement/component/observability/genai/messages.py index ab09826a..f1f08c9a 100644 --- a/dashscope/finetune/reinforcement/component/observability/genai/messages.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/genai/messages.py @@ -27,9 +27,13 @@ import os from typing import Any, Dict, List, Optional -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.common.utils import deep_mask -from dashscope.finetune.reinforcement.component.observability import genai +from ....common.log import logger +from ....common.utils import ( + deep_mask, +) +from .. import ( + genai, +) # pylint: disable=protected-access _core = genai._core diff --git a/dashscope/finetune/reinforcement/component/observability/genai/tools.py b/dashscope/applications/finetune/reinforcement/component/observability/genai/tools.py similarity index 99% rename from dashscope/finetune/reinforcement/component/observability/genai/tools.py rename to dashscope/applications/finetune/reinforcement/component/observability/genai/tools.py index bdf20b9a..b74cec51 100644 --- a/dashscope/finetune/reinforcement/component/observability/genai/tools.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/genai/tools.py @@ -50,7 +50,7 @@ from opentelemetry.trace.status import Status, StatusCode -from dashscope.finetune.reinforcement.component.observability import ( +from .. import ( genai, tracing, ) diff --git a/dashscope/finetune/reinforcement/component/observability/processor_span.py b/dashscope/applications/finetune/reinforcement/component/observability/processor_span.py similarity index 97% rename from dashscope/finetune/reinforcement/component/observability/processor_span.py rename to dashscope/applications/finetune/reinforcement/component/observability/processor_span.py index 65fc3824..a8c69f04 100644 --- a/dashscope/finetune/reinforcement/component/observability/processor_span.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/processor_span.py @@ -34,11 +34,11 @@ from functools import wraps from typing import Any, Callable, Dict, Optional, TypeVar, cast -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from ...common.log import logger +from ..data.base_data_model import ( BaseDataModel, ) -from dashscope.finetune.reinforcement.component.observability.tracing import ( +from ..observability.tracing import ( _REWARD_SPAN_PREFIX, _ROLLOUT_SPAN_PREFIX, apply_processor_span_attributes_after, @@ -233,7 +233,7 @@ def observe_processor( Replaces the explicit ``@trace_processor_span(logical_kind=...)`` style. """ - from dashscope.finetune.reinforcement.component.processor import ( + from ..processor import ( AbstractRewardProcessor, AbstractRolloutProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/observability/tracing.py b/dashscope/applications/finetune/reinforcement/component/observability/tracing.py similarity index 98% rename from dashscope/finetune/reinforcement/component/observability/tracing.py rename to dashscope/applications/finetune/reinforcement/component/observability/tracing.py index 1da284b7..36d88784 100644 --- a/dashscope/finetune/reinforcement/component/observability/tracing.py +++ b/dashscope/applications/finetune/reinforcement/component/observability/tracing.py @@ -36,11 +36,11 @@ from enum import Enum from typing import Any, Dict, Iterator, Optional, Tuple -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.common.model_types import ( +from ...common.log import logger +from ...common.model_types import ( FunctionType as FuncType, ) -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from ..data.base_data_model import ( BaseDataModel, ) @@ -64,7 +64,10 @@ _DEFAULT_FORCE_FLUSH_TIMEOUT_MS = 3000 _MAX_FORCE_FLUSH_TIMEOUT_MS = 60_000 -_TRACER_NAME = "dashscope.finetune.reinforcement" +# OpenTelemetry instrumentation scope name. Renaming it would split +# already-exported traces across two scopes, so it keeps the published +# name even though the package now lives under ``dashscope``. +_TRACER_NAME = "dashscope.applications.finetune.reinforcement" # I/O preview limits for span attributes. _IO_MAX_LEN = 128 * 1024 # 128KB per input/output preview # Per-string limit applied during JSON-friendly conversion (before diff --git a/dashscope/applications/finetune/reinforcement/component/parser/__init__.py b/dashscope/applications/finetune/reinforcement/component/parser/__init__.py new file mode 100644 index 00000000..87a1ba44 --- /dev/null +++ b/dashscope/applications/finetune/reinforcement/component/parser/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from ..parser.base_parser import ( + BaseRequestParser, +) +from ..parser.group_reward_parser import ( # noqa: E501 + GroupRewardRequestParser, +) +from ..parser.reward_parser import ( + RewardRequestParser, +) +from ..parser.rollout_parser import ( + RolloutRequestParser, +) + +__all__ = [ + "BaseRequestParser", + "RewardRequestParser", + "RolloutRequestParser", + "GroupRewardRequestParser", +] diff --git a/dashscope/finetune/reinforcement/component/parser/base_parser.py b/dashscope/applications/finetune/reinforcement/component/parser/base_parser.py similarity index 91% rename from dashscope/finetune/reinforcement/component/parser/base_parser.py rename to dashscope/applications/finetune/reinforcement/component/parser/base_parser.py index fac27693..837eb15c 100644 --- a/dashscope/finetune/reinforcement/component/parser/base_parser.py +++ b/dashscope/applications/finetune/reinforcement/component/parser/base_parser.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from typing import Any, Dict -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from ..data.base_data_model import ( BaseDataModel, ) diff --git a/dashscope/finetune/reinforcement/component/parser/group_reward_parser.py b/dashscope/applications/finetune/reinforcement/component/parser/group_reward_parser.py similarity index 82% rename from dashscope/finetune/reinforcement/component/parser/group_reward_parser.py rename to dashscope/applications/finetune/reinforcement/component/parser/group_reward_parser.py index aceb78d7..f137e372 100644 --- a/dashscope/finetune/reinforcement/component/parser/group_reward_parser.py +++ b/dashscope/applications/finetune/reinforcement/component/parser/group_reward_parser.py @@ -7,11 +7,11 @@ from typing import Any, Dict -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data import ( +from ...common.log import logger +from ..data import ( GroupRewardInput, ) -from dashscope.finetune.reinforcement.component.parser.base_parser import ( +from .base_parser import ( BaseRequestParser, ) diff --git a/dashscope/finetune/reinforcement/component/parser/reward_parser.py b/dashscope/applications/finetune/reinforcement/component/parser/reward_parser.py similarity index 80% rename from dashscope/finetune/reinforcement/component/parser/reward_parser.py rename to dashscope/applications/finetune/reinforcement/component/parser/reward_parser.py index 0dff8195..c4935d3e 100644 --- a/dashscope/finetune/reinforcement/component/parser/reward_parser.py +++ b/dashscope/applications/finetune/reinforcement/component/parser/reward_parser.py @@ -7,11 +7,11 @@ from typing import Any, Dict -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data.reward_input import ( +from ...common.log import logger +from ..data.reward_input import ( RewardInput, ) -from dashscope.finetune.reinforcement.component.parser.base_parser import ( +from .base_parser import ( BaseRequestParser, ) diff --git a/dashscope/finetune/reinforcement/component/parser/rollout_parser.py b/dashscope/applications/finetune/reinforcement/component/parser/rollout_parser.py similarity index 80% rename from dashscope/finetune/reinforcement/component/parser/rollout_parser.py rename to dashscope/applications/finetune/reinforcement/component/parser/rollout_parser.py index 0aaed03b..32682a89 100644 --- a/dashscope/finetune/reinforcement/component/parser/rollout_parser.py +++ b/dashscope/applications/finetune/reinforcement/component/parser/rollout_parser.py @@ -7,11 +7,11 @@ from typing import Any, Dict -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.component.data.rollout_input import ( +from ...common.log import logger +from ..data.rollout_input import ( RolloutInput, ) -from dashscope.finetune.reinforcement.component.parser.base_parser import ( +from .base_parser import ( BaseRequestParser, ) diff --git a/dashscope/applications/finetune/reinforcement/component/processor/__init__.py b/dashscope/applications/finetune/reinforcement/component/processor/__init__.py new file mode 100644 index 00000000..47f50056 --- /dev/null +++ b/dashscope/applications/finetune/reinforcement/component/processor/__init__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from ..processor.abstract_group_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long + AbstractGroupRewardProcessor, +) +from ..processor.abstract_processor import ( # noqa: E501 # pylint: disable=line-too-long + AbstractProcessor, +) +from ..processor.abstract_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long + AbstractRewardProcessor, +) +from ..processor.abstract_rollout_processor import ( # noqa: E501 # pylint: disable=line-too-long + AbstractRolloutProcessor, +) + +__all__ = [ + "AbstractProcessor", + "AbstractRewardProcessor", + "AbstractRolloutProcessor", + "AbstractGroupRewardProcessor", +] diff --git a/dashscope/finetune/reinforcement/component/processor/abstract_group_reward_processor.py b/dashscope/applications/finetune/reinforcement/component/processor/abstract_group_reward_processor.py similarity index 91% rename from dashscope/finetune/reinforcement/component/processor/abstract_group_reward_processor.py rename to dashscope/applications/finetune/reinforcement/component/processor/abstract_group_reward_processor.py index 5a8f60a2..42b3601e 100644 --- a/dashscope/finetune/reinforcement/component/processor/abstract_group_reward_processor.py +++ b/dashscope/applications/finetune/reinforcement/component/processor/abstract_group_reward_processor.py @@ -9,13 +9,13 @@ from abc import abstractmethod -from dashscope.finetune.reinforcement.component.data import ( +from ..data import ( GroupRewardInput, ) -from dashscope.finetune.reinforcement.component.data import ( +from ..data import ( GroupRewardOutput, ) -from dashscope.finetune.reinforcement.component.processor.abstract_processor import ( # noqa: E501 +from .abstract_processor import ( # noqa: E501 # pylint: disable=line-too-long AbstractProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/processor/abstract_processor.py b/dashscope/applications/finetune/reinforcement/component/processor/abstract_processor.py similarity index 94% rename from dashscope/finetune/reinforcement/component/processor/abstract_processor.py rename to dashscope/applications/finetune/reinforcement/component/processor/abstract_processor.py index 8f13afd3..ec716143 100644 --- a/dashscope/finetune/reinforcement/component/processor/abstract_processor.py +++ b/dashscope/applications/finetune/reinforcement/component/processor/abstract_processor.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from typing import Any -from dashscope.finetune.reinforcement.component.data.base_data_model import ( +from ..data.base_data_model import ( BaseDataModel, ) diff --git a/dashscope/finetune/reinforcement/component/processor/abstract_reward_processor.py b/dashscope/applications/finetune/reinforcement/component/processor/abstract_reward_processor.py similarity index 95% rename from dashscope/finetune/reinforcement/component/processor/abstract_reward_processor.py rename to dashscope/applications/finetune/reinforcement/component/processor/abstract_reward_processor.py index 0b64262f..8cfdc7fd 100644 --- a/dashscope/finetune/reinforcement/component/processor/abstract_reward_processor.py +++ b/dashscope/applications/finetune/reinforcement/component/processor/abstract_reward_processor.py @@ -14,18 +14,18 @@ from concurrent.futures import ThreadPoolExecutor, Executor from typing import Optional, Dict -from dashscope.finetune.reinforcement.component.data.reward_input import ( +from ..data.reward_input import ( RewardInput, ) -from dashscope.finetune.reinforcement.component.data.reward_output import ( +from ..data.reward_output import ( RewardOutput, ) -from dashscope.finetune.reinforcement.component.func_decorator import ( +from ..func_decorator import ( RewardProcessorMeta, SubRewardFunction, AggregateFunction, ) -from dashscope.finetune.reinforcement.component.processor import ( +from . import ( AbstractProcessor, ) diff --git a/dashscope/finetune/reinforcement/component/processor/abstract_rollout_processor.py b/dashscope/applications/finetune/reinforcement/component/processor/abstract_rollout_processor.py similarity index 89% rename from dashscope/finetune/reinforcement/component/processor/abstract_rollout_processor.py rename to dashscope/applications/finetune/reinforcement/component/processor/abstract_rollout_processor.py index 9c6dace9..2696f4d3 100644 --- a/dashscope/finetune/reinforcement/component/processor/abstract_rollout_processor.py +++ b/dashscope/applications/finetune/reinforcement/component/processor/abstract_rollout_processor.py @@ -9,13 +9,13 @@ from abc import abstractmethod -from dashscope.finetune.reinforcement.component.data.rollout_input import ( +from ..data.rollout_input import ( RolloutInput, ) -from dashscope.finetune.reinforcement.component.data.rollout_output import ( +from ..data.rollout_output import ( RolloutOutput, ) -from dashscope.finetune.reinforcement.component.processor.abstract_processor import ( # noqa: E501 # pylint: disable=line-too-long +from ..processor.abstract_processor import ( # noqa: E501 # pylint: disable=line-too-long AbstractProcessor, ) diff --git a/dashscope/common/__init__.py b/dashscope/applications/finetune/reinforcement/component/server/__init__.py similarity index 100% rename from dashscope/common/__init__.py rename to dashscope/applications/finetune/reinforcement/component/server/__init__.py diff --git a/dashscope/finetune/reinforcement/component/server/server.py b/dashscope/applications/finetune/reinforcement/component/server/server.py similarity index 94% rename from dashscope/finetune/reinforcement/component/server/server.py rename to dashscope/applications/finetune/reinforcement/component/server/server.py index ce2d400d..56945cd6 100644 --- a/dashscope/finetune/reinforcement/component/server/server.py +++ b/dashscope/applications/finetune/reinforcement/component/server/server.py @@ -10,7 +10,7 @@ FUNC_TYPE Function type, current supported values: "reward"/"rollout" (required) PROCESSOR_CLASS Full Python path to processor class, e.g.: -dashscope.agenticRL.component.demo.reward_processor_demo.DemoRewardProcessor +dashscope.finetune.reinforcement.component.demo.reward_processor_demo.DemoRewardProcessor (required, processor instantiated using this class) SERVER_PORT Server port, default 8000 ENABLE_LOGGING Enable verbose logging, "true"/"1" to enable, @@ -22,14 +22,15 @@ == Startup Methods == # Recommended: Run with python -m - FUNC_TYPE=reward PROCESSOR_CLASS=dashscope.agenticRL.component.demo. + FUNC_TYPE=reward PROCESSOR_CLASS=dashscope.finetune.reinforcement.component.demo. reward_processor_demo.DemoRewardProcessor python -m - dashscope.agenticRL.server.server + dashscope.finetune.reinforcement.component.server.server # Using uvicorn - FUNC_TYPE=reward PROCESSOR_CLASS=dashscope.agenticRL.component.demo. + FUNC_TYPE=reward PROCESSOR_CLASS=dashscope.finetune.reinforcement.component.demo. reward_processor_demo.DemoRewardProcessor uvicorn - dashscope.agenticRL.server.server:app --host 0.0.0.0 --port 8000 + dashscope.finetune.reinforcement.component.server.server:app \ + --host 0.0.0.0 --port 8000 == Endpoints == @@ -47,17 +48,21 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse -from dashscope.finetune.reinforcement.common.log import logger -from dashscope.finetune.reinforcement.common.utils import ( +from ...common.log import logger +from ...common.utils import ( get_fc_request_id, get_business_summary, ) -from dashscope.finetune.reinforcement.common.model_types import ( +from ...common.model_types import ( FunctionType as FuncType, ) -from dashscope.finetune.reinforcement.component import BaseDataModel -from dashscope.finetune.reinforcement.component.func_manager import FuncManager -from dashscope.finetune.reinforcement.component.observability.tracing import ( +from .. import ( + BaseDataModel, +) +from ..func_manager import ( + FuncManager, +) +from ..observability.tracing import ( ensure_agentic_rl_baggage_span_processor, is_tracing_enabled, maybe_force_flush_async, @@ -118,7 +123,7 @@ def _resolve_func_type(raw: str) -> FuncType: if not _PROCESSOR_CLASS_ENV: raise RuntimeError( "Environment variable PROCESSOR_CLASS is required. " - "Example: PROCESSOR_CLASS=dashscope.agenticRL.component.demo." + "Example: PROCESSOR_CLASS=dashscope.finetune.reinforcement.component.demo." "reward_processor_demo.DemoRewardProcessor", ) @@ -588,7 +593,8 @@ async def health_check_endpoint() -> JSONResponse: if __name__ == "__main__": # Direct execution entry point for FastAPI server. # Alternative startup using uvicorn: - # uvicorn dashscope.agenticRL.server.server:app --host 0.0.0.0 --port 8000 + # uvicorn dashscope.finetune.reinforcement.component.server.server:app \ + # --host 0.0.0.0 --port 8000 import uvicorn import sys import argparse @@ -622,7 +628,9 @@ async def health_check_endpoint() -> JSONResponse: # Start uvicorn with calculated worker count uvicorn.run( - # app + # uvicorn resolves this import string inside its own worker + # process, so it must stay the public path used by + # examples/workspace/scripts/start_local_*.sh's ``python3 -m``. "dashscope.finetune.reinforcement.component.server.server:app", host="0.0.0.0", port=_SERVER_PORT, diff --git a/dashscope/finetune/__init__.py b/dashscope/applications/finetune/reinforcement/examples/observability/__init__.py similarity index 100% rename from dashscope/finetune/__init__.py rename to dashscope/applications/finetune/reinforcement/examples/observability/__init__.py diff --git a/dashscope/finetune/reinforcement/examples/observability/smoke_observability.py b/dashscope/applications/finetune/reinforcement/examples/observability/smoke_observability.py similarity index 99% rename from dashscope/finetune/reinforcement/examples/observability/smoke_observability.py rename to dashscope/applications/finetune/reinforcement/examples/observability/smoke_observability.py index d34cc349..e54d1c16 100644 --- a/dashscope/finetune/reinforcement/examples/observability/smoke_observability.py +++ b/dashscope/applications/finetune/reinforcement/examples/observability/smoke_observability.py @@ -64,7 +64,9 @@ from dashscope.finetune.reinforcement.component.data.rollout_input import ( RolloutInput, ) -from dashscope.finetune.reinforcement.component.func_manager import FuncManager +from dashscope.finetune.reinforcement.component.func_manager import ( + FuncManager, +) from dashscope.finetune.reinforcement.component.observability.genai import ( observe_llm, observe_tool, diff --git a/dashscope/finetune/reinforcement/examples/workspace/README-zh.md b/dashscope/applications/finetune/reinforcement/examples/workspace/README-zh.md similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/README-zh.md rename to dashscope/applications/finetune/reinforcement/examples/workspace/README-zh.md diff --git a/dashscope/finetune/reinforcement/examples/workspace/README.md b/dashscope/applications/finetune/reinforcement/examples/workspace/README.md similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/README.md rename to dashscope/applications/finetune/reinforcement/examples/workspace/README.md diff --git a/dashscope/finetune/reinforcement/examples/workspace/UserGuide-zh.md b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/UserGuide-zh.md rename to dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide-zh.md diff --git a/dashscope/finetune/reinforcement/examples/workspace/UserGuide.md b/dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/UserGuide.md rename to dashscope/applications/finetune/reinforcement/examples/workspace/UserGuide.md diff --git a/dashscope/finetune/reinforcement/examples/workspace/cli.sh b/dashscope/applications/finetune/reinforcement/examples/workspace/cli.sh similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/cli.sh rename to dashscope/applications/finetune/reinforcement/examples/workspace/cli.sh diff --git a/dashscope/finetune/reinforcement/examples/workspace/data/calc_train_min.jsonl b/dashscope/applications/finetune/reinforcement/examples/workspace/data/calc_train_min.jsonl similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/data/calc_train_min.jsonl rename to dashscope/applications/finetune/reinforcement/examples/workspace/data/calc_train_min.jsonl diff --git a/dashscope/finetune/reinforcement/examples/workspace/data/calc_validation_min.jsonl b/dashscope/applications/finetune/reinforcement/examples/workspace/data/calc_validation_min.jsonl similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/data/calc_validation_min.jsonl rename to dashscope/applications/finetune/reinforcement/examples/workspace/data/calc_validation_min.jsonl diff --git a/dashscope/finetune/reinforcement/common/__init__.py b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/__init__.py similarity index 100% rename from dashscope/finetune/reinforcement/common/__init__.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/functions/__init__.py diff --git a/dashscope/finetune/reinforcement/examples/workspace/functions/reward/group_reward.py b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/group_reward.py similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/functions/reward/group_reward.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/group_reward.py diff --git a/dashscope/finetune/reinforcement/examples/workspace/functions/reward/reward.py b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/reward.py similarity index 98% rename from dashscope/finetune/reinforcement/examples/workspace/functions/reward/reward.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/reward.py index aef38047..b0c7b7af 100644 --- a/dashscope/finetune/reinforcement/examples/workspace/functions/reward/reward.py +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/reward.py @@ -16,7 +16,10 @@ # Public names are provided lazily via __getattr__; __all__ entries are not # bindings. # pylint: disable=undefined-all-variable -from dashscope.finetune.reinforcement import RewardInput, RewardOutput +from dashscope.finetune.reinforcement import ( + RewardInput, + RewardOutput, +) from dashscope.finetune.reinforcement import logger from dashscope.finetune.reinforcement.component.data.base_data_model import ( TaskStatus, diff --git a/dashscope/finetune/reinforcement/examples/workspace/functions/reward/reward_decorator.py b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/reward_decorator.py similarity index 97% rename from dashscope/finetune/reinforcement/examples/workspace/functions/reward/reward_decorator.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/reward_decorator.py index 394e7d5a..e46a15e1 100644 --- a/dashscope/finetune/reinforcement/examples/workspace/functions/reward/reward_decorator.py +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/reward/reward_decorator.py @@ -12,7 +12,9 @@ import time from typing import Dict -from dashscope.finetune.reinforcement import AbstractRewardProcessor +from dashscope.finetune.reinforcement import ( + AbstractRewardProcessor, +) from dashscope.finetune.reinforcement import ( RewardInput, RewardOutput, diff --git a/dashscope/finetune/reinforcement/examples/workspace/functions/rollout/rollout.py b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/rollout/rollout.py similarity index 99% rename from dashscope/finetune/reinforcement/examples/workspace/functions/rollout/rollout.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/functions/rollout/rollout.py index 12ece959..515c9b87 100644 --- a/dashscope/finetune/reinforcement/examples/workspace/functions/rollout/rollout.py +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/rollout/rollout.py @@ -29,7 +29,10 @@ # Public names are provided lazily via __getattr__; __all__ entries are not # bindings. # pylint: disable=undefined-all-variable -from dashscope.finetune.reinforcement import RolloutInput, RolloutOutput +from dashscope.finetune.reinforcement import ( + RolloutInput, + RolloutOutput, +) from dashscope.finetune.reinforcement.component.data.base_data_model import ( AgentOutput, TaskStatus, @@ -438,7 +441,7 @@ def __del__(self): if __name__ == "__main__": - from dashscope.finetune.reinforcement.component.data.base_data_model import ( # noqa: E501 + from dashscope.finetune.reinforcement.component.data.base_data_model import ( # noqa: E501 # pylint: disable=line-too-long ModelResource, ) import os diff --git a/dashscope/finetune/reinforcement/examples/workspace/functions/rollout/rollout_only.py b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/rollout/rollout_only.py similarity index 99% rename from dashscope/finetune/reinforcement/examples/workspace/functions/rollout/rollout_only.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/functions/rollout/rollout_only.py index 14cd00bb..36a4b6a9 100644 --- a/dashscope/finetune/reinforcement/examples/workspace/functions/rollout/rollout_only.py +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/functions/rollout/rollout_only.py @@ -36,7 +36,10 @@ from types import SimpleNamespace from typing import Any, Dict, List -from dashscope.finetune.reinforcement import RolloutInput, RolloutOutput +from dashscope.finetune.reinforcement import ( + RolloutInput, + RolloutOutput, +) from dashscope.finetune.reinforcement import logger from dashscope.finetune.reinforcement.component.data.base_data_model import ( AgentOutput, diff --git a/dashscope/finetune/reinforcement/examples/workspace/opd-job.yaml b/dashscope/applications/finetune/reinforcement/examples/workspace/opd-job.yaml similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/opd-job.yaml rename to dashscope/applications/finetune/reinforcement/examples/workspace/opd-job.yaml diff --git a/dashscope/finetune/reinforcement/examples/workspace/requirements.txt b/dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/requirements.txt rename to dashscope/applications/finetune/reinforcement/examples/workspace/requirements.txt diff --git a/dashscope/finetune/reinforcement/examples/workspace/resources/group_reward_input.json b/dashscope/applications/finetune/reinforcement/examples/workspace/resources/group_reward_input.json similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/resources/group_reward_input.json rename to dashscope/applications/finetune/reinforcement/examples/workspace/resources/group_reward_input.json diff --git a/dashscope/finetune/reinforcement/examples/workspace/resources/reward_decorator_input.json b/dashscope/applications/finetune/reinforcement/examples/workspace/resources/reward_decorator_input.json similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/resources/reward_decorator_input.json rename to dashscope/applications/finetune/reinforcement/examples/workspace/resources/reward_decorator_input.json diff --git a/dashscope/finetune/reinforcement/examples/workspace/resources/reward_input.json b/dashscope/applications/finetune/reinforcement/examples/workspace/resources/reward_input.json similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/resources/reward_input.json rename to dashscope/applications/finetune/reinforcement/examples/workspace/resources/reward_input.json diff --git a/dashscope/finetune/reinforcement/examples/workspace/resources/rollout_input.json b/dashscope/applications/finetune/reinforcement/examples/workspace/resources/rollout_input.json similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/resources/rollout_input.json rename to dashscope/applications/finetune/reinforcement/examples/workspace/resources/rollout_input.json diff --git a/dashscope/finetune/reinforcement/examples/workspace/rl-job.yaml b/dashscope/applications/finetune/reinforcement/examples/workspace/rl-job.yaml similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/rl-job.yaml rename to dashscope/applications/finetune/reinforcement/examples/workspace/rl-job.yaml diff --git a/dashscope/finetune/reinforcement/examples/workspace/submit_job.py b/dashscope/applications/finetune/reinforcement/examples/workspace/submit_job.py similarity index 98% rename from dashscope/finetune/reinforcement/examples/workspace/submit_job.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/submit_job.py index c4b971c7..7311f4ad 100644 --- a/dashscope/finetune/reinforcement/examples/workspace/submit_job.py +++ b/dashscope/applications/finetune/reinforcement/examples/workspace/submit_job.py @@ -117,7 +117,7 @@ async def main_workflow(): ], # Function Component Configuration functions=[ - # RolloutFunctionComponent:Environment simulation/rollout + # RolloutFunctionComponent: Environment simulation/rollout # generation RolloutFunctionComponent( name="rollout-1", @@ -127,7 +127,7 @@ async def main_workflow(): ), runtime=FunctionComponentRuntime(**rollout_runtime), ), - # RewardFunctionComponent:Reward calculation engine + # RewardFunctionComponent: Reward calculation engine RewardFunctionComponent( name="reward-1", weight=1.0, diff --git a/dashscope/finetune/reinforcement/examples/workspace/test_functions.py b/dashscope/applications/finetune/reinforcement/examples/workspace/test_functions.py similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/test_functions.py rename to dashscope/applications/finetune/reinforcement/examples/workspace/test_functions.py diff --git a/dashscope/v2/client.py b/dashscope/client.py similarity index 77% rename from dashscope/v2/client.py rename to dashscope/client.py index f2c91dc6..d000f3f9 100644 --- a/dashscope/v2/client.py +++ b/dashscope/client.py @@ -10,35 +10,37 @@ from pathlib import Path from typing import Any, Dict, Mapping, Optional, Union -import warnings 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 ( +from dashscope._config import Config, resolve_config +from dashscope.protocols import ChatProtocol, get_protocol +from dashscope.resources.apps import Apps, AsyncApps +from dashscope.resources.audio import AsyncAudio, Audio +from dashscope.resources.chat import AsyncChat, Chat +from dashscope.resources.decision import AsyncDecision, Decision +from dashscope.resources.deployments import AsyncDeployments, Deployments +from dashscope.resources.embeddings import AsyncEmbeddings, Embeddings +from dashscope.resources.files import AsyncFiles, Files +from dashscope.resources.fine_tunes import AsyncFineTunes, FineTunes +from dashscope.resources.images import AsyncImages, Images +from dashscope.resources.models import AsyncModels, Models +from dashscope.resources.multimodal import AsyncMultimodal, Multimodal +from dashscope.resources.rerank import AsyncRerank, Rerank +from dashscope.resources.reinforcement_learning import AsyncRl, Rl +from dashscope.resources.tingwu import AsyncTingWu, TingWu +from dashscope.resources.tokenization import AsyncTokenization, Tokenization +from dashscope.resources.videos import AsyncVideos, Videos +from dashscope.transport import ( AsyncTransport, SyncTransport, default_user_agent, ) -from dashscope.v2.transport.ws import Connector -from dashscope.v2.transport.ws_connection import WsConnection +from dashscope.transport.ws import Connector +from dashscope.transport.ws_connection import WsConnection + +# Historical dashscope.client.* children resolve through the 1.x alias finder. +__path__ = [] def _http_limits(config: Config) -> httpx.Limits: @@ -50,6 +52,21 @@ def _http_limits(config: Config) -> httpx.Limits: ) +def _agentstudio_base_url(config: Config) -> Optional[str]: + """Derive AgentStudio's service root from an explicit main base URL. + + Default workspace gateways are resolved by the AgentStudio core itself. + A configured main service root ends at ``/api/v1``; AgentStudio is mounted + below it at ``/agentstudio``. + """ + if config.configured_base_url is None: + return None + base = config.configured_base_url.rstrip("/") + if base.endswith("/agentstudio"): + return base + return f"{base}/agentstudio" + + class Client: """Synchronous dashscope 2.0 client. @@ -127,18 +144,8 @@ def __init__( @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. - """ + """Audio capabilities (lazily mounted).""" 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 @@ -151,33 +158,31 @@ def reinforcement_learning(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. + """Lazily mounted AgentStudio sub-SDK (design doc M3 3.8). + + AgentStudio raises ``dashscope.errors`` natively. It ships its + own transport rather than riding this client's, so its requests + keep the unmarked module tag (``agentstudio``) instead of the v2 + ``2`` marker -- the backend already reports on that tag, and + changing it would split the server-side statistics. """ 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 + from dashscope.applications.agentstudio import ( + Client as AgentStudioClient, + ) # 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, - ), + self._agentstudio = AgentStudioClient( + api_key=self._config.api_key, + workspace=self._config.workspace, + region=self._config.region, + base_url=_agentstudio_base_url(self._config), + timeout=self._config.timeout, + max_retries=self._config.max_retries, + http_client=self._transport.http_client, ) return self._agentstudio @@ -260,6 +265,8 @@ def ws_connection( connector: Optional[Connector] = None, timeout: Optional[float] = None, module: Optional[str] = None, + task_queue_size: int = 1024, + ping_interval: Optional[float] = 30.0, ) -> WsConnection: """Open a shared duplex websocket carrying multiple tasks. @@ -282,6 +289,8 @@ def ws_connection( connector=connector, timeout=config.timeout if timeout is None else timeout, module=module, + task_queue_size=task_queue_size, + ping_interval=ping_interval, ) def __enter__(self) -> "Client": @@ -368,18 +377,8 @@ def __init__( @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. - """ + """Audio capabilities (lazily mounted).""" 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 @@ -392,25 +391,27 @@ def reinforcement_learning(self) -> "AsyncRl": @property def agentstudio(self): - """Lazily mounted 1.x AgentStudio sub-SDK (design doc M3 3.8). + """Lazily mounted 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). + See :attr:`Client.agentstudio`; errors use the ``dashscope`` + exception family natively. """ 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 + from dashscope.applications.agentstudio import ( + AsyncClient as AsClient, + ) # 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, - ), + self._agentstudio = AsClient( + api_key=self._config.api_key, + workspace=self._config.workspace, + region=self._config.region, + base_url=_agentstudio_base_url(self._config), + timeout=self._config.timeout, + max_retries=self._config.max_retries, + http_client=self._transport.http_client, ) return self._agentstudio diff --git a/dashscope/v2/errors.py b/dashscope/errors.py similarity index 69% rename from dashscope/v2/errors.py rename to dashscope/errors.py index c2435535..005f53b8 100644 --- a/dashscope/v2/errors.py +++ b/dashscope/errors.py @@ -3,7 +3,7 @@ """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 +catalog (``dashscope/_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. @@ -18,8 +18,15 @@ 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`. +:class:`SdkError`; the transport errors (:class:`APIConnectionError` / +:class:`APITimeoutError`) are SdkError subclasses backed by the generic +``sdk.ConnectionError`` / ``sdk.Timeout`` entries, the websocket +transport errors (:class:`WsEventError` / :class:`WsStateError` / +:class:`WsQueueCapacityExceededError` / +:class:`WsBinaryOwnerConflictError`) are backed by the ``sdk.ws.*`` +entries. AgentStudio and AgenticRL also raise catalog-backed v2 errors +natively; :func:`error_from_sdk_exception` remains available for mounts +over external SDK families. """ from __future__ import annotations @@ -31,8 +38,9 @@ import httpx -from dashscope.v2 import _error_registry as _registry -from dashscope.v2._error_registry import PublicError, SdkErrorDef +from dashscope import _error_registry as _registry +from dashscope._error_registry import PublicError, SdkErrorDef +from dashscope._shared._errors import DashScopeException class ErrorCode(str, Enum): @@ -53,8 +61,13 @@ class ErrorCode(str, Enum): ARREARAGE = "Arrearage" -class DashScopeError(Exception): - """Base class for all dashscope 2.0 SDK errors.""" +class DashScopeError(DashScopeException): + """Base class for all dashscope 2.0 SDK errors. + + Roots into the shared :class:`~dashscope._shared._errors.DashScopeException` + so a single ``except DashScopeException`` catches errors from both the 1.x + API classes and the 2.0 client (including the AgentStudio sub-SDK). + """ def __init__( self, @@ -85,12 +98,119 @@ def __str__(self) -> str: return " | ".join(parts) -class APIConnectionError(DashScopeError): - """Network-level failure (connection refused, DNS, TLS...).""" +_SDK_ERRORS: Dict[str, SdkErrorDef] = { + entry.name: entry for entry in _registry.SDK_ERRORS +} + + +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 + + +class _CatalogSdkError(SdkError): + """SdkError whose message defaults to the entry's ``{detail}`` template.""" + + _SDK_ERROR_NAME = "" + + def __init__( + self, + message: Optional[str] = None, + *, + detail: Optional[str] = None, + **kwargs: Any, + ) -> None: + entry = _SDK_ERRORS[self._SDK_ERROR_NAME] + if message is None: + message = entry.format_message({"detail": detail or ""}) + kwargs.setdefault("code", entry.name) + kwargs.setdefault("solution", entry.solution) + kwargs.setdefault("allow_retry", entry.allow_retry) + super().__init__(message, **kwargs) + + +class APIConnectionError(_CatalogSdkError): + """Network-level failure (connection refused, DNS, TLS...). + + Backed by the catalog's ``sdk.ConnectionError`` entry: ``code`` / + ``solution`` / ``allow_retry`` default from it, so every raise site + is catalog-identical without repeating the wiring. Pass ``detail`` + instead of ``message`` to use the entry's message template. + """ + + _SDK_ERROR_NAME = "sdk.ConnectionError" class APITimeoutError(APIConnectionError): - """Request timed out.""" + """Request timed out; backed by the catalog's ``sdk.Timeout`` entry.""" + + _SDK_ERROR_NAME = "sdk.Timeout" + + +class WsEventError(_CatalogSdkError): + """The server sent an unexpected websocket protocol event. + + Backed by the catalog's ``sdk.ws.ProtocolError`` entry: ``code`` / + ``solution`` / ``allow_retry`` default from it. Pass ``detail`` + instead of ``message`` to use the entry's message template. + """ + + _SDK_ERROR_NAME = "sdk.ws.ProtocolError" + + +class WsStateError(_CatalogSdkError): + """Operation invalid for the websocket task's current state. + + Backed by the catalog's ``sdk.ws.IllegalState`` entry: duplicate + start, send after close, receive on a closed session. + """ + + _SDK_ERROR_NAME = "sdk.ws.IllegalState" + + +class WsQueueCapacityExceededError(_CatalogSdkError): + """A bounded websocket task receive queue overflowed. + + Backed by the catalog's ``sdk.ws.QueueCapacityExceeded`` entry. + """ + + _SDK_ERROR_NAME = "sdk.ws.QueueCapacityExceeded" + + +class WsBinaryOwnerConflictError(_CatalogSdkError): + """A shared connection allows one binary owner per direction. + + Backed by the catalog's ``sdk.ws.BinaryOwnerConflict`` entry. + """ + + _SDK_ERROR_NAME = "sdk.ws.BinaryOwnerConflict" class APIStatusError(DashScopeError): @@ -161,38 +281,6 @@ class ContentFilterError(BadRequestError): """ -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 ( @@ -208,10 +296,15 @@ def __init__( ) } -_STATUS_MAP: Dict[int, Type[APIStatusError]] = { - entry.status_code: _CATALOG_CLASSES[entry.error_code] - for entry in _registry.PUBLIC_ERRORS -} +# First entry per status wins. Statuses with multiple public entries +# (400, 401, and 429) intentionally resolve to their shared class. +_STATUS_MAP: Dict[int, Type[APIStatusError]] = {} +for _entry_ in _registry.PUBLIC_ERRORS: + _STATUS_MAP.setdefault( + _entry_.status_code, + _CATALOG_CLASSES[_entry_.error_code], + ) +del _entry_ _STATUS_COUNTS = Counter( entry.status_code for entry in _registry.PUBLIC_ERRORS @@ -263,10 +356,6 @@ def _entry(key: str) -> PublicError: 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, @@ -347,19 +436,41 @@ def error_from_business_code( ) +def sdk_error( + name: str, + variables: Optional[Dict[str, str]] = None, + **kwargs: Any, +) -> SdkError: + """Build an :class:`SdkError` straight from a catalog entry. + + For ``SDK_ERRORS`` entries with no dedicated exception class (e.g. + ``sdk.IoError``): message text, ``code``, ``solution`` and + ``allow_retry`` all come from the entry, so the raise site carries + no catalog knowledge of its own. + """ + entry = _SDK_ERRORS[name] + return SdkError( + entry.format_message(variables), + code=entry.name, + solution=entry.solution, + allow_retry=entry.allow_retry, + **kwargs, + ) + + def error_from_sdk_exception( exc: BaseException, *, family: str, ) -> SdkError: - """Translate a 1.x SDK-side exception into the v2 error family. + """Translate a mounted sub-SDK's own exception into the v2 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. + text and any numeric ``error_code`` are deliberately never sniffed + -- both are the source SDK's internals. """ name = f"{family}.{type(exc).__name__}" entry = _SDK_ERRORS.get(name) @@ -422,6 +533,46 @@ def flush() -> Any: return body if body is not None else fallback +def internal_response_error( + response: httpx.Response, + body: Any, +) -> InternalServerError: + """Build the catalog ``internal_error`` for an unusable response body.""" + entry = _entry("internal_error") + data = body if isinstance(body, dict) else {} + return InternalServerError( + entry.error_msg, + code=entry.error_code, + status_code=entry.status_code, + request_id=( + data.get("request_id") or response.headers.get("x-request-id") + ), + body=body, + headers=response.headers, + ) + + +def response_json( + response: httpx.Response, + *, + require_object: bool = True, +) -> Any: + """Parse a successful JSON response through the v2 error contract. + + A response that cannot be decoded, or whose top-level shape is not the + object every DashScope resource envelope requires, is a server response + contract failure. Report it as the catalog ``internal_error`` rather than + leaking ``JSONDecodeError`` / ``AttributeError`` from the parser. + """ + try: + body = response.json() + except ValueError as exc: + raise internal_response_error(response, response.text) from exc + if require_object and not isinstance(body, dict): + raise internal_response_error(response, body) + return body + + def _parse_body(response: httpx.Response) -> Any: try: return response.json() diff --git a/dashscope/finetune/reinforcement/component/demo/__init__.py b/dashscope/finetune/reinforcement/component/demo/__init__.py deleted file mode 100644 index 6100812f..00000000 --- a/dashscope/finetune/reinforcement/component/demo/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from dashscope.finetune.reinforcement.component.demo.group_reward_processor_demo import ( # noqa: E501 # pylint: disable=line-too-long - DemoGroupRewardProcessor, -) -from dashscope.finetune.reinforcement.component.demo.reward_processor_demo import ( # noqa: E501 # pylint: disable=line-too-long - DemoRewardProcessor, -) -from dashscope.finetune.reinforcement.component.demo.rollout_processor_demo import ( # noqa: E501 # pylint: disable=line-too-long - DemoRolloutProcessor, -) - -__all__ = [ - "DemoRewardProcessor", - # Inherits AbstractRewardProcessor, returns RewardOutput - "DemoRolloutProcessor", - # Inherits AbstractRolloutProcessor, returns RolloutOutput - "DemoGroupRewardProcessor", - # Inherits AbstractGroupRewardProcessor, returns GroupRewardOutput -] diff --git a/dashscope/finetune/reinforcement/component/parser/__init__.py b/dashscope/finetune/reinforcement/component/parser/__init__.py deleted file mode 100644 index 6f5dedd0..00000000 --- a/dashscope/finetune/reinforcement/component/parser/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from dashscope.finetune.reinforcement.component.parser.base_parser import ( - BaseRequestParser, -) -from dashscope.finetune.reinforcement.component.parser.group_reward_parser import ( # noqa: E501 - GroupRewardRequestParser, -) -from dashscope.finetune.reinforcement.component.parser.reward_parser import ( - RewardRequestParser, -) -from dashscope.finetune.reinforcement.component.parser.rollout_parser import ( - RolloutRequestParser, -) - -__all__ = [ - "BaseRequestParser", - "RewardRequestParser", - "RolloutRequestParser", - "GroupRewardRequestParser", -] diff --git a/dashscope/finetune/reinforcement/component/processor/__init__.py b/dashscope/finetune/reinforcement/component/processor/__init__.py deleted file mode 100644 index 9246fa56..00000000 --- a/dashscope/finetune/reinforcement/component/processor/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -from dashscope.finetune.reinforcement.component.processor.abstract_group_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long - AbstractGroupRewardProcessor, -) -from dashscope.finetune.reinforcement.component.processor.abstract_processor import ( # noqa: E501 # pylint: disable=line-too-long - AbstractProcessor, -) -from dashscope.finetune.reinforcement.component.processor.abstract_reward_processor import ( # noqa: E501 # pylint: disable=line-too-long - AbstractRewardProcessor, -) -from dashscope.finetune.reinforcement.component.processor.abstract_rollout_processor import ( # noqa: E501 # pylint: disable=line-too-long - AbstractRolloutProcessor, -) - -__all__ = [ - "AbstractProcessor", - "AbstractRewardProcessor", - "AbstractRolloutProcessor", - "AbstractGroupRewardProcessor", -] diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/build.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/build.sh deleted file mode 100644 index 14400acc..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/build.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env bash -#****************************************************************# -# ScriptName: build.sh -# Create Date: 2026-03-04 17:22 -# Modify Date: 2026-03-25 14:27 -# Function: -# Build and optionally install DashScope SDK for Agentic RL development -# Default behavior: Build wheel only, no installation -# Set INSTALL_LOCAL=1 to install local build -# Automatically detects the latest built wheel file -#***************************************************************# - -# Configuration -WORK_DIR='dashscope/finetune/reinforcement/examples/workspace/' - -# Parse command line arguments -INSTALL_FLAG=0 -while [[ $# -gt 0 ]]; do - case "$1" in - -i|--install) - INSTALL_FLAG=1 - shift - ;; - *) - echo "Unknown option: $1" - exit 1 - ;; - esac -done - -# Find project root directory (where setup.py is located) -find_project_root() { - # Start from current directory - local current_dir="$PWD" - - # Look for setup.py in current and parent directories - while [[ "$current_dir" != "/" ]]; do - if [[ -f "$current_dir/setup.py" ]]; then - echo "$current_dir" - return 0 - fi - current_dir=$(dirname "$current_dir") - done - - echo "Error: Could not find project root (setup.py)" >&2 - return 1 -} - -# Build SDK wheel function -build_sdk_wheel() { - echo "Building SDK wheel..." - if ! python3 -m pip install --upgrade pip setuptools wheel >/dev/null 2>&1; then - echo "Error: Failed to upgrade build tools" >&2 - return 1 - fi - - if ! python3 setup.py sdist bdist_wheel >/dev/null 2>&1; then - echo "Error: Failed to build wheel package" >&2 - return 1 - fi - - rm -rf build - return 0 -} - -# Find the latest wheel file -find_latest_wheel() { - wheels=($(find dist -name "dashscope-*.whl" 2>/dev/null)) - - if [ ${#wheels[@]} -eq 0 ]; then - echo "Error: No wheel files found in dist directory" >&2 - return 1 - fi - - # Sort by modification time (newest first) - latest=$(ls -t dist/*.whl | head -n1 2>/dev/null) - - if [ -z "$latest" ]; then - echo "Error: Failed to determine latest wheel" >&2 - return 1 - fi - - echo "$latest" - return 0 -} - -# Install local wheel function -install_local_wheel() { - echo "Installing local wheel..." - if ! pip3 uninstall -y dashscope >/dev/null 2>&1; then - echo "Warning: Failed to uninstall existing dashscope" >&2 - fi - - if ! pip3 install -U "$1" -i https://mirrors.aliyun.com/pypi/simple/ >/dev/null 2>&1; then - echo "Error: Failed to install local wheel" >&2 - return 1 - fi - - return 0 -} - -# Main script execution -main() { - # Find project root - PROJECT_ROOT=$(find_project_root) || exit 1 - echo "Project root: $PROJECT_ROOT" - - # Navigate to project root - pushd "$PROJECT_ROOT" >/dev/null || exit 1 - - # Build SDK wheel - if ! build_sdk_wheel; then - echo "Build failed" >&2 - popd >/dev/null - exit 1 - fi - - # Find the latest wheel file - LATEST_WHL=$(find_latest_wheel) - if [ $? -ne 0 ]; then - echo "Error: Failed to find wheel file" >&2 - popd >/dev/null - exit 1 - fi - - # Extract the wheel filename - WHL_FILENAME=$(basename "$LATEST_WHL") - echo "Latest wheel file: $WHL_FILENAME" - - # Create workspace directory if needed - WORKSPACE_PATH="${PROJECT_ROOT}/${WORK_DIR}" - if [ ! -d "$WORKSPACE_PATH" ]; then - mkdir -p "$WORKSPACE_PATH" - fi - - # Copy wheel file to workspace - echo "Copying wheel ($WHL_FILENAME) to workspace..." - if ! cp -v "$LATEST_WHL" "${WORKSPACE_PATH}/"; then - echo "Error: Failed to copy wheel to workspace" >&2 - popd >/dev/null - exit 1 - fi - - # Install local wheel if requested - if [ "$INSTALL_FLAG" = "1" ]; then - if ! install_local_wheel "${WORKSPACE_PATH}/${WHL_FILENAME}"; then - echo "Installation failed" >&2 - popd >/dev/null - exit 1 - fi - echo "Successfully installed local build" - else - echo "Local build created but not installed (use --install to install)" - fi - - # Return to original directory - popd >/dev/null - exit 0 -} - -# Execute main function -main diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_group_reward.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_group_reward.sh deleted file mode 100644 index 227618a7..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_group_reward.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -#****************************************************************# -# ScriptName: query_group_reward.sh -# Create Date: 2026-03-16 11:44 -# Modify Date: 2026-03-16 11:44 -# Function: -#***************************************************************# -curl -X POST http://localhost:8000/api/v1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer xxx" \ - -d '{ - "func_type": "group_reward", - "ground_truth": "0.5", - "request_metadata": null, - "agent_outputs": [{ - "messages": [{"role": "user", "content": "Test question"}], - "rollout_extra": { - "content": "This is a test content that exceeds 50 characters in length and should receive a reward score of 0.5" - }, - "rollout_metrics": {"accuracy": 0.95}, - "reward_score": null - }], - "rollout_id": "ro-ins-e8e92e94-b5c3-4546-b7b1-9832b8b948f2" -}' diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_reward.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_reward.sh deleted file mode 100644 index da04f084..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_reward.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -#****************************************************************# -# ScriptName: query_reward.sh -# Create Date: 2026-03-16 11:44 -# Modify Date: 2026-03-16 11:44 -# Function: -#***************************************************************# -curl -X POST http://localhost:8000/api/v1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer xxx" \ - -d '{ - "func_type": "reward", - "ground_truth": "0.5", - "request_metadata": null, - "agent_output": { - "messages": [{"role": "user", "content": "Test question"}], - "rollout_extra": { - "content": "This is a test content that exceeds 50 characters in length and should receive a reward score of 0.5" - }, - "rollout_metrics": {"accuracy": 0.95}, - "reward_score": null - }, - "rollout_id": "ro-ins-e8e92e94-b5c3-4546-b7b1-9832b8b948f2" -}' diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_reward_decorator.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_reward_decorator.sh deleted file mode 100644 index 9a459b1f..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_reward_decorator.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/sh -#****************************************************************# -# ScriptName: query_local_reward_decorator.sh -# Create Date: 2026-03-16 11:44 -# Modify Date: 2026-03-16 11:44 -# Function: -#***************************************************************# -curl -X POST http://localhost:8000/api/v1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer xxx" \ - -d '{ - "func_type": "reward", - "ground_truth": "0.5", - "agent_output": { - "messages": [{"role": "user", "content": "I cannot help you with that"}] - } -}' & - -curl -X POST http://localhost:8000/api/v1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer xxx" \ - -d '{ - "func_type": "reward", - "ground_truth": "0.5", - "agent_output": { - "messages": [{"role": "user", "content": "I attack you with that"}] - } -}' & - -curl -X POST http://localhost:8000/api/v1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer xxx" \ - -d '{ - "func_type": "reward", - "ground_truth": "0.5", - "agent_output": { - "messages": [{"role": "user", "content": "I walk with you"}] - } -}' & diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_rollout.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_rollout.sh deleted file mode 100644 index 23c44c84..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_local_rollout.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/sh -#****************************************************************# -# ScriptName: 1.sh -# Create Date: 2026-03-16 11:30 -# Modify Date: 2026-03-16 11:30 -# Function: -#***************************************************************# -curl -X POST http://localhost:8000/api/v1 \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer xxx" \ - -d '{ - "func_type": "rollout", - "messages": [ - { - "role": "user", - "content": "Hello, please introduce yourself" - } - ], - "tools": null, - "ground_truth": "", - "rollout_extra": { - "rollout_id": "ro-ins-e8e92e94-b5c3-4546-b7b1-9832b8b948f2", - "training_state": { - "task_id": "task-001" - } - }, - "sampling_params": { - "temperature": 0.7, - "max_tokens": 1024, - "max_turns": 25, - "timeout": 60.0 - }, - "model_resource": { - "model_name": "qwen3.5-35b-a3b", - "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "api_key": "your-api-key-here" - }, - "request_metadata": { - "protocol": "openai", - "system_prompt": "", - "job_id": "dummy-job-id", - "sample_id": "dummy-sample-id", - "rollout_id": "dummy-rollout-id", - "attempt_id": "dummy-attempt-id" - }, - "additional_field": "any extra data you want" -}' diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_online_tuning.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/query_online_tuning.sh deleted file mode 100644 index 70e616b1..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/query_online_tuning.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/sh -#****************************************************************# -# ScriptName: 1.sh -# Create Date: 2026-03-16 11:30 -# Modify Date: 2026-03-16 11:30 -# Function: -#***************************************************************# -curl -X POST https://dashscope.aliyuncs.com/api/v1/fine-tunes \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer sk-xxx" \ - -d '{ - 'model': 'qwen3-32b', - 'rollout': { - 'rollout_id': 'ro-50ada8b9-e027-4e43-9b6d-c0c875a8f06b', - 'cpu': 2, - 'memory_size': 4096, - 'disk_size': 10240, - 'concurrency': 2 - }, - 'rewards': [{ - 'reward_id': 'rw-4092eb99-8252-46a7-9919-3819df9484b1', - 'cpu': 2, - 'memory_size': 4096, - 'disk_size': 10240, - 'concurrency': 10 - }, { - 'reward_id': 'rw-38d37050-6c07-4b63-b014-fbb048c2a1c0', - 'cpu': 2, - 'memory_size': 4096, - 'disk_size': 10240, - 'concurrency': 5 - }], - 'training_file_ids': ['c5c39255-04d5-4d2f-9df5-c4db06107046'], - 'validation_file_ids': ['ec24290d-67bd-4378-93bf-9c23a27d4b79'], - 'hyper_parameters': { - 'batch_size': '256' - }, - 'training_type': 'reinforcement', - 'job_name': 'agentic-rl-3773091b', - 'model_name': '' -}' diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_group_reward.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_group_reward.sh deleted file mode 100755 index 04020e3c..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_group_reward.sh +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env bash -#****************************************************************# -# ScriptName: start_reward_server.sh -# Create Date: 2026-03-17 10:05 -# Modify Date: 2026-03-24 17:54 -# Function: -#***************************************************************# -set -euo pipefail # Strict error handling - -cd ../ - -# ================= Configuration ================== -#VERSION="1.25.16" -SERVICE_TYPE="group_reward" # group_reward/reward|rollout -PROCESSOR_CLASS="functions.reward.group_reward.DemoGroupRewardProcessor" # Full class path -PYPI_REPO="https://mirrors.aliyun.com/pypi/simple/" -#SDK_PACKAGE="dashscope-${VERSION}-py3-none-any.whl fastapi pyyaml uvicorn" -REQUIREMENTS_FILE="./requirements.txt" -LOG_DIR="/tmp/log/agentic_rl" -MAX_RETRIES=3 - -# ================ Helper Functions ================ -init_logging() { - mkdir -p "${LOG_DIR}" - local log_file="${LOG_DIR}/service_$(date +%Y%m%d).log" - exec 3>&1 4>&2 - trap 'exec 1>&3 2>&4' EXIT - # 关键修复:使用双引号、修正进程替换语法 - exec > >(tee -a "$log_file") 2>&1 - echo -e "\n\n=== Service Start: $(date) ===" -} - -log() { - printf "[%s] %s\n" "$(date +'%Y-%m-%d %H:%M:%S')" "$*" -} - -cleanup() { - log "Cleaning temporary resources..." - rm -rf "${TMPDIR:-/tmp}/pip*" - find . -name "__pycache__" -exec rm -rf {} + 2>/dev/null -} - -validate_environment() { - log "Validating runtime environment..." - - # Python check - if ! command -v python3 &>/dev/null; then - log "ERROR: Python3 not found in PATH" - exit 101 - fi -} - -# ============== Dependency Management ============== -install_with_retry() { - local packages=("$@") - local retry_count=0 - - while [ $retry_count -lt $MAX_RETRIES ]; do - log "Installing ${packages[*]} (attempt $((retry_count+1))/${MAX_RETRIES})" - if python3 -m pip install -U "${packages[@]}" \ - --index-url "${PYPI_REPO}" \ - --no-cache-dir \ - --compile; then - return 0 - fi - retry_count=$((retry_count+1)) - sleep $((retry_count * 10)) - done - - log "FATAL: Failed to install ${packages[*]} after ${MAX_RETRIES} attempts" - return 1 -} - -# ================ Main Execution =================== -main() { - # Phase 1: Initialization - init_logging - trap cleanup EXIT - validate_environment - - # Phase 2: Dependency Setup - log "Starting dependency installation" - -# local packages=($SDK_PACKAGE) -# for pkg in "${packages[@]}"; do -# if ! install_with_retry "$pkg"; then -# exit 201 -# fi -# done - - if [ -f "${REQUIREMENTS_FILE}" ]; then - log "Installing additional requirements" - if ! install_with_retry -r "${REQUIREMENTS_FILE}"; then - exit 202 - fi - fi - - # Phase 3: Environment Configuration - export FUNC_TYPE="${SERVICE_TYPE}" - export PROCESSOR_CLASS="${PROCESSOR_CLASS}" - export PYTHONPATH=".:${PYTHONPATH:-}" - - log "Final Environment:" - env | grep -E 'FUNC_TYPE|PROCESSOR_CLASS|PYTHONPATH' - - # Phase 4: Service Launch - log "Starting ${SERVICE_TYPE} service" - exec python3 -m dashscope.finetune.reinforcement.component.server.server "$@" -} - -# ==================== Entry ======================== -main "$@" diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_reward.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_reward.sh deleted file mode 100755 index c4c8445a..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_reward.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -#****************************************************************# -# ScriptName: start_reward_server.sh -# Create Date: 2026-03-17 10:05 -# Modify Date: 2026-03-24 17:54 -# Function: -#***************************************************************# -set -euo pipefail # Strict error handling - -cd ../ - -# ================= Configuration ================== -#VERSION="1.25.16" -SERVICE_TYPE="reward" # reward|rollout -PROCESSOR_CLASS="functions.reward.reward.DemoRewardProcessor" # Full class path -PYPI_REPO="https://mirrors.aliyun.com/pypi/simple/" -#SDK_PACKAGE="dashscope-${VERSION}-py3-none-any.whl fastapi pyyaml uvicorn" -REQUIREMENTS_FILE="./requirements.txt" -LOG_DIR="/tmp/log/agentic_rl" -MAX_RETRIES=3 - -# ================ Helper Functions ================ -init_logging() { - mkdir -p "${LOG_DIR}" - local log_file="${LOG_DIR}/service_$(date +%Y%m%d).log" - exec 3>&1 4>&2 - trap 'exec 1>&3 2>&4' EXIT - exec > >(tee -a "$log_file") 2>&1 - echo -e "\n\n=== Service Start: $(date) ===" -} - -log() { - printf "[%s] %s\n" "$(date +'%Y-%m-%d %H:%M:%S')" "$*" -} - -cleanup() { - log "Cleaning temporary resources..." - rm -rf "${TMPDIR:-/tmp}/pip*" - find . -name "__pycache__" -exec rm -rf {} + 2>/dev/null -} - -validate_environment() { - log "Validating runtime environment..." - - # Python check - if ! command -v python3 &>/dev/null; then - log "ERROR: Python3 not found in PATH" - exit 101 - fi -} - -# ============== Dependency Management ============== -install_with_retry() { - local packages=("$@") - local retry_count=0 - - while [ $retry_count -lt $MAX_RETRIES ]; do - log "Installing ${packages[*]} (attempt $((retry_count+1))/${MAX_RETRIES})" - if python3 -m pip install -U "${packages[@]}" \ - --index-url "${PYPI_REPO}" \ - --no-cache-dir \ - --compile; then - return 0 - fi - retry_count=$((retry_count+1)) - sleep $((retry_count * 10)) - done - - log "FATAL: Failed to install ${packages[*]} after ${MAX_RETRIES} attempts" - return 1 -} - -# ================ Main Execution =================== -main() { - # Phase 1: Initialization - init_logging - trap cleanup EXIT - validate_environment - - # Phase 2: Dependency Setup - log "Starting dependency installation" - -# local packages=($SDK_PACKAGE) -# for pkg in "${packages[@]}"; do -# if ! install_with_retry "$pkg"; then -# exit 201 -# fi -# done - - if [ -f "${REQUIREMENTS_FILE}" ]; then - log "Installing additional requirements" - if ! install_with_retry -r "${REQUIREMENTS_FILE}"; then - exit 202 - fi - fi - - # Phase 3: Environment Configuration - export FUNC_TYPE="${SERVICE_TYPE}" - export PROCESSOR_CLASS="${PROCESSOR_CLASS}" - export PYTHONPATH=".:${PYTHONPATH:-}" - - log "Final Environment:" - env | grep -E 'FUNC_TYPE|PROCESSOR_CLASS|PYTHONPATH' - - # Phase 4: Service Launch - log "Starting ${SERVICE_TYPE} service" - exec python3 -m dashscope.finetune.reinforcement.component.server.server "$@" -} - -# ==================== Entry ======================== -main "$@" diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_reward_decorator.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_reward_decorator.sh deleted file mode 100755 index 31f1ac76..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_reward_decorator.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -#****************************************************************# -# ScriptName: start_local_reward_decorator.sh -# Create Date: 2026-03-17 10:05 -# Modify Date: 2026-03-24 17:54 -# Function: -#***************************************************************# -set -euo pipefail # Strict error handling - -cd ../ - -# ================= Configuration ================== -#VERSION="1.25.16" -SERVICE_TYPE="reward" # reward|rollout -PROCESSOR_CLASS="functions.reward.reward_decorator.SafetyProcessor" # Full class path -PYPI_REPO="https://mirrors.aliyun.com/pypi/simple/" -#SDK_PACKAGE="dashscope-${VERSION}-py3-none-any.whl fastapi pyyaml uvicorn" -REQUIREMENTS_FILE="./requirements.txt" -LOG_DIR="/tmp/log/agentic_rl" -MAX_RETRIES=3 - -# ================ Helper Functions ================ -init_logging() { - mkdir -p "${LOG_DIR}" - local log_file="${LOG_DIR}/service_$(date +%Y%m%d).log" - exec 3>&1 4>&2 - trap 'exec 1>&3 2>&4' EXIT - exec > >(tee -a "$log_file") 2>&1 - echo -e "\n\n=== Service Start: $(date) ===" -} - -log() { - printf "[%s] %s\n" "$(date +'%Y-%m-%d %H:%M:%S')" "$*" -} - -cleanup() { - log "Cleaning temporary resources..." - rm -rf "${TMPDIR:-/tmp}/pip*" - find . -name "__pycache__" -exec rm -rf {} + 2>/dev/null -} - -validate_environment() { - log "Validating runtime environment..." - - # Python check - if ! command -v python3 &>/dev/null; then - log "ERROR: Python3 not found in PATH" - exit 101 - fi -} - -# ============== Dependency Management ============== -install_with_retry() { - local packages=("$@") - local retry_count=0 - - while [ $retry_count -lt $MAX_RETRIES ]; do - log "Installing ${packages[*]} (attempt $((retry_count+1))/${MAX_RETRIES})" - if python3 -m pip install -U "${packages[@]}" \ - --index-url "${PYPI_REPO}" \ - --no-cache-dir \ - --compile; then - return 0 - fi - retry_count=$((retry_count+1)) - sleep $((retry_count * 10)) - done - - log "FATAL: Failed to install ${packages[*]} after ${MAX_RETRIES} attempts" - return 1 -} - -# ================ Main Execution =================== -main() { - # Phase 1: Initialization - init_logging - trap cleanup EXIT - validate_environment - - # Phase 2: Dependency Setup - log "Starting dependency installation" - -# local packages=($SDK_PACKAGE) -# for pkg in "${packages[@]}"; do -# if ! install_with_retry "$pkg"; then -# exit 201 -# fi -# done - - if [ -f "${REQUIREMENTS_FILE}" ]; then - log "Installing additional requirements" - if ! install_with_retry -r "${REQUIREMENTS_FILE}"; then - exit 202 - fi - fi - - # Phase 3: Environment Configuration - export FUNC_TYPE="${SERVICE_TYPE}" - export PROCESSOR_CLASS="${PROCESSOR_CLASS}" - export PYTHONPATH=".:${PYTHONPATH:-}" - - log "Final Environment:" - env | grep -E 'FUNC_TYPE|PROCESSOR_CLASS|PYTHONPATH' - - # Phase 4: Service Launch - log "Starting ${SERVICE_TYPE} service" - exec python3 -m dashscope.finetune.reinforcement.component.server.server "$@" -} - -# ==================== Entry ======================== -main "$@" diff --git a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_rollout.sh b/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_rollout.sh deleted file mode 100644 index 49c38b12..00000000 --- a/dashscope/finetune/reinforcement/examples/workspace/scripts/start_local_rollout.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/usr/bin/env bash -#****************************************************************# -# ScriptName: start_reward_server.sh -# Create Date: 2026-03-17 10:05 -# Modify Date: 2026-03-17 10:05 -# Function: -#***************************************************************# -set -euo pipefail # Strict error handling - -cd ../ - -# ================= Configuration ================== -#VERSION="1.25.16" -SERVICE_TYPE="rollout" # reward|rollout -PROCESSOR_CLASS="functions.rollout.rollout2.DemoRolloutProcessor" # Full class path -PYPI_REPO="https://mirrors.aliyun.com/pypi/simple/" -#SDK_PACKAGE="dashscope-${VERSION}-py3-none-any.whl fastapi pyyaml uvicorn" -REQUIREMENTS_FILE="./requirements.txt" -LOG_DIR="/tmp/log/agentic_rl" -MAX_RETRIES=3 - -# ================ Helper Functions ================ -init_logging() { - mkdir -p "${LOG_DIR}" - local log_file="${LOG_DIR}/service_$(date +%Y%m%d).log" - exec 3>&1 4>&2 - trap 'exec 1>&3 2>&4' EXIT - exec > >(tee -a "$log_file") 2>&1 - echo -e "\n\n=== Service Start: $(date) ===" -} - -log() { - printf "[%s] %s\n" "$(date +'%Y-%m-%d %H:%M:%S')" "$*" -} - -cleanup() { - log "Cleaning temporary resources..." - rm -rf "${TMPDIR:-/tmp}/pip*" - find . -name "__pycache__" -exec rm -rf {} + 2>/dev/null -} - -validate_environment() { - log "Validating runtime environment..." - - # Python check - if ! command -v python3 &>/dev/null; then - log "ERROR: Python3 not found in PATH" - exit 101 - fi -} - -# ============== Dependency Management ============== -install_with_retry() { - local packages=("$@") - local retry_count=0 - - while [ $retry_count -lt $MAX_RETRIES ]; do - log "Installing ${packages[*]} (attempt $((retry_count+1))/${MAX_RETRIES})" - if python3 -m pip install -U "${packages[@]}" \ - --index-url "${PYPI_REPO}" \ - --no-cache-dir \ - --compile; then - return 0 - fi - retry_count=$((retry_count+1)) - sleep $((retry_count * 10)) - done - - log "FATAL: Failed to install ${packages[*]} after ${MAX_RETRIES} attempts" - return 1 -} - -# ================ Main Execution =================== -main() { - # Phase 1: Initialization - init_logging - trap cleanup EXIT - validate_environment - - # Phase 2: Dependency Setup - log "Starting dependency installation" - -# local packages=($SDK_PACKAGE) -# for pkg in "${packages[@]}"; do -# if ! install_with_retry "$pkg"; then -# exit 201 -# fi -# done - - if [ -f "${REQUIREMENTS_FILE}" ]; then - log "Installing additional requirements" - if ! install_with_retry -r "${REQUIREMENTS_FILE}"; then - exit 202 - fi - fi - - # Phase 3: Environment Configuration - export FUNC_TYPE="${SERVICE_TYPE}" - export PROCESSOR_CLASS="${PROCESSOR_CLASS}" - export PYTHONPATH=".:${PYTHONPATH:-}" - - log "Final Environment:" - env | grep -E 'FUNC_TYPE|PROCESSOR_CLASS|PYTHONPATH' - - # Phase 4: Service Launch - log "Starting ${SERVICE_TYPE} service" - exec python3 -m dashscope.finetune.reinforcement.component.server.server "$@" -} - -# ==================== Entry ======================== -main "$@" diff --git a/dashscope/legacy_1x/__init__.py b/dashscope/legacy_1x/__init__.py new file mode 100644 index 00000000..64709f71 --- /dev/null +++ b/dashscope/legacy_1x/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The dashscope 1.x SDK. + +Everything under this package is the legacy 1.x business surface. It is +kept importable both at its canonical ``dashscope.legacy_1x.*`` path and, +for backward compatibility, at the historical top-level paths (for +example ``dashscope.aigc`` -> ``dashscope.legacy_1x.aigc``) via thin +alias shims installed at the package root. + +New code should target the 2.0 SDK, which lives at the top level of +``dashscope`` (``dashscope.Client``, ``dashscope.chat``, ...). +""" diff --git a/dashscope/legacy_1x/_shim.py b/dashscope/legacy_1x/_shim.py new file mode 100644 index 00000000..d43a1429 --- /dev/null +++ b/dashscope/legacy_1x/_shim.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Lazy aliases for the historical 1.x import paths.""" + +from __future__ import annotations + +import importlib +import importlib.abc +import importlib.util +import os +import sys + + +_APPLICATION_ROOTS = frozenset({"finetune"}) + +_PACKAGE_ROOTS = frozenset( + { + "aigc", + "api_entities", + "app", + "assistants", + "audio", + "cli", + "common", + "embeddings", + "io", + "multimodal", + "nlp", + "protocol", + "rerank", + "threads", + "tokenizers", + "utils", + }, +) + +_EXACT_MODULES = { + "dashscope.finetune": "dashscope.legacy_1x.finetune", + "dashscope.agentstudio": "dashscope.legacy_1x.agentstudio", + "dashscope.agentstudio.client": "dashscope.legacy_1x.agentstudio.client", + "dashscope.agentstudio.exceptions": ( + "dashscope.legacy_1x.agentstudio.exceptions" + ), + "dashscope.agentstudio.streaming": ( + "dashscope.legacy_1x.agentstudio.streaming" + ), + "dashscope.agentstudio.transport": ( + "dashscope.legacy_1x.agentstudio.transport" + ), + "dashscope.files": "dashscope.legacy_1x.files", + "dashscope.model": "dashscope.legacy_1x.model", + "dashscope.models": "dashscope.legacy_1x.models", + "dashscope.finetune.agentic_rl": ( + "dashscope.legacy_1x.finetune.agentic_rl" + ), + "dashscope.finetune.reinforcement": ( + "dashscope.legacy_1x.finetune.reinforcement" + ), + "dashscope.finetune.reinforcement.common": ( + "dashscope.legacy_1x.finetune.reinforcement.common" + ), + "dashscope.finetune.reinforcement.common.errors": ( + "dashscope.legacy_1x.finetune.reinforcement.common.errors" + ), + "dashscope.finetune.customize_types": ( + "dashscope.legacy_1x.finetune.customize_types" + ), + "dashscope.finetune.deployments": ( + "dashscope.legacy_1x.finetune.deployments" + ), + "dashscope.finetune.finetunes": "dashscope.legacy_1x.finetune.finetunes", + # ``dashscope.finetune`` aliases the shared ``dashscope.applications.finetune`` + # module object, so ``from dashscope.finetune import `` resolves the child + # against that canonical ``__name__`` rather than the alias prefix above. Map the + # canonical names too, otherwise those 1.x-only submodules raise ImportError. + "dashscope.applications.finetune.customize_types": ( + "dashscope.legacy_1x.finetune.customize_types" + ), + "dashscope.applications.finetune.deployments": ( + "dashscope.legacy_1x.finetune.deployments" + ), + "dashscope.applications.finetune.finetunes": ( + "dashscope.legacy_1x.finetune.finetunes" + ), +} + +_FINDER_MARKER = "_dashscope_legacy_1x_alias_finder" + +_CLIENT_NAME = "dashscope.client" + + +def _client_module_spec(): + """Bind ``dashscope.client`` to ``client.py`` when a leftover 1.x ``client/`` + directory shadows it, since CPython resolves directories first. + + Returns None on a clean install, where normal resolution already works. + """ + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + module_file = os.path.join(root, "client.py") + if not os.path.isfile(module_file): + return None + if not os.path.isdir(os.path.join(root, "client")): + return None + return importlib.util.spec_from_file_location(_CLIENT_NAME, module_file) + + +def _canonical_name(name): + if name in _EXACT_MODULES: + return _EXACT_MODULES[name] + if name.startswith("dashscope.client."): + return "dashscope.legacy_1x.client." + name[len("dashscope.client.") :] + if name.startswith("dashscope.agentstudio."): + suffix = name[len("dashscope.agentstudio.") :] + return "dashscope.applications.agentstudio." + suffix + parts = name.split(".") + if len(parts) > 1 and parts[0] == "dashscope": + if parts[1] in _APPLICATION_ROOTS: + return "dashscope.applications." + ".".join(parts[1:]) + if parts[1] in _PACKAGE_ROOTS: + return "dashscope.legacy_1x." + ".".join(parts[1:]) + return None + + +# pylint: disable=abstract-method,unused-argument +class _AliasLoader(importlib.abc.Loader): + def __init__(self, alias_name, canonical_name, canonical_spec): + self._alias_name = alias_name + self._canonical_name = canonical_name + self._canonical_spec = canonical_spec + + def create_module(self, spec): + return None + + def exec_module(self, module): + sys.modules[self._alias_name] = importlib.import_module( + self._canonical_name, + ) + + def get_code(self, fullname): + loader = self._canonical_spec.loader + if loader is None or not hasattr(loader, "get_code"): + return None + return loader.get_code(self._canonical_name) + + +# pylint: disable=unused-argument +class _AliasFinder(importlib.abc.MetaPathFinder): + _dashscope_legacy_1x_alias_finder = True + + def find_spec(self, fullname, path=None, target=None): + if fullname == _CLIENT_NAME: + return _client_module_spec() + canonical_name = _canonical_name(fullname) + if canonical_name is None: + return None + canonical_spec = importlib.util.find_spec(canonical_name) + if canonical_spec is None: + return None + loader = _AliasLoader(fullname, canonical_name, canonical_spec) + is_package = canonical_spec.submodule_search_locations is not None + spec = importlib.util.spec_from_loader( + fullname, + loader, + origin=canonical_spec.origin, + is_package=is_package, + ) + if is_package: + spec.submodule_search_locations = ( + canonical_spec.submodule_search_locations + ) + return spec + + +def install(): + """Install the process-wide lazy aliases without importing business modules.""" + for finder in sys.meta_path: + if getattr(finder, _FINDER_MARKER, False): + return + sys.meta_path.insert(0, _AliasFinder()) + + +__all__ = ["install"] diff --git a/dashscope/legacy_1x/agentstudio/__init__.py b/dashscope/legacy_1x/agentstudio/__init__.py new file mode 100644 index 00000000..39a23e55 --- /dev/null +++ b/dashscope/legacy_1x/agentstudio/__init__.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The historical AgentStudio facade over the shared application core. + +The ``agentstudio`` package wraps the AgentStudio managed-agent HTTP +API. All resource classes are accessed through a client instance:: + + from dashscope.applications.agentstudio import Client + from dashscope.applications.agentstudio.types import ( + user_message, user_tool_result, + ) + +Example +------- +>>> from dashscope.applications.agentstudio import Client +>>> from dashscope.applications.agentstudio.types import user_message +>>> client = Client(api_key="sk-xxx") +>>> agent = client.agents.create(name="demo", model="qwen-plus") +>>> session = client.sessions.create(agent=agent.id) +>>> client.sessions.events.send(session.id, [user_message("Hello!")]) +>>> with client.sessions.events.stream(session.id) as stream: +... for event in stream: +... print(event.type, event.to_dict()) +... if event.type == "session_status": +... break +""" + +# yapf: disable + +from dashscope.legacy_1x.agentstudio.client import Client, AsyncClient +from dashscope.legacy_1x.agentstudio.transport import APIResponse, RequestOptions +from dashscope.applications.agentstudio import types +from dashscope.legacy_1x.agentstudio.exceptions import ( + AgentStudioError, + APIConnectionError, + APIStatusError, + APITimeoutError, + AuthenticationError, + ConflictError, + InternalServerError, + InvalidRequestError, + NotFoundError, + OverloadedError, + PermissionDeniedError, + RateLimitError, + StreamClosedError, + StreamError, +) +from dashscope.applications.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, +) +from dashscope.applications.agentstudio.types import ( + Memory, + MemoryStore, + MemoryVersion, + Message, + PermissionPolicy, + SecurityAlert, + SecurityAlertList, + SecurityOverview, + ServerEvent, + SessionResource, + StopReason, + user_define_outcome, + user_interrupt, + user_message, + user_tool_approval_response, + user_custom_tool_result, + user_tool_result, +) +from dashscope.applications.agentstudio.types.params import ( + AgentCreateParams, + AgentUpdateParams, + SessionCreateParams, + SessionUpdateParams, + EnvironmentCreateParams, + EnvironmentUpdateParams, + DeploymentCreateParams, + DeploymentUpdateParams, + SessionEventSendParams, + WebhookEndpointCreateParams, + WebhookEndpointUpdateParams, + WebhookEventListParams, +) +from dashscope.applications.agentstudio.constants import ( + PermissionPolicyType, + SSEEventType, + SessionStatus, + StopReasonType, + WebhookDeliveryStatus, + WebhookDisabledReason, + WebhookEventType, + WebhookStatus, +) + +__all__ = [ + # client + "Client", + "AsyncClient", + # base types + "RequestOptions", + "APIResponse", + # exceptions + "AgentStudioError", + "APIConnectionError", + "APIStatusError", + "APITimeoutError", + "AuthenticationError", + "ConflictError", + "InternalServerError", + "InvalidRequestError", + "NotFoundError", + "OverloadedError", + "PermissionDeniedError", + "RateLimitError", + "StreamError", + "StreamClosedError", + # pagination + "CursorPage", + "AsyncCursorPage", + # unified message type + "Memory", + "MemoryStore", + "MemoryVersion", + "Message", + "PermissionPolicy", + "SecurityAlert", + "SecurityAlertList", + "SecurityOverview", + "ServerEvent", + "SessionResource", + "StopReason", + # client event helpers (re-exported for convenience) + "user_message", + "user_interrupt", + "user_tool_approval_response", + "user_custom_tool_result", + "user_tool_result", + "user_define_outcome", + # typing module + "types", + # params (commonly used request-body types) + "AgentCreateParams", + "AgentUpdateParams", + "SessionCreateParams", + "SessionUpdateParams", + "EnvironmentCreateParams", + "EnvironmentUpdateParams", + "DeploymentCreateParams", + "DeploymentUpdateParams", + "SessionEventSendParams", + "WebhookEndpointCreateParams", + "WebhookEndpointUpdateParams", + "WebhookEventListParams", + "WebhookStatus", + "WebhookDisabledReason", + "WebhookDeliveryStatus", + "WebhookEventType", + "SSEEventType", + "SessionStatus", + "StopReasonType", + "PermissionPolicyType", +] diff --git a/dashscope/legacy_1x/agentstudio/client.py b/dashscope/legacy_1x/agentstudio/client.py new file mode 100644 index 00000000..1ed4ae51 --- /dev/null +++ b/dashscope/legacy_1x/agentstudio/client.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The historical AgentStudio Client facade. + +Business resources and HTTP mechanics live in ``dashscope.applications``; +this facade preserves 1.x credential resolution and exception classes. +""" + +from __future__ import annotations + +from typing import Optional, Tuple, Union + +import httpx + +from dashscope.applications.agentstudio.client import ( # noqa: F401 + AsyncClient as _CoreAsyncClient, +) +from dashscope.applications.agentstudio.client import Client as _CoreClient +from dashscope.applications.agentstudio import client as _core_client +from dashscope.applications.agentstudio.constants import ( + AGENTSTUDIO_MAX_RETRIES, +) +from dashscope.legacy_1x.agentstudio import exceptions +from dashscope.legacy_1x.agentstudio.streaming import ( + AsyncEventStream, + EventStream, +) +from dashscope.legacy_1x.agentstudio.transport import ( + AsyncTransport, + SyncTransport, +) +from dashscope.legacy_1x.common.api_key import get_default_api_key + +# Historical module-level helpers remain importable from this facade. +# pylint: disable=protected-access +_resolve_base_url = _core_client._resolve_base_url +_user_agent = _core_client._user_agent + + +class Client(_CoreClient): + """1.x AgentStudio client backed by the shared application core.""" + + def __init__( + self, + *, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + base_url: Optional[str] = None, + uid: Optional[str] = None, + timeout: Optional[ + Union[float, httpx.Timeout, Tuple[float, float]] + ] = None, + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.Client] = None, + ) -> None: + super().__init__( + api_key=api_key, + workspace=workspace, + region=region, + base_url=base_url, + uid=uid, + timeout=timeout, + max_retries=max_retries, + http_client=http_client, + _exceptions_module=exceptions, + _api_key_resolver=get_default_api_key, + _transport_cls=SyncTransport, + _event_stream_cls=EventStream, + ) + + +class AsyncClient(_CoreAsyncClient): + """Async 1.x AgentStudio client backed by the shared core.""" + + def __init__( + self, + *, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + region: Optional[str] = None, + base_url: Optional[str] = None, + uid: Optional[str] = None, + timeout: Optional[ + Union[float, httpx.Timeout, Tuple[float, float]] + ] = None, + max_retries: int = AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.AsyncClient] = None, + ) -> None: + super().__init__( + api_key=api_key, + workspace=workspace, + region=region, + base_url=base_url, + uid=uid, + timeout=timeout, + max_retries=max_retries, + http_client=http_client, + _exceptions_module=exceptions, + _api_key_resolver=get_default_api_key, + _transport_cls=AsyncTransport, + _event_stream_cls=AsyncEventStream, + ) + + +__all__ = ["AsyncClient", "Client"] diff --git a/dashscope/agentstudio/exceptions.py b/dashscope/legacy_1x/agentstudio/exceptions.py similarity index 100% rename from dashscope/agentstudio/exceptions.py rename to dashscope/legacy_1x/agentstudio/exceptions.py diff --git a/dashscope/legacy_1x/agentstudio/streaming.py b/dashscope/legacy_1x/agentstudio/streaming.py new file mode 100644 index 00000000..8e2d00a0 --- /dev/null +++ b/dashscope/legacy_1x/agentstudio/streaming.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Historical AgentStudio stream facades over the shared SSE core.""" + +from typing import Optional + +import httpx + +from dashscope.applications.agentstudio.streaming import ( + AsyncEventStream as _CoreAsyncEventStream, +) +from dashscope.applications.agentstudio.streaming import ( + EventStream as _CoreEventStream, +) +from dashscope.legacy_1x.agentstudio import exceptions + + +class EventStream(_CoreEventStream): + def __init__( + self, + response: httpx.Response, + idle_timeout: Optional[float] = 120.0, + exceptions_module=exceptions, + ) -> None: + super().__init__( + response=response, + idle_timeout=idle_timeout, + exceptions_module=exceptions, + ) + + +class AsyncEventStream(_CoreAsyncEventStream): + def __init__( + self, + response: httpx.Response, + idle_timeout: Optional[float] = 120.0, + exceptions_module=exceptions, + ) -> None: + super().__init__( + response=response, + idle_timeout=idle_timeout, + exceptions_module=exceptions, + ) + + +__all__ = ["AsyncEventStream", "EventStream"] diff --git a/dashscope/legacy_1x/agentstudio/transport.py b/dashscope/legacy_1x/agentstudio/transport.py new file mode 100644 index 00000000..ca4f6433 --- /dev/null +++ b/dashscope/legacy_1x/agentstudio/transport.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The 1.x AgentStudio transport facade over the shared implementation.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Tuple, Union + +import httpx + +from dashscope.applications.agentstudio import transport as _core +from dashscope.legacy_1x.agentstudio import exceptions + +APIResponse = _core.APIResponse +RequestOptions = _core.RequestOptions +unwrap = _core.unwrap +is_error_payload = _core.is_error_payload + + +def build_headers( + *, + api_key: Optional[str], + uid: Optional[str], + user_agent: str, + extra: Optional[Mapping[str, str]] = None, + json_body: bool = True, +): + """Build headers while preserving the 1.x AuthenticationError type.""" + return _core.build_headers( + api_key=api_key, + uid=uid, + user_agent=user_agent, + extra=extra, + json_body=json_body, + _exceptions_module=exceptions, + ) + + +class SyncTransport(_core.SyncTransport): + """1.x transport facade; HTTP implementation remains single-sourced.""" + + def __init__( + self, + *, + base_url: str, + api_key: Optional[str], + workspace: Optional[str], + uid: Optional[str], + user_agent: str, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], + max_retries: int = _core.AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.Client] = None, + exceptions_module: Any = exceptions, + ) -> None: + super().__init__( + base_url=base_url, + api_key=api_key, + workspace=workspace, + uid=uid, + user_agent=user_agent, + timeout=timeout, + max_retries=max_retries, + http_client=http_client, + exceptions_module=exceptions, + ) + + +class AsyncTransport(_core.AsyncTransport): + """Async 1.x transport facade over the shared core.""" + + def __init__( + self, + *, + base_url: str, + api_key: Optional[str], + workspace: Optional[str], + uid: Optional[str], + user_agent: str, + timeout: Union[float, httpx.Timeout, Tuple[float, float], None], + max_retries: int = _core.AGENTSTUDIO_MAX_RETRIES, + http_client: Optional[httpx.AsyncClient] = None, + exceptions_module: Any = exceptions, + ) -> None: + super().__init__( + base_url=base_url, + api_key=api_key, + workspace=workspace, + uid=uid, + user_agent=user_agent, + timeout=timeout, + max_retries=max_retries, + http_client=http_client, + exceptions_module=exceptions, + ) + + +__all__ = [ + "APIResponse", + "AsyncTransport", + "RequestOptions", + "SyncTransport", + "build_headers", + "is_error_payload", + "unwrap", +] diff --git a/dashscope/aigc/__init__.py b/dashscope/legacy_1x/aigc/__init__.py similarity index 100% rename from dashscope/aigc/__init__.py rename to dashscope/legacy_1x/aigc/__init__.py diff --git a/dashscope/aigc/chat_completion.py b/dashscope/legacy_1x/aigc/chat_completion.py similarity index 97% rename from dashscope/aigc/chat_completion.py rename to dashscope/legacy_1x/aigc/chat_completion.py index 2ec2c512..99957e65 100644 --- a/dashscope/aigc/chat_completion.py +++ b/dashscope/legacy_1x/aigc/chat_completion.py @@ -5,18 +5,18 @@ from typing import Any, Dict, Generator, List, Union import dashscope -from dashscope.aigc.generation import Generation -from dashscope.api_entities.chat_completion_types import ( +from dashscope.legacy_1x.aigc.generation import Generation +from dashscope.legacy_1x.api_entities.chat_completion_types import ( ChatCompletion, ChatCompletionChunk, ) -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( GenerationResponse, Message, ) -from dashscope.client.base_api import BaseAioApi, CreateMixin -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.client.base_api import BaseAioApi, CreateMixin +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task class Completions(CreateMixin): diff --git a/dashscope/aigc/code_generation.py b/dashscope/legacy_1x/aigc/code_generation.py similarity index 97% rename from dashscope/aigc/code_generation.py rename to dashscope/legacy_1x/aigc/code_generation.py index 4dbf422e..281c72b6 100644 --- a/dashscope/aigc/code_generation.py +++ b/dashscope/legacy_1x/aigc/code_generation.py @@ -3,15 +3,15 @@ from typing import Generator, List, Union -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, DictMixin, Role, ) -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import MESSAGE, SCENE -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import MESSAGE, SCENE +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task class MessageParam(DictMixin): diff --git a/dashscope/aigc/conversation.py b/dashscope/legacy_1x/aigc/conversation.py similarity index 97% rename from dashscope/aigc/conversation.py rename to dashscope/legacy_1x/aigc/conversation.py index 65a2f856..1f937f3f 100644 --- a/dashscope/aigc/conversation.py +++ b/dashscope/legacy_1x/aigc/conversation.py @@ -6,16 +6,24 @@ from http import HTTPStatus from typing import Any, Dict, Generator, List, Union -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( ConversationResponse, Message, Role, ) -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import DEPRECATED_MESSAGE, HISTORY, PROMPT -from dashscope.common.error import InputRequired, InvalidInput, ModelRequired -from dashscope.common.logging import logger -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import ( + DEPRECATED_MESSAGE, + HISTORY, + PROMPT, +) +from dashscope.legacy_1x.common.error import ( + InputRequired, + InvalidInput, + ModelRequired, +) +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import _get_task_group_and_task class HistoryItem(dict): diff --git a/dashscope/aigc/generation.py b/dashscope/legacy_1x/aigc/generation.py similarity index 97% rename from dashscope/aigc/generation.py rename to dashscope/legacy_1x/aigc/generation.py index 0c6f0341..0f80f85c 100644 --- a/dashscope/aigc/generation.py +++ b/dashscope/legacy_1x/aigc/generation.py @@ -5,24 +5,24 @@ import json from typing import Any, Dict, Generator, List, Union, AsyncGenerator -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( GenerationResponse, Message, Role, ) -from dashscope.client.base_api import BaseAioApi, BaseApi -from dashscope.common.constants import ( +from dashscope.legacy_1x.client.base_api import BaseAioApi, BaseApi +from dashscope.legacy_1x.common.constants import ( CUSTOMIZED_MODEL_ID, DEPRECATED_MESSAGE, HISTORY, MESSAGES, PROMPT, ) -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.logging import logger -from dashscope.common.utils import _get_task_group_and_task -from dashscope.utils.param_utils import ParamUtil -from dashscope.utils.message_utils import merge_single_response +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.utils.param_utils import ParamUtil +from dashscope.legacy_1x.utils.message_utils import merge_single_response class Generation(BaseApi): diff --git a/dashscope/aigc/image_generation.py b/dashscope/legacy_1x/aigc/image_generation.py similarity index 98% rename from dashscope/aigc/image_generation.py rename to dashscope/legacy_1x/aigc/image_generation.py index b8304957..8d896bae 100644 --- a/dashscope/aigc/image_generation.py +++ b/dashscope/legacy_1x/aigc/image_generation.py @@ -3,22 +3,22 @@ from typing import Generator, List, Union, AsyncGenerator -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( Message, DashScopeAPIResponse, ImageGenerationResponse, ) -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( BaseAioApi, BaseApi, BaseAsyncApi, BaseAsyncAioApi, ) -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.utils import _get_task_group_and_task -from dashscope.utils.oss_utils import preprocess_message_element -from dashscope.utils.param_utils import ParamUtil -from dashscope.utils.message_utils import merge_single_response +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.utils.oss_utils import preprocess_message_element +from dashscope.legacy_1x.utils.param_utils import ParamUtil +from dashscope.legacy_1x.utils.message_utils import merge_single_response class ImageGeneration(BaseApi, BaseAsyncApi): diff --git a/dashscope/aigc/image_synthesis.py b/dashscope/legacy_1x/aigc/image_synthesis.py similarity index 99% rename from dashscope/aigc/image_synthesis.py rename to dashscope/legacy_1x/aigc/image_synthesis.py index eb25276a..fd56c717 100644 --- a/dashscope/aigc/image_synthesis.py +++ b/dashscope/legacy_1x/aigc/image_synthesis.py @@ -3,20 +3,24 @@ from typing import Any, Dict, List, Union -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, ImageSynthesisResponse, ) -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( BaseAsyncApi, BaseApi, BaseAsyncAioApi, BaseAioApi, ) -from dashscope.common.constants import IMAGES, NEGATIVE_PROMPT, PROMPT -from dashscope.common.error import InputRequired -from dashscope.common.utils import _get_task_group_and_task -from dashscope.utils.oss_utils import check_and_upload_local +from dashscope.legacy_1x.common.constants import ( + IMAGES, + NEGATIVE_PROMPT, + PROMPT, +) +from dashscope.legacy_1x.common.error import InputRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.utils.oss_utils import check_and_upload_local class ImageSynthesis(BaseAsyncApi): diff --git a/dashscope/aigc/multimodal_conversation.py b/dashscope/legacy_1x/aigc/multimodal_conversation.py similarity index 97% rename from dashscope/aigc/multimodal_conversation.py rename to dashscope/legacy_1x/aigc/multimodal_conversation.py index c3d900b7..67cf0e7c 100644 --- a/dashscope/aigc/multimodal_conversation.py +++ b/dashscope/legacy_1x/aigc/multimodal_conversation.py @@ -4,15 +4,17 @@ import copy from typing import Any, AsyncGenerator, Dict, Generator, List, Union -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( MultiModalConversationResponse, ) -from dashscope.client.base_api import BaseAioApi, BaseApi -from dashscope.common.error import ModelRequired -from dashscope.common.utils import _get_task_group_and_task -from dashscope.utils.oss_utils import preprocess_message_element -from dashscope.utils.param_utils import ParamUtil -from dashscope.utils.message_utils import merge_multimodal_single_response +from dashscope.legacy_1x.client.base_api import BaseAioApi, BaseApi +from dashscope.legacy_1x.common.error import ModelRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.utils.oss_utils import preprocess_message_element +from dashscope.legacy_1x.utils.param_utils import ParamUtil +from dashscope.legacy_1x.utils.message_utils import ( + merge_multimodal_single_response, +) class MultiModalConversation(BaseApi): diff --git a/dashscope/aigc/video_synthesis.py b/dashscope/legacy_1x/aigc/video_synthesis.py similarity index 99% rename from dashscope/aigc/video_synthesis.py rename to dashscope/legacy_1x/aigc/video_synthesis.py index 0c46257e..12c34384 100644 --- a/dashscope/aigc/video_synthesis.py +++ b/dashscope/legacy_1x/aigc/video_synthesis.py @@ -5,19 +5,19 @@ from concurrent.futures import ThreadPoolExecutor, as_completed -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, VideoSynthesisResponse, ) -from dashscope.client.base_api import BaseAsyncApi, BaseAsyncAioApi -from dashscope.common.constants import ( +from dashscope.legacy_1x.client.base_api import BaseAsyncApi, BaseAsyncAioApi +from dashscope.legacy_1x.common.constants import ( PROMPT, REFERENCE_VIDEO_URLS, REFERENCE_URLS, MEDIA_URLS, ) -from dashscope.common.utils import _get_task_group_and_task -from dashscope.utils.oss_utils import check_and_upload_local +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.utils.oss_utils import check_and_upload_local class VideoSynthesis(BaseAsyncApi): diff --git a/dashscope/finetune/reinforcement/component/server/__init__.py b/dashscope/legacy_1x/api_entities/__init__.py similarity index 100% rename from dashscope/finetune/reinforcement/component/server/__init__.py rename to dashscope/legacy_1x/api_entities/__init__.py diff --git a/dashscope/api_entities/aio_session.py b/dashscope/legacy_1x/api_entities/aio_session.py similarity index 98% rename from dashscope/api_entities/aio_session.py rename to dashscope/legacy_1x/api_entities/aio_session.py index 2de1c46e..7edd7d74 100644 --- a/dashscope/api_entities/aio_session.py +++ b/dashscope/legacy_1x/api_entities/aio_session.py @@ -16,7 +16,7 @@ import aiohttp import certifi -from dashscope.common.logging import logger +from dashscope.legacy_1x.common.logging import logger _shared_ssl_context: Optional[ssl.SSLContext] = None _aio_sessions: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary() diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/legacy_1x/api_entities/aiohttp_request.py similarity index 96% rename from dashscope/api_entities/aiohttp_request.py rename to dashscope/legacy_1x/api_entities/aiohttp_request.py index 611e72ec..e2784627 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/legacy_1x/api_entities/aiohttp_request.py @@ -7,20 +7,22 @@ import aiohttp -from dashscope.api_entities.aio_session import ( +from dashscope.legacy_1x.api_entities.aio_session import ( get_shared_aio_session, send_with_retry_async, ) -from dashscope.api_entities.base_request import AioBaseRequest -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.common.constants import ( +from dashscope.legacy_1x.api_entities.base_request import AioBaseRequest +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.common.constants import ( DEFAULT_REQUEST_TIMEOUT_SECONDS, SSE_CONTENT_TYPE, HTTPMethod, ) -from dashscope.common.error import UnsupportedHTTPMethod -from dashscope.common.logging import logger -from dashscope.common.utils import async_to_sync +from dashscope.legacy_1x.common.error import UnsupportedHTTPMethod +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import async_to_sync class AioHttpRequest(AioBaseRequest): diff --git a/dashscope/api_entities/api_request_data.py b/dashscope/legacy_1x/api_entities/api_request_data.py similarity index 97% rename from dashscope/api_entities/api_request_data.py rename to dashscope/legacy_1x/api_entities/api_request_data.py index c6ea54ab..4b839103 100644 --- a/dashscope/api_entities/api_request_data.py +++ b/dashscope/legacy_1x/api_entities/api_request_data.py @@ -7,8 +7,8 @@ import aiohttp -from dashscope.common.constants import ApiProtocol -from dashscope.io.input_output import InputResolver +from dashscope.legacy_1x.common.constants import ApiProtocol +from dashscope.legacy_1x.io.input_output import InputResolver class ApiRequestData: diff --git a/dashscope/api_entities/api_request_factory.py b/dashscope/legacy_1x/api_entities/api_request_factory.py similarity index 92% rename from dashscope/api_entities/api_request_factory.py rename to dashscope/legacy_1x/api_entities/api_request_factory.py index 6511c29c..1ff1fc83 100644 --- a/dashscope/api_entities/api_request_factory.py +++ b/dashscope/legacy_1x/api_entities/api_request_factory.py @@ -7,20 +7,23 @@ import requests import dashscope -from dashscope.api_entities.api_request_data import ApiRequestData -from dashscope.api_entities.encryption import Encryption -from dashscope.api_entities.http_request import HttpRequest -from dashscope.api_entities.websocket_request import WebSocketRequest -from dashscope.common.constants import ( +from dashscope.legacy_1x.api_entities.api_request_data import ApiRequestData +from dashscope.legacy_1x.api_entities.encryption import Encryption +from dashscope.legacy_1x.api_entities.http_request import HttpRequest +from dashscope.legacy_1x.api_entities.websocket_request import WebSocketRequest +from dashscope.legacy_1x.common.constants import ( SERVICE_API_PATH, ApiProtocol, HTTPMethod, ) -from dashscope.common.error import InputDataRequired, UnsupportedApiProtocol -from dashscope.common.logging import logger -from dashscope.common.utils import get_sdk_headers -from dashscope.common.env import resolve_base_url -from dashscope.protocol.websocket import WebsocketStreamingMode +from dashscope.legacy_1x.common.error import ( + InputDataRequired, + UnsupportedApiProtocol, +) +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import get_sdk_headers +from dashscope.legacy_1x.common.env import resolve_base_url +from dashscope.legacy_1x.protocol.websocket import WebsocketStreamingMode def _build_api_request( # pylint: disable=too-many-branches diff --git a/dashscope/api_entities/base_request.py b/dashscope/legacy_1x/api_entities/base_request.py similarity index 83% rename from dashscope/api_entities/base_request.py rename to dashscope/legacy_1x/api_entities/base_request.py index 1a9a664f..eb3b9847 100644 --- a/dashscope/api_entities/base_request.py +++ b/dashscope/legacy_1x/api_entities/base_request.py @@ -4,8 +4,10 @@ import os from abc import ABC, abstractmethod -from dashscope.common.constants import DASHSCOPE_DISABLE_DATA_INSPECTION_ENV -from dashscope.common.utils import get_sdk_headers, get_user_agent +from dashscope.legacy_1x.common.constants import ( + DASHSCOPE_DISABLE_DATA_INSPECTION_ENV, +) +from dashscope.legacy_1x.common.utils import get_sdk_headers, get_user_agent class BaseRequest(ABC): diff --git a/dashscope/api_entities/chat_completion_types.py b/dashscope/legacy_1x/api_entities/chat_completion_types.py similarity index 99% rename from dashscope/api_entities/chat_completion_types.py rename to dashscope/legacy_1x/api_entities/chat_completion_types.py index 5d8ac2f9..d2ace56a 100644 --- a/dashscope/api_entities/chat_completion_types.py +++ b/dashscope/legacy_1x/api_entities/chat_completion_types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import List, Literal, Optional -from dashscope.common.base_type import BaseObjectMixin +from dashscope.legacy_1x.common.base_type import BaseObjectMixin @dataclass(init=False) diff --git a/dashscope/api_entities/dashscope_response.py b/dashscope/legacy_1x/api_entities/dashscope_response.py similarity index 100% rename from dashscope/api_entities/dashscope_response.py rename to dashscope/legacy_1x/api_entities/dashscope_response.py diff --git a/dashscope/api_entities/encryption.py b/dashscope/legacy_1x/api_entities/encryption.py similarity index 94% rename from dashscope/api_entities/encryption.py rename to dashscope/legacy_1x/api_entities/encryption.py index a40dc2bf..deac4759 100644 --- a/dashscope/api_entities/encryption.py +++ b/dashscope/legacy_1x/api_entities/encryption.py @@ -13,15 +13,15 @@ from cryptography.hazmat.backends import default_backend import dashscope -from dashscope.common.constants import ( +from dashscope.legacy_1x.common.constants import ( ENCRYPTION_AES_SECRET_KEY_BYTES, ENCRYPTION_AES_IV_LENGTH, DEFAULT_REQUEST_TIMEOUT_SECONDS, ) -from dashscope.common.logging import logger -from dashscope.common.env import resolve_base_url -from dashscope.common.api_key import get_default_api_key -from dashscope.common.utils import get_sdk_headers +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.env import resolve_base_url +from dashscope.legacy_1x.common.api_key import get_default_api_key +from dashscope.legacy_1x.common.utils import get_sdk_headers, get_user_agent class Encryption: @@ -100,8 +100,9 @@ def _get_public_keys(self): api_key = self._api_key or get_default_api_key() url = resolve_base_url(base_url) + "/public-keys/latest" headers = { - "Authorization": f"Bearer {api_key}", + "user-agent": get_user_agent(), **get_sdk_headers(module="utils"), + "Authorization": f"Bearer {api_key}", } response = requests.get( diff --git a/dashscope/api_entities/http_request.py b/dashscope/legacy_1x/api_entities/http_request.py similarity index 97% rename from dashscope/api_entities/http_request.py rename to dashscope/legacy_1x/api_entities/http_request.py index 96ee9ea8..4f283625 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/legacy_1x/api_entities/http_request.py @@ -12,26 +12,28 @@ import requests from requests.adapters import HTTPAdapter -from dashscope.api_entities.aio_session import ( +from dashscope.legacy_1x.api_entities.aio_session import ( get_shared_aio_session, send_with_retry_async, ) -from dashscope.api_entities.base_request import AioBaseRequest -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.common.constants import ( +from dashscope.legacy_1x.api_entities.base_request import AioBaseRequest +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.common.constants import ( DEFAULT_REQUEST_TIMEOUT_SECONDS, SSE_CONTENT_TYPE, HTTPMethod, ) -from dashscope.common.error import UnsupportedHTTPMethod -from dashscope.common.logging import logger -from dashscope.common.utils import ( +from dashscope.legacy_1x.common.error import UnsupportedHTTPMethod +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import ( _handle_aio_stream, _handle_aiohttp_failed_response, _handle_http_failed_response, _handle_stream, ) -from dashscope.api_entities.encryption import Encryption +from dashscope.legacy_1x.api_entities.encryption import Encryption # Shared synchronous session for connection pooling _shared_sync_session: Optional[requests.Session] = None diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/legacy_1x/api_entities/websocket_request.py similarity index 97% rename from dashscope/api_entities/websocket_request.py rename to dashscope/legacy_1x/api_entities/websocket_request.py index 2358b4ec..7e285aa8 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/legacy_1x/api_entities/websocket_request.py @@ -9,21 +9,23 @@ import aiohttp -from dashscope.api_entities.base_request import AioBaseRequest -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.common.constants import ( +from dashscope.legacy_1x.api_entities.base_request import AioBaseRequest +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.common.constants import ( DEFAULT_REQUEST_TIMEOUT_SECONDS, SERVICE_503_MESSAGE, WEBSOCKET_ERROR_CODE, ) -from dashscope.common.error import ( +from dashscope.legacy_1x.common.error import ( RequestFailure, UnexpectedMessageReceived, UnknownMessageReceived, ) -from dashscope.common.logging import logger -from dashscope.common.utils import async_to_sync -from dashscope.protocol.websocket import ( +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import async_to_sync +from dashscope.legacy_1x.protocol.websocket import ( ACTION_KEY, ERROR_MESSAGE, ERROR_NAME, diff --git a/dashscope/app/__init__.py b/dashscope/legacy_1x/app/__init__.py similarity index 100% rename from dashscope/app/__init__.py rename to dashscope/legacy_1x/app/__init__.py diff --git a/dashscope/app/application.py b/dashscope/legacy_1x/app/application.py similarity index 95% rename from dashscope/app/application.py rename to dashscope/legacy_1x/app/application.py index 6afb72cf..90d70fa3 100644 --- a/dashscope/app/application.py +++ b/dashscope/legacy_1x/app/application.py @@ -8,20 +8,22 @@ import copy from typing import Any, Dict, Generator, List, Optional, Union -from dashscope.api_entities.api_request_factory import _build_api_request -from dashscope.api_entities.dashscope_response import Message, Role -from dashscope.app.application_response import ApplicationResponse -from dashscope.client.base_api import BaseApi -from dashscope.common.api_key import get_default_api_key -from dashscope.common.constants import ( +from dashscope.legacy_1x.api_entities.api_request_factory import ( + _build_api_request, +) +from dashscope.legacy_1x.api_entities.dashscope_response import Message, Role +from dashscope.legacy_1x.app.application_response import ApplicationResponse +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.api_key import get_default_api_key +from dashscope.legacy_1x.common.constants import ( DEPRECATED_MESSAGE, HISTORY, MESSAGES, PROMPT, ) -from dashscope.common.error import InputRequired, InvalidInput -from dashscope.common.logging import logger -from dashscope.utils.message_utils import merge_single_response +from dashscope.legacy_1x.common.error import InputRequired, InvalidInput +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.utils.message_utils import merge_single_response class Application(BaseApi): diff --git a/dashscope/app/application_response.py b/dashscope/legacy_1x/app/application_response.py similarity index 99% rename from dashscope/app/application_response.py rename to dashscope/legacy_1x/app/application_response.py index 19af1668..976d7150 100644 --- a/dashscope/app/application_response.py +++ b/dashscope/legacy_1x/app/application_response.py @@ -9,7 +9,7 @@ from http import HTTPStatus from typing import Dict, List -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, DictMixin, ) diff --git a/dashscope/assistants/__init__.py b/dashscope/legacy_1x/assistants/__init__.py similarity index 84% rename from dashscope/assistants/__init__.py rename to dashscope/legacy_1x/assistants/__init__.py index 2cc9f714..9c63d153 100644 --- a/dashscope/assistants/__init__.py +++ b/dashscope/legacy_1x/assistants/__init__.py @@ -5,12 +5,12 @@ # yapf: disable -from dashscope.assistants.assistant_types import ( +from dashscope.legacy_1x.assistants.assistant_types import ( Assistant, AssistantFile, AssistantList, DeleteResponse, ) -from dashscope.assistants.assistants import Assistants +from dashscope.legacy_1x.assistants.assistants import Assistants __all__ = [ 'Assistant', diff --git a/dashscope/assistants/assistant_types.py b/dashscope/legacy_1x/assistants/assistant_types.py similarity index 98% rename from dashscope/assistants/assistant_types.py rename to dashscope/legacy_1x/assistants/assistant_types.py index 624c4c8e..71691752 100644 --- a/dashscope/assistants/assistant_types.py +++ b/dashscope/legacy_1x/assistants/assistant_types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Dict, List, Optional, Union -from dashscope.common.base_type import BaseList, BaseObjectMixin +from dashscope.legacy_1x.common.base_type import BaseList, BaseObjectMixin __all__ = [ "Assistant", diff --git a/dashscope/assistants/assistants.py b/dashscope/legacy_1x/assistants/assistants.py similarity index 98% rename from dashscope/assistants/assistants.py rename to dashscope/legacy_1x/assistants/assistants.py index 5342f785..2036050e 100644 --- a/dashscope/assistants/assistants.py +++ b/dashscope/legacy_1x/assistants/assistants.py @@ -4,12 +4,12 @@ import warnings from typing import Dict, List, Optional -from dashscope.assistants.assistant_types import ( +from dashscope.legacy_1x.assistants.assistant_types import ( Assistant, AssistantList, DeleteResponse, ) -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( CancelMixin, CreateMixin, DeleteMixin, @@ -17,13 +17,13 @@ ListObjectMixin, UpdateMixin, ) -from dashscope.common.error import ModelRequired +from dashscope.legacy_1x.common.error import ModelRequired __all__ = ["Assistants"] # Deprecation warning message _DEPRECATION_MSG = ( - "The Assistants API (dashscope.assistants) is deprecated and will be " + "The Assistants API (dashscope.legacy_1x.assistants) is deprecated and will be " "removed in a future release. Please migrate to the Responses API. " "See https://help.aliyun.com/zh/model-studio/" "synchronous-call-api-reference for migration details." diff --git a/dashscope/assistants/files.py b/dashscope/legacy_1x/assistants/files.py similarity index 96% rename from dashscope/assistants/files.py rename to dashscope/legacy_1x/assistants/files.py index 6e118001..6cc21c88 100644 --- a/dashscope/assistants/files.py +++ b/dashscope/legacy_1x/assistants/files.py @@ -3,24 +3,24 @@ import warnings from typing import Optional -from dashscope.assistants.assistant_types import ( +from dashscope.legacy_1x.assistants.assistant_types import ( AssistantFile, AssistantFileList, DeleteResponse, ) -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( CreateMixin, DeleteMixin, GetStatusMixin, ListObjectMixin, ) -from dashscope.common.error import InputRequired +from dashscope.legacy_1x.common.error import InputRequired __all__ = ["Files"] # Deprecation warning message _DEPRECATION_MSG = ( - "The Assistants API (dashscope.assistants) is deprecated and will be " + "The Assistants API (dashscope.legacy_1x.assistants) is deprecated and will be " "removed in a future release. Please migrate to the Responses API. " "See https://help.aliyun.com/zh/model-studio/" "synchronous-call-api-reference for migration details." diff --git a/dashscope/audio/__init__.py b/dashscope/legacy_1x/audio/__init__.py similarity index 100% rename from dashscope/audio/__init__.py rename to dashscope/legacy_1x/audio/__init__.py diff --git a/dashscope/audio/asr/__init__.py b/dashscope/legacy_1x/audio/asr/__init__.py similarity index 100% rename from dashscope/audio/asr/__init__.py rename to dashscope/legacy_1x/audio/asr/__init__.py diff --git a/dashscope/audio/asr/asr_phrase_manager.py b/dashscope/legacy_1x/audio/asr/asr_phrase_manager.py similarity index 95% rename from dashscope/audio/asr/asr_phrase_manager.py rename to dashscope/legacy_1x/audio/asr/asr_phrase_manager.py index c1dd1a5f..ab833bf1 100644 --- a/dashscope/audio/asr/asr_phrase_manager.py +++ b/dashscope/legacy_1x/audio/asr/asr_phrase_manager.py @@ -4,11 +4,13 @@ from http import HTTPStatus from typing import Any, Dict -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseAsyncApi -from dashscope.common.error import InvalidParameter -from dashscope.common.logging import logger -from dashscope.finetune.finetunes import FineTunes +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import BaseAsyncApi +from dashscope.legacy_1x.common.error import InvalidParameter +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.finetune.finetunes import FineTunes class AsrPhraseManager(BaseAsyncApi): diff --git a/dashscope/audio/asr/recognition.py b/dashscope/legacy_1x/audio/asr/recognition.py similarity index 98% rename from dashscope/audio/asr/recognition.py rename to dashscope/legacy_1x/audio/asr/recognition.py index b9bf978e..0967f549 100644 --- a/dashscope/audio/asr/recognition.py +++ b/dashscope/legacy_1x/audio/asr/recognition.py @@ -12,19 +12,21 @@ from threading import Timer from typing import Any, Dict, List, Optional, Union -from dashscope.api_entities.dashscope_response import RecognitionResponse -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import ApiProtocol -from dashscope.common.error import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( + RecognitionResponse, +) +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import ApiProtocol +from dashscope.legacy_1x.common.error import ( InputDataRequired, InputRequired, InvalidParameter, InvalidTask, ModelRequired, ) -from dashscope.common.logging import logger -from dashscope.common.utils import _get_task_group_and_task -from dashscope.protocol.websocket import WebsocketStreamingMode +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.protocol.websocket import WebsocketStreamingMode def _merge_recognition_params( diff --git a/dashscope/audio/asr/transcription.py b/dashscope/legacy_1x/audio/asr/transcription.py similarity index 96% rename from dashscope/audio/asr/transcription.py rename to dashscope/legacy_1x/audio/asr/transcription.py index 88631aca..609f6c11 100644 --- a/dashscope/audio/asr/transcription.py +++ b/dashscope/legacy_1x/audio/asr/transcription.py @@ -6,14 +6,14 @@ import requests -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, TranscriptionResponse, ) -from dashscope.client.base_api import BaseAsyncApi -from dashscope.common.constants import ApiProtocol, HTTPMethod -from dashscope.common.logging import logger -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.client.base_api import BaseAsyncApi +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import _get_task_group_and_task class Transcription(BaseAsyncApi): diff --git a/dashscope/audio/asr/translation_recognizer.py b/dashscope/legacy_1x/audio/asr/translation_recognizer.py similarity index 99% rename from dashscope/audio/asr/translation_recognizer.py rename to dashscope/legacy_1x/audio/asr/translation_recognizer.py index d24d277f..bd32fea9 100644 --- a/dashscope/audio/asr/translation_recognizer.py +++ b/dashscope/legacy_1x/audio/asr/translation_recognizer.py @@ -11,19 +11,19 @@ from threading import Timer from typing import Any, Dict, List, Optional -from dashscope.audio.asr.recognition import _merge_recognition_params -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import ApiProtocol -from dashscope.common.error import ( +from dashscope.legacy_1x.audio.asr.recognition import _merge_recognition_params +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import ApiProtocol +from dashscope.legacy_1x.common.error import ( InputDataRequired, InputRequired, InvalidParameter, InvalidTask, ModelRequired, ) -from dashscope.common.logging import logger -from dashscope.common.utils import _get_task_group_and_task -from dashscope.protocol.websocket import WebsocketStreamingMode +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.protocol.websocket import WebsocketStreamingMode DASHSCOPE_TRANSLATION_KEY = "translations" DASHSCOPE_TRANSCRIPTION_KEY = "transcription" diff --git a/dashscope/audio/asr/vocabulary.py b/dashscope/legacy_1x/audio/asr/vocabulary.py similarity index 97% rename from dashscope/audio/asr/vocabulary.py rename to dashscope/legacy_1x/audio/asr/vocabulary.py index 9e8510b4..9a3111ba 100644 --- a/dashscope/audio/asr/vocabulary.py +++ b/dashscope/legacy_1x/audio/asr/vocabulary.py @@ -6,9 +6,9 @@ import requests -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import ApiProtocol, HTTPMethod -from dashscope.common.logging import logger +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod +from dashscope.legacy_1x.common.logging import logger class VocabularyServiceException(Exception): diff --git a/dashscope/audio/http_tts/__init__.py b/dashscope/legacy_1x/audio/http_tts/__init__.py similarity index 74% rename from dashscope/audio/http_tts/__init__.py rename to dashscope/legacy_1x/audio/http_tts/__init__.py index 9d304d32..d0e451c2 100644 --- a/dashscope/audio/http_tts/__init__.py +++ b/dashscope/legacy_1x/audio/http_tts/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.audio.http_tts.http_speech_synthesizer import ( +from dashscope.legacy_1x.audio.http_tts.http_speech_synthesizer import ( HttpSpeechSynthesisResult, HttpSpeechSynthesizer, ) diff --git a/dashscope/audio/http_tts/http_speech_synthesizer.py b/dashscope/legacy_1x/audio/http_tts/http_speech_synthesizer.py similarity index 95% rename from dashscope/audio/http_tts/http_speech_synthesizer.py rename to dashscope/legacy_1x/audio/http_tts/http_speech_synthesizer.py index b2e3e84e..ca831955 100644 --- a/dashscope/audio/http_tts/http_speech_synthesizer.py +++ b/dashscope/legacy_1x/audio/http_tts/http_speech_synthesizer.py @@ -5,12 +5,12 @@ from http import HTTPStatus from typing import Dict, Iterator, List, Optional, Union -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, SpeechSynthesisResponse, ) -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import HTTPMethod +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import HTTPMethod class HttpSpeechSynthesisResult: @@ -199,12 +199,12 @@ def _http_call( url: Optional[str] = None, ): """Make HTTP API call using BaseApi infrastructure.""" - from dashscope.api_entities.http_request import HttpRequest + from dashscope.legacy_1x.api_entities.http_request import HttpRequest # Get base URL import dashscope - from dashscope.common.env import resolve_base_url - from dashscope.common.utils import get_sdk_headers, join_url + from dashscope.legacy_1x.common.env import resolve_base_url + from dashscope.legacy_1x.common.utils import get_sdk_headers, join_url if not url: url = dashscope.base_http_api_url @@ -215,7 +215,7 @@ def _http_call( ) # Get API key - from dashscope.common.api_key import get_default_api_key + from dashscope.legacy_1x.common.api_key import get_default_api_key if api_key is None: api_key = get_default_api_key() diff --git a/dashscope/audio/qwen_asr/__init__.py b/dashscope/legacy_1x/audio/qwen_asr/__init__.py similarity index 100% rename from dashscope/audio/qwen_asr/__init__.py rename to dashscope/legacy_1x/audio/qwen_asr/__init__.py diff --git a/dashscope/audio/qwen_asr/qwen_transcription.py b/dashscope/legacy_1x/audio/qwen_asr/qwen_transcription.py similarity index 96% rename from dashscope/audio/qwen_asr/qwen_transcription.py rename to dashscope/legacy_1x/audio/qwen_asr/qwen_transcription.py index f731ff92..f32b5f78 100644 --- a/dashscope/audio/qwen_asr/qwen_transcription.py +++ b/dashscope/legacy_1x/audio/qwen_asr/qwen_transcription.py @@ -6,13 +6,13 @@ import requests -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, TranscriptionResponse, ) -from dashscope.client.base_api import BaseAsyncApi -from dashscope.common.constants import ApiProtocol, HTTPMethod -from dashscope.common.logging import logger +from dashscope.legacy_1x.client.base_api import BaseAsyncApi +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod +from dashscope.legacy_1x.common.logging import logger class QwenTranscription(BaseAsyncApi): diff --git a/dashscope/audio/qwen_omni/__init__.py b/dashscope/legacy_1x/audio/qwen_omni/__init__.py similarity index 100% rename from dashscope/audio/qwen_omni/__init__.py rename to dashscope/legacy_1x/audio/qwen_omni/__init__.py diff --git a/dashscope/audio/qwen_omni/omni_realtime.py b/dashscope/legacy_1x/audio/qwen_omni/omni_realtime.py similarity index 99% rename from dashscope/audio/qwen_omni/omni_realtime.py rename to dashscope/legacy_1x/audio/qwen_omni/omni_realtime.py index 8f5251a2..ddef0037 100644 --- a/dashscope/audio/qwen_omni/omni_realtime.py +++ b/dashscope/legacy_1x/audio/qwen_omni/omni_realtime.py @@ -12,9 +12,9 @@ import websocket # pylint: disable=wrong-import-order import dashscope -from dashscope.common.error import ModelRequired -from dashscope.common.logging import logger -from dashscope.common.utils import get_sdk_headers, get_user_agent +from dashscope.legacy_1x.common.error import ModelRequired +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import get_sdk_headers, get_user_agent class OmniRealtimeCallback: diff --git a/dashscope/audio/qwen_tts/__init__.py b/dashscope/legacy_1x/audio/qwen_tts/__init__.py similarity index 100% rename from dashscope/audio/qwen_tts/__init__.py rename to dashscope/legacy_1x/audio/qwen_tts/__init__.py diff --git a/dashscope/audio/qwen_tts/speech_synthesizer.py b/dashscope/legacy_1x/audio/qwen_tts/speech_synthesizer.py similarity index 92% rename from dashscope/audio/qwen_tts/speech_synthesizer.py rename to dashscope/legacy_1x/audio/qwen_tts/speech_synthesizer.py index f15f7c89..423b3598 100644 --- a/dashscope/audio/qwen_tts/speech_synthesizer.py +++ b/dashscope/legacy_1x/audio/qwen_tts/speech_synthesizer.py @@ -3,9 +3,11 @@ from typing import Generator, Union -from dashscope.api_entities.dashscope_response import TextToSpeechResponse -from dashscope.client.base_api import BaseApi -from dashscope.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.api_entities.dashscope_response import ( + TextToSpeechResponse, +) +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired class SpeechSynthesizer(BaseApi): diff --git a/dashscope/audio/qwen_tts_realtime/__init__.py b/dashscope/legacy_1x/audio/qwen_tts_realtime/__init__.py similarity index 100% rename from dashscope/audio/qwen_tts_realtime/__init__.py rename to dashscope/legacy_1x/audio/qwen_tts_realtime/__init__.py diff --git a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py b/dashscope/legacy_1x/audio/qwen_tts_realtime/qwen_tts_realtime.py similarity index 98% rename from dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py rename to dashscope/legacy_1x/audio/qwen_tts_realtime/qwen_tts_realtime.py index 31eb0380..48c67856 100644 --- a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py +++ b/dashscope/legacy_1x/audio/qwen_tts_realtime/qwen_tts_realtime.py @@ -10,9 +10,9 @@ import websocket # pylint: disable=wrong-import-order import dashscope -from dashscope.common.error import ModelRequired -from dashscope.common.logging import logger -from dashscope.common.utils import get_sdk_headers, get_user_agent +from dashscope.legacy_1x.common.error import ModelRequired +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import get_sdk_headers, get_user_agent class QwenTtsRealtimeCallback: diff --git a/dashscope/audio/tts/__init__.py b/dashscope/legacy_1x/audio/tts/__init__.py similarity index 100% rename from dashscope/audio/tts/__init__.py rename to dashscope/legacy_1x/audio/tts/__init__.py diff --git a/dashscope/audio/tts/speech_synthesizer.py b/dashscope/legacy_1x/audio/tts/speech_synthesizer.py similarity index 96% rename from dashscope/audio/tts/speech_synthesizer.py rename to dashscope/legacy_1x/audio/tts/speech_synthesizer.py index 0381abe8..c3328681 100644 --- a/dashscope/audio/tts/speech_synthesizer.py +++ b/dashscope/legacy_1x/audio/tts/speech_synthesizer.py @@ -4,10 +4,12 @@ from http import HTTPStatus from typing import Dict, List -from dashscope.api_entities.dashscope_response import SpeechSynthesisResponse -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import ApiProtocol -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.api_entities.dashscope_response import ( + SpeechSynthesisResponse, +) +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import ApiProtocol +from dashscope.legacy_1x.common.utils import _get_task_group_and_task class SpeechSynthesisResult: diff --git a/dashscope/audio/tts_v2/__init__.py b/dashscope/legacy_1x/audio/tts_v2/__init__.py similarity index 100% rename from dashscope/audio/tts_v2/__init__.py rename to dashscope/legacy_1x/audio/tts_v2/__init__.py diff --git a/dashscope/audio/tts_v2/enrollment.py b/dashscope/legacy_1x/audio/tts_v2/enrollment.py similarity index 97% rename from dashscope/audio/tts_v2/enrollment.py rename to dashscope/legacy_1x/audio/tts_v2/enrollment.py index 239606a1..073f7086 100644 --- a/dashscope/audio/tts_v2/enrollment.py +++ b/dashscope/legacy_1x/audio/tts_v2/enrollment.py @@ -6,9 +6,9 @@ import requests -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import ApiProtocol, HTTPMethod -from dashscope.common.logging import logger +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod +from dashscope.legacy_1x.common.logging import logger class VoiceEnrollmentException(Exception): diff --git a/dashscope/audio/tts_v2/speech_synthesizer.py b/dashscope/legacy_1x/audio/tts_v2/speech_synthesizer.py similarity index 99% rename from dashscope/audio/tts_v2/speech_synthesizer.py rename to dashscope/legacy_1x/audio/tts_v2/speech_synthesizer.py index eea32e3f..a96b132f 100644 --- a/dashscope/audio/tts_v2/speech_synthesizer.py +++ b/dashscope/legacy_1x/audio/tts_v2/speech_synthesizer.py @@ -14,17 +14,17 @@ import websocket import dashscope -from dashscope.common.constants import WEBSOCKET_ERROR_CODE -from dashscope.common.env import resolve_base_url -from dashscope.common.error import ( +from dashscope.legacy_1x.common.constants import WEBSOCKET_ERROR_CODE +from dashscope.legacy_1x.common.env import resolve_base_url +from dashscope.legacy_1x.common.error import ( InputRequired, InvalidTask, ModelRequired, RequestFailure, ) -from dashscope.common.logging import logger -from dashscope.common.utils import get_sdk_headers, get_user_agent -from dashscope.protocol.websocket import ( +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import get_sdk_headers, get_user_agent +from dashscope.legacy_1x.protocol.websocket import ( ACTION_KEY, ERROR_MESSAGE, ERROR_NAME, diff --git a/dashscope/cli/__init__.py b/dashscope/legacy_1x/cli/__init__.py similarity index 98% rename from dashscope/cli/__init__.py rename to dashscope/legacy_1x/cli/__init__.py index 244d227b..b72c1dde 100644 --- a/dashscope/cli/__init__.py +++ b/dashscope/legacy_1x/cli/__init__.py @@ -21,10 +21,10 @@ from rich.markup import escape # noqa: E402 import dashscope # noqa: E402 -from dashscope.cli.common import console, err_console # noqa: E402 -from dashscope.common.error import AuthenticationError # noqa: E402 -from dashscope.common.utils import set_sdk_client # noqa: E402 -from dashscope.cli import ( # noqa: E402 +from dashscope.legacy_1x.cli.common import console, err_console # noqa: E402 +from dashscope.legacy_1x.common.error import AuthenticationError # noqa: E402 +from dashscope.legacy_1x.common.utils import set_sdk_client # noqa: E402 +from dashscope.legacy_1x.cli import ( # noqa: E402 application, auth, deployments, @@ -470,7 +470,7 @@ def _register_rl_app(): global _RL_IMPORT_ERROR # pylint: disable=global-statement try: - from dashscope.cli.agentic_rl import app as rl_app + from dashscope.legacy_1x.cli.agentic_rl import app as rl_app app.add_typer( rl_app, diff --git a/dashscope/cli/__main__.py b/dashscope/legacy_1x/cli/__main__.py similarity index 70% rename from dashscope/cli/__main__.py rename to dashscope/legacy_1x/cli/__main__.py index 9f75714a..f4406ab1 100644 --- a/dashscope/cli/__main__.py +++ b/dashscope/legacy_1x/cli/__main__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # pylint: disable=wrong-import-position -"""Allow ``python -m dashscope.cli`` invocation.""" +"""Allow ``python -m dashscope.legacy_1x.cli`` invocation.""" import warnings # Suppress urllib3 NotOpenSSLWarning on systems with LibreSSL @@ -11,7 +11,7 @@ ) # fmt: off -from dashscope.cli import main # noqa: E402 +from dashscope.legacy_1x.cli import main # noqa: E402 # fmt: on main() diff --git a/dashscope/cli/agentic_rl.py b/dashscope/legacy_1x/cli/agentic_rl.py similarity index 99% rename from dashscope/cli/agentic_rl.py rename to dashscope/legacy_1x/cli/agentic_rl.py index f850f718..827c86ac 100644 --- a/dashscope/cli/agentic_rl.py +++ b/dashscope/legacy_1x/cli/agentic_rl.py @@ -17,14 +17,13 @@ from rich.progress import Progress, SpinnerColumn, TextColumn from rich.table import Table -from dashscope.finetune.agentic_rl import AgenticRL +from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL, SubmitResult from dashscope.finetune.reinforcement import ( serialize_for_output, FunctionType, ) from dashscope.finetune.reinforcement.common.errors import OutputError -from dashscope.finetune.customize_types import FineTune -from dashscope.cli.common import error, normalize_local_path_or_url +from dashscope.legacy_1x.cli.common import error, normalize_local_path_or_url app = typer.Typer( @@ -427,7 +426,7 @@ async def _run_workflow_async( config_path: Optional[str], api_key: Optional[str], run_kwargs: Dict[str, Any], -) -> FineTune: +) -> SubmitResult: """ Execute the RL tuning workflow asynchronously. diff --git a/dashscope/cli/application.py b/dashscope/legacy_1x/cli/application.py similarity index 96% rename from dashscope/cli/application.py rename to dashscope/legacy_1x/cli/application.py index a2ef3e72..3c834619 100644 --- a/dashscope/cli/application.py +++ b/dashscope/legacy_1x/cli/application.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import console, ensure_ok, handle_sdk_error +from dashscope.legacy_1x.cli.common import console, ensure_ok, handle_sdk_error app = typer.Typer( name="application", diff --git a/dashscope/cli/auth.py b/dashscope/legacy_1x/cli/auth.py similarity index 91% rename from dashscope/cli/auth.py rename to dashscope/legacy_1x/cli/auth.py index 89aff97c..f2144f8f 100644 --- a/dashscope/cli/auth.py +++ b/dashscope/legacy_1x/cli/auth.py @@ -6,10 +6,15 @@ import typer import dashscope -from dashscope.cli.common import console, err_console -from dashscope.common.api_key import get_default_api_key, save_api_key -from dashscope.common.constants import DEFAULT_DASHSCOPE_API_KEY_FILE_PATH -from dashscope.common.error import AuthenticationError +from dashscope.legacy_1x.cli.common import console, err_console +from dashscope.legacy_1x.common.api_key import ( + get_default_api_key, + save_api_key, +) +from dashscope.legacy_1x.common.constants import ( + DEFAULT_DASHSCOPE_API_KEY_FILE_PATH, +) +from dashscope.legacy_1x.common.error import AuthenticationError app = typer.Typer( name="auth", diff --git a/dashscope/cli/code_generation.py b/dashscope/legacy_1x/cli/code_generation.py similarity index 93% rename from dashscope/cli/code_generation.py rename to dashscope/legacy_1x/cli/code_generation.py index 414941fd..5366e451 100644 --- a/dashscope/cli/code_generation.py +++ b/dashscope/legacy_1x/cli/code_generation.py @@ -6,11 +6,16 @@ import typer import dashscope -from dashscope.aigc.code_generation import ( +from dashscope.legacy_1x.aigc.code_generation import ( AttachmentRoleMessageParam, UserRoleMessageParam, ) -from dashscope.cli.common import console, ensure_ok, error, handle_sdk_error +from dashscope.legacy_1x.cli.common import ( + console, + ensure_ok, + error, + handle_sdk_error, +) app = typer.Typer( name="code-generation", diff --git a/dashscope/cli/common.py b/dashscope/legacy_1x/cli/common.py similarity index 99% rename from dashscope/cli/common.py rename to dashscope/legacy_1x/cli/common.py index 68f0eea4..b05d0504 100644 --- a/dashscope/cli/common.py +++ b/dashscope/legacy_1x/cli/common.py @@ -10,7 +10,7 @@ import typer from rich.console import Console -logger = logging.getLogger("dashscope.cli") +logger = logging.getLogger("dashscope.legacy_1x.cli") CommandFunction = TypeVar("CommandFunction", bound=Callable) # --------------------------------------------------------------------------- diff --git a/dashscope/cli/deployments.py b/dashscope/legacy_1x/cli/deployments.py similarity index 98% rename from dashscope/cli/deployments.py rename to dashscope/legacy_1x/cli/deployments.py index 6fffe1ec..c3bb664e 100644 --- a/dashscope/cli/deployments.py +++ b/dashscope/legacy_1x/cli/deployments.py @@ -7,8 +7,8 @@ import typer import dashscope -from dashscope.common.constants import DeploymentStatus -from dashscope.cli.common import ( +from dashscope.legacy_1x.common.constants import DeploymentStatus +from dashscope.legacy_1x.cli.common import ( POLL_INTERVAL, console, err_console, diff --git a/dashscope/cli/embeddings.py b/dashscope/legacy_1x/cli/embeddings.py similarity index 96% rename from dashscope/cli/embeddings.py rename to dashscope/legacy_1x/cli/embeddings.py index ae61ccab..392614dc 100644 --- a/dashscope/cli/embeddings.py +++ b/dashscope/legacy_1x/cli/embeddings.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import console, ensure_ok, handle_sdk_error +from dashscope.legacy_1x.cli.common import console, ensure_ok, handle_sdk_error app = typer.Typer( name="embeddings", diff --git a/dashscope/cli/files.py b/dashscope/legacy_1x/cli/files.py similarity index 97% rename from dashscope/cli/files.py rename to dashscope/legacy_1x/cli/files.py index cb25a710..8b5f25aa 100644 --- a/dashscope/cli/files.py +++ b/dashscope/legacy_1x/cli/files.py @@ -7,8 +7,8 @@ import typer import dashscope -from dashscope.common.constants import FilePurpose -from dashscope.cli.common import ( +from dashscope.legacy_1x.common.constants import FilePurpose +from dashscope.legacy_1x.cli.common import ( console, ensure_ok, error, diff --git a/dashscope/cli/fine_tunes.py b/dashscope/legacy_1x/cli/fine_tunes.py similarity index 99% rename from dashscope/cli/fine_tunes.py rename to dashscope/legacy_1x/cli/fine_tunes.py index d5317527..2694b654 100644 --- a/dashscope/cli/fine_tunes.py +++ b/dashscope/legacy_1x/cli/fine_tunes.py @@ -7,8 +7,8 @@ import typer import dashscope -from dashscope.common.constants import TaskStatus -from dashscope.cli.common import ( +from dashscope.legacy_1x.common.constants import TaskStatus +from dashscope.legacy_1x.cli.common import ( LOG_PAGE_SIZE, POLL_INTERVAL, console, diff --git a/dashscope/cli/generation.py b/dashscope/legacy_1x/cli/generation.py similarity index 98% rename from dashscope/cli/generation.py rename to dashscope/legacy_1x/cli/generation.py index 2305dae7..9b7e6677 100644 --- a/dashscope/cli/generation.py +++ b/dashscope/legacy_1x/cli/generation.py @@ -7,8 +7,8 @@ import typer -from dashscope.aigc import Generation -from dashscope.cli.common import ( +from dashscope.legacy_1x.aigc import Generation +from dashscope.legacy_1x.cli.common import ( error, extract_text, handle_sdk_error, diff --git a/dashscope/cli/image_generation.py b/dashscope/legacy_1x/cli/image_generation.py similarity index 96% rename from dashscope/cli/image_generation.py rename to dashscope/legacy_1x/cli/image_generation.py index a2efb627..7ae850dc 100644 --- a/dashscope/cli/image_generation.py +++ b/dashscope/legacy_1x/cli/image_generation.py @@ -5,9 +5,9 @@ import typer -from dashscope.aigc.image_generation import ImageGeneration -from dashscope.api_entities.dashscope_response import Message, Role -from dashscope.cli.common import ( +from dashscope.legacy_1x.aigc.image_generation import ImageGeneration +from dashscope.legacy_1x.api_entities.dashscope_response import Message, Role +from dashscope.legacy_1x.cli.common import ( console, ensure_ok, handle_sdk_error, diff --git a/dashscope/cli/image_synthesis.py b/dashscope/legacy_1x/cli/image_synthesis.py similarity index 99% rename from dashscope/cli/image_synthesis.py rename to dashscope/legacy_1x/cli/image_synthesis.py index 63e393cb..37754ca1 100644 --- a/dashscope/cli/image_synthesis.py +++ b/dashscope/legacy_1x/cli/image_synthesis.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import ( +from dashscope.legacy_1x.cli.common import ( console, ensure_ok, handle_sdk_error, diff --git a/dashscope/cli/models.py b/dashscope/legacy_1x/cli/models.py similarity index 94% rename from dashscope/cli/models.py rename to dashscope/legacy_1x/cli/models.py index dc0dc747..f4bc96b8 100644 --- a/dashscope/cli/models.py +++ b/dashscope/legacy_1x/cli/models.py @@ -5,7 +5,7 @@ import typer import dashscope -from dashscope.cli.common import console, ensure_ok, handle_sdk_error +from dashscope.legacy_1x.cli.common import console, ensure_ok, handle_sdk_error app = typer.Typer( name="models", diff --git a/dashscope/cli/multimodal_conversation.py b/dashscope/legacy_1x/cli/multimodal_conversation.py similarity index 98% rename from dashscope/cli/multimodal_conversation.py rename to dashscope/legacy_1x/cli/multimodal_conversation.py index a1133993..1031db24 100644 --- a/dashscope/cli/multimodal_conversation.py +++ b/dashscope/legacy_1x/cli/multimodal_conversation.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import ( +from dashscope.legacy_1x.cli.common import ( console, ensure_ok, handle_sdk_error, diff --git a/dashscope/cli/multimodal_embedding.py b/dashscope/legacy_1x/cli/multimodal_embedding.py similarity index 98% rename from dashscope/cli/multimodal_embedding.py rename to dashscope/legacy_1x/cli/multimodal_embedding.py index ea4db7b8..c48299e5 100644 --- a/dashscope/cli/multimodal_embedding.py +++ b/dashscope/legacy_1x/cli/multimodal_embedding.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import ( +from dashscope.legacy_1x.cli.common import ( console, ensure_ok, error, diff --git a/dashscope/cli/oss.py b/dashscope/legacy_1x/cli/oss.py similarity index 92% rename from dashscope/cli/oss.py rename to dashscope/legacy_1x/cli/oss.py index 80bb815b..e6e52bf2 100644 --- a/dashscope/cli/oss.py +++ b/dashscope/legacy_1x/cli/oss.py @@ -6,8 +6,13 @@ import typer import dashscope -from dashscope.utils.oss_utils import OssUtils -from dashscope.cli.common import console, error, handle_sdk_error, success +from dashscope.legacy_1x.utils.oss_utils import OssUtils +from dashscope.legacy_1x.cli.common import ( + console, + error, + handle_sdk_error, + success, +) app = typer.Typer( name="oss", diff --git a/dashscope/cli/rerank.py b/dashscope/legacy_1x/cli/rerank.py similarity index 95% rename from dashscope/cli/rerank.py rename to dashscope/legacy_1x/cli/rerank.py index 07ea0307..f61f4814 100644 --- a/dashscope/cli/rerank.py +++ b/dashscope/legacy_1x/cli/rerank.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import console, ensure_ok, handle_sdk_error +from dashscope.legacy_1x.cli.common import console, ensure_ok, handle_sdk_error app = typer.Typer( name="rerank", diff --git a/dashscope/cli/speech_synthesis.py b/dashscope/legacy_1x/cli/speech_synthesis.py similarity index 98% rename from dashscope/cli/speech_synthesis.py rename to dashscope/legacy_1x/cli/speech_synthesis.py index 6978c394..0fba7bd2 100644 --- a/dashscope/cli/speech_synthesis.py +++ b/dashscope/legacy_1x/cli/speech_synthesis.py @@ -7,7 +7,7 @@ import typer import dashscope -from dashscope.cli.common import ( +from dashscope.legacy_1x.cli.common import ( console, error, handle_sdk_error, diff --git a/dashscope/cli/tokenization.py b/dashscope/legacy_1x/cli/tokenization.py similarity index 94% rename from dashscope/cli/tokenization.py rename to dashscope/legacy_1x/cli/tokenization.py index a869d01a..6e212fa7 100644 --- a/dashscope/cli/tokenization.py +++ b/dashscope/legacy_1x/cli/tokenization.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import console, ensure_ok, handle_sdk_error +from dashscope.legacy_1x.cli.common import console, ensure_ok, handle_sdk_error app = typer.Typer( name="tokenization", diff --git a/dashscope/cli/transcription.py b/dashscope/legacy_1x/cli/transcription.py similarity index 99% rename from dashscope/cli/transcription.py rename to dashscope/legacy_1x/cli/transcription.py index af25d27e..098098a0 100644 --- a/dashscope/cli/transcription.py +++ b/dashscope/legacy_1x/cli/transcription.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import ( +from dashscope.legacy_1x.cli.common import ( console, ensure_ok, handle_sdk_error, diff --git a/dashscope/cli/understanding.py b/dashscope/legacy_1x/cli/understanding.py similarity index 95% rename from dashscope/cli/understanding.py rename to dashscope/legacy_1x/cli/understanding.py index 30fdfca2..f430e866 100644 --- a/dashscope/cli/understanding.py +++ b/dashscope/legacy_1x/cli/understanding.py @@ -6,7 +6,7 @@ import typer import dashscope -from dashscope.cli.common import console, ensure_ok, handle_sdk_error +from dashscope.legacy_1x.cli.common import console, ensure_ok, handle_sdk_error app = typer.Typer( name="understanding", diff --git a/dashscope/cli/video_synthesis.py b/dashscope/legacy_1x/cli/video_synthesis.py similarity index 98% rename from dashscope/cli/video_synthesis.py rename to dashscope/legacy_1x/cli/video_synthesis.py index 8c46492f..39455a50 100644 --- a/dashscope/cli/video_synthesis.py +++ b/dashscope/legacy_1x/cli/video_synthesis.py @@ -6,7 +6,12 @@ import typer import dashscope -from dashscope.cli.common import console, ensure_ok, handle_sdk_error, success +from dashscope.legacy_1x.cli.common import ( + console, + ensure_ok, + handle_sdk_error, + success, +) app = typer.Typer( name="video-synthesis", diff --git a/dashscope/finetune/reinforcement/examples/observability/__init__.py b/dashscope/legacy_1x/client/__init__.py similarity index 100% rename from dashscope/finetune/reinforcement/examples/observability/__init__.py rename to dashscope/legacy_1x/client/__init__.py diff --git a/dashscope/client/base_api.py b/dashscope/legacy_1x/client/base_api.py similarity index 98% rename from dashscope/client/base_api.py rename to dashscope/legacy_1x/client/base_api.py index 8ae18173..deb1ba81 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/legacy_1x/client/base_api.py @@ -10,10 +10,14 @@ import requests import dashscope -from dashscope.api_entities.api_request_factory import _build_api_request -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.common.api_key import get_default_api_key -from dashscope.common.constants import ( +from dashscope.legacy_1x.api_entities.api_request_factory import ( + _build_api_request, +) +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.common.api_key import get_default_api_key +from dashscope.legacy_1x.common.constants import ( DEFAULT_REQUEST_TIMEOUT_SECONDS, REPEATABLE_STATUS, REQUEST_TIMEOUT_KEYWORD, @@ -21,10 +25,14 @@ TaskStatus, HTTPMethod, ) -from dashscope.common.error import InvalidParameter, InvalidTask, ModelRequired -from dashscope.common.logging import logger -from dashscope.common.env import resolve_base_url -from dashscope.common.utils import ( +from dashscope.legacy_1x.common.error import ( + InvalidParameter, + InvalidTask, + ModelRequired, +) +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.env import resolve_base_url +from dashscope.legacy_1x.common.utils import ( _handle_http_failed_response, _handle_http_response, _handle_http_stream_response, @@ -411,7 +419,7 @@ async def list( message="", ) else: - from dashscope.common.utils import ( # pylint: disable=import-outside-toplevel # noqa: E501 + from dashscope.legacy_1x.common.utils import ( # pylint: disable=import-outside-toplevel # noqa: E501 _handle_aiohttp_failed_response, ) diff --git a/dashscope/finetune/reinforcement/examples/workspace/functions/__init__.py b/dashscope/legacy_1x/common/__init__.py similarity index 100% rename from dashscope/finetune/reinforcement/examples/workspace/functions/__init__.py rename to dashscope/legacy_1x/common/__init__.py diff --git a/dashscope/legacy_1x/common/_sdk_session.py b/dashscope/legacy_1x/common/_sdk_session.py new file mode 100644 index 00000000..148e269d --- /dev/null +++ b/dashscope/legacy_1x/common/_sdk_session.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Compatibility re-export; the implementation moved to ``dashscope._shared``. + +The session id state lives only in ``dashscope._shared._sdk_session`` so a +process keeps exactly one id. Import from there in new code. +""" + +from dashscope._shared._sdk_session import sdk_session_id + +__all__ = ["sdk_session_id"] diff --git a/dashscope/common/api_key.py b/dashscope/legacy_1x/common/api_key.py similarity index 95% rename from dashscope/common/api_key.py rename to dashscope/legacy_1x/common/api_key.py index d083e849..74bc785f 100644 --- a/dashscope/common/api_key.py +++ b/dashscope/legacy_1x/common/api_key.py @@ -5,11 +5,11 @@ from typing import Optional import dashscope -from dashscope.common.constants import ( +from dashscope.legacy_1x.common.constants import ( DEFAULT_DASHSCOPE_API_KEY_FILE_PATH, DEFAULT_DASHSCOPE_CACHE_PATH, ) -from dashscope.common.error import AuthenticationError +from dashscope.legacy_1x.common.error import AuthenticationError def get_default_api_key(): diff --git a/dashscope/common/base_type.py b/dashscope/legacy_1x/common/base_type.py similarity index 100% rename from dashscope/common/base_type.py rename to dashscope/legacy_1x/common/base_type.py diff --git a/dashscope/common/constants.py b/dashscope/legacy_1x/common/constants.py similarity index 100% rename from dashscope/common/constants.py rename to dashscope/legacy_1x/common/constants.py diff --git a/dashscope/common/env.py b/dashscope/legacy_1x/common/env.py similarity index 62% rename from dashscope/common/env.py rename to dashscope/legacy_1x/common/env.py index 330baca9..df8afa0c 100644 --- a/dashscope/common/env.py +++ b/dashscope/legacy_1x/common/env.py @@ -2,64 +2,21 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import os -import re -from dashscope.common.constants import ( +# Re-exported for callers that import them from here; ``validate_region`` +# is not used in this module itself. +# pylint: disable-next=unused-import +from dashscope._shared._validation import ( # noqa: F401 + validate_region, + validate_workspace_id, +) +from dashscope.legacy_1x.common.constants import ( DASHSCOPE_API_KEY_ENV, DASHSCOPE_API_KEY_FILE_PATH_ENV, DASHSCOPE_API_REGION_ENV, DASHSCOPE_API_VERSION_ENV, ) -from dashscope.common.error import InputRequired - -# workspace_id and region become DNS labels of the MaaS endpoint host, so -# they must not contain characters (/, ?, #, @, :, whitespace, ...) that -# could break out of the host and redirect requests elsewhere. -# \A...\Z rather than ^...$: $ also matches before a trailing newline. -_WORKSPACE_ID_PATTERN = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z") -# RFC 1123 hostname label: 1-63 chars, no leading/trailing hyphen. Upper -# case is allowed because DNS is case-insensitive and httpx lowercases the -# host, so rejecting it would break working callers for no security gain. -_REGION_PATTERN = re.compile( - r"\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\Z", -) - - -def validate_workspace_id(workspace_id): - """Validate that workspace_id is safe to use as a URL subdomain. - - Raises: - ValueError: If workspace_id contains characters outside - [A-Za-z0-9_-], is empty, or exceeds 64 characters. - """ - if not workspace_id or not _WORKSPACE_ID_PATTERN.fullmatch( - workspace_id, - ): - raise ValueError( - f"Invalid workspace_id {workspace_id!r}: must match " - "^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$", - ) - return workspace_id - - -def validate_region(region): - """Validate that region is safe to use as a URL subdomain. - - Deliberately a hostname-label check and not a MAAS_REGIONS whitelist: - rejecting an unknown-but-well-formed region would break callers as soon - as a subpackage supports a region this list has not caught up with. - - Raises: - ValueError: If region is not a valid hostname label. - """ - if not region or not _REGION_PATTERN.fullmatch(region): - raise ValueError( - f"Invalid region {region!r}: must be a hostname label " - "(1-63 chars of [A-Za-z0-9-], not starting or ending " - "with '-')", - ) - return region - +from dashscope.legacy_1x.common.error import InputRequired # MaaS regions: region -> URL subdomain identifier # cn-beijing is deliberately excluded: it is the default diff --git a/dashscope/common/error.py b/dashscope/legacy_1x/common/error.py similarity index 92% rename from dashscope/common/error.py rename to dashscope/legacy_1x/common/error.py index 2b49cc71..aca70b6a 100644 --- a/dashscope/common/error.py +++ b/dashscope/legacy_1x/common/error.py @@ -2,8 +2,9 @@ # Copyright (c) Alibaba, Inc. and its affiliates. -class DashScopeException(Exception): - pass +# One shared base preserves broad ``except DashScopeException`` handlers while +# every concrete 1.x error below remains the unchanged historical class. +from dashscope._shared._errors import DashScopeException class AuthenticationError(DashScopeException): diff --git a/dashscope/common/logging.py b/dashscope/legacy_1x/common/logging.py similarity index 93% rename from dashscope/common/logging.py rename to dashscope/legacy_1x/common/logging.py index a5ca4abb..f345e053 100644 --- a/dashscope/common/logging.py +++ b/dashscope/legacy_1x/common/logging.py @@ -4,7 +4,7 @@ import logging import os -from dashscope.common.constants import DASHSCOPE_LOGGING_LEVEL_ENV +from dashscope.legacy_1x.common.constants import DASHSCOPE_LOGGING_LEVEL_ENV logger = logging.getLogger("dashscope") diff --git a/dashscope/common/message_manager.py b/dashscope/legacy_1x/common/message_manager.py similarity index 95% rename from dashscope/common/message_manager.py rename to dashscope/legacy_1x/common/message_manager.py index 9b546b9e..1fc34617 100644 --- a/dashscope/common/message_manager.py +++ b/dashscope/legacy_1x/common/message_manager.py @@ -4,7 +4,7 @@ from collections import deque from typing import List -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( ConversationResponse, GenerationResponse, Message, diff --git a/dashscope/common/utils.py b/dashscope/legacy_1x/common/utils.py similarity index 88% rename from dashscope/common/utils.py rename to dashscope/legacy_1x/common/utils.py index bb9229ba..732e0971 100644 --- a/dashscope/common/utils.py +++ b/dashscope/legacy_1x/common/utils.py @@ -4,7 +4,6 @@ import asyncio import json import os -import platform import queue import threading from dataclasses import dataclass @@ -15,17 +14,23 @@ import aiohttp 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__ - -# 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() +# The x-dashscope-sdk-client state (including the CLI's ``python-cli`` +# override), the request-identity builders and the session id live in the +# neutral layer so both stacks share one copy; re-exported here for +# callers that import them from this module. +# pylint: disable-next=unused-import +from dashscope._shared._sdk_ident import ( # noqa: F401 + _SDK_SESSION_ID, + get_sdk_headers, + get_user_agent, + set_sdk_client, +) +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.common.api_key import get_default_api_key +from dashscope.legacy_1x.common.constants import SSE_CONTENT_TYPE +from dashscope.legacy_1x.common.logging import logger def is_validate_fine_tune_file(file_path): @@ -143,31 +148,6 @@ def async_to_sync(async_generator): yield message -def get_user_agent(): - try: - platform_ = ( - platform.platform().replace("\n", "").replace("\r", "").strip() - ) - except Exception: - platform_ = "unknown" - - try: - processor_ = ( - platform.processor().replace("\n", "").replace("\r", "").strip() - ) - except Exception: - processor_ = "unknown" - - ua = ( - f"dashscope/{__version__}; python/{platform.python_version()}; " - f"platform/{platform_}; " - f"processor/{processor_}" - ) - return ua - - -_SDK_CLIENT = "python-sdk" - # Package name -> sdk module tag, for packages whose directory name does # not match the module segment (threads/messages/runs/steps belong to the # assistants module). @@ -179,46 +159,21 @@ def get_user_agent(): def get_api_module(module_name: str) -> str: """Derive the sdk module tag from an API class's __module__. - e.g. "dashscope.aigc.generation" -> "aigc", - "dashscope.threads.runs.runs" -> "assistants". + e.g. "dashscope.legacy_1x.aigc.generation" -> "aigc", + "dashscope.legacy_1x.threads.runs.runs" -> "assistants". """ parts = module_name.split(".") if len(parts) < 2 or parts[0] != "dashscope": return "" - pkg = parts[1] + # The 1.x tree now lives under ``dashscope.legacy_1x``; the logical API + # package is the segment after that container, not ``legacy_1x`` itself. + idx = 2 if parts[1] == "legacy_1x" else 1 + if len(parts) <= idx: + return "" + pkg = parts[idx] return _SDK_MODULE_PACKAGE_OVERRIDES.get(pkg, pkg) -def set_sdk_client(client: str) -> None: - """Override the client identifier in the x-dashscope-sdk-client header. - - Process-wide, used by entry points that embed the SDK (e.g. the - ``dashscope`` CLI marks itself as ``python-cli``). - """ - global _SDK_CLIENT # pylint: disable=global-statement - if client: - _SDK_CLIENT = client - - -def get_sdk_headers(module: str = "") -> Dict[str, str]: - """Structured SDK identification headers for backend statistics. - - Set DASHSCOPE_DISABLE_SDK_HEADERS=1 to omit them. - - Args: - module: Optional module name (e.g. agentstudio, rl, app). - """ - if os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): - return {} - parts = [_SDK_CLIENT, __version__] - if module: - parts.append(module) - return { - "x-dashscope-sdk-client": "/".join(parts), - "x-dashscope-sdk-session-id": _SDK_SESSION_ID, - } - - def default_headers(api_key: str = None, module: str = "") -> Dict[str, str]: ua = get_user_agent() headers = {"user-agent": ua} diff --git a/dashscope/embeddings/__init__.py b/dashscope/legacy_1x/embeddings/__init__.py similarity index 100% rename from dashscope/embeddings/__init__.py rename to dashscope/legacy_1x/embeddings/__init__.py diff --git a/dashscope/embeddings/batch_text_embedding.py b/dashscope/legacy_1x/embeddings/batch_text_embedding.py similarity index 95% rename from dashscope/embeddings/batch_text_embedding.py rename to dashscope/legacy_1x/embeddings/batch_text_embedding.py index c2c9b9cb..d2ef1832 100644 --- a/dashscope/embeddings/batch_text_embedding.py +++ b/dashscope/legacy_1x/embeddings/batch_text_embedding.py @@ -3,11 +3,13 @@ from typing import Union -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseAsyncApi -from dashscope.common.error import InputRequired -from dashscope.common.utils import _get_task_group_and_task -from dashscope.embeddings.batch_text_embedding_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import BaseAsyncApi +from dashscope.legacy_1x.common.error import InputRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.embeddings.batch_text_embedding_response import ( BatchTextEmbeddingResponse, ) diff --git a/dashscope/embeddings/batch_text_embedding_response.py b/dashscope/legacy_1x/embeddings/batch_text_embedding_response.py similarity index 96% rename from dashscope/embeddings/batch_text_embedding_response.py rename to dashscope/legacy_1x/embeddings/batch_text_embedding_response.py index 405ee102..762653d9 100644 --- a/dashscope/embeddings/batch_text_embedding_response.py +++ b/dashscope/legacy_1x/embeddings/batch_text_embedding_response.py @@ -5,7 +5,7 @@ from attr import dataclass -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, DictMixin, ) diff --git a/dashscope/embeddings/multimodal_embedding.py b/dashscope/legacy_1x/embeddings/multimodal_embedding.py similarity index 96% rename from dashscope/embeddings/multimodal_embedding.py rename to dashscope/legacy_1x/embeddings/multimodal_embedding.py index d68c13f5..846a31bd 100644 --- a/dashscope/embeddings/multimodal_embedding.py +++ b/dashscope/legacy_1x/embeddings/multimodal_embedding.py @@ -4,14 +4,14 @@ from dataclasses import dataclass from typing import List -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, DictMixin, ) -from dashscope.client.base_api import BaseApi, BaseAioApi -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.utils import _get_task_group_and_task -from dashscope.utils.oss_utils import preprocess_message_element +from dashscope.legacy_1x.client.base_api import BaseApi, BaseAioApi +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.utils.oss_utils import preprocess_message_element @dataclass(init=False) diff --git a/dashscope/embeddings/text_embedding.py b/dashscope/legacy_1x/embeddings/text_embedding.py similarity index 90% rename from dashscope/embeddings/text_embedding.py rename to dashscope/legacy_1x/embeddings/text_embedding.py index b388bad2..9835e194 100644 --- a/dashscope/embeddings/text_embedding.py +++ b/dashscope/legacy_1x/embeddings/text_embedding.py @@ -3,10 +3,12 @@ from typing import List, Union -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import TEXT_EMBEDDING_INPUT_KEY -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import TEXT_EMBEDDING_INPUT_KEY +from dashscope.legacy_1x.common.utils import _get_task_group_and_task class TextEmbedding(BaseApi): diff --git a/dashscope/files.py b/dashscope/legacy_1x/files.py similarity index 92% rename from dashscope/files.py rename to dashscope/legacy_1x/files.py index b62d3da8..c3e908c8 100644 --- a/dashscope/files.py +++ b/dashscope/legacy_1x/files.py @@ -4,16 +4,18 @@ import os from typing import Optional -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import ( DeleteMixin, FileUploadMixin, GetMixin, ListMixin, ) -from dashscope.common.constants import FilePurpose -from dashscope.common.error import InvalidFileFormat -from dashscope.common.utils import is_validate_fine_tune_file +from dashscope.legacy_1x.common.constants import FilePurpose +from dashscope.legacy_1x.common.error import InvalidFileFormat +from dashscope.legacy_1x.common.utils import is_validate_fine_tune_file class Files(FileUploadMixin, ListMixin, DeleteMixin, GetMixin): diff --git a/dashscope/io/__init__.py b/dashscope/legacy_1x/finetune/__init__.py similarity index 100% rename from dashscope/io/__init__.py rename to dashscope/legacy_1x/finetune/__init__.py diff --git a/dashscope/legacy_1x/finetune/agentic_rl.py b/dashscope/legacy_1x/finetune/agentic_rl.py new file mode 100644 index 00000000..74c0f15b --- /dev/null +++ b/dashscope/legacy_1x/finetune/agentic_rl.py @@ -0,0 +1,326 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +# pylint: disable=unused-import +"""Compatibility shim plus the 1.x surface; the implementation moved to +``dashscope.applications.finetune.agentic_rl``. + +The agentic-RL workflow is owned by the 2.0 SDK, while the ``dashscope`` +CLI and acli skill docs use this 1.x import path. + +What stays here is the half of the surface that belongs to 1.x -- the +five job-management verbs, which delegate to ``dashscope.FineTunes`` (a +1.x root public API, alongside ``Deployments``), and the ``FineTune`` +dataclasses they return. ``submit_job`` therefore returns a ``FineTune`` +from this path and a ``SubmitResult`` from the 2.0 one; the payload and +the POST are built once, in the 2.0 class. + +New 2.0 code reaches the workflow through ``client.rl``. +""" + +import functools +import inspect +from typing import Dict, List, Optional, Union, cast + +from dashscope.legacy_1x.finetune.customize_types import ( + FineTune, + FineTuneCancel, + FineTuneDelete, + FineTuneList, +) +from dashscope.legacy_1x.finetune.finetunes import FineTunes +from dashscope.applications.finetune.agentic_rl import ( + AgenticRL as _AgenticRLBase, +) +from dashscope.applications.finetune.agentic_rl import ( + SubmitOutput, + SubmitResult, +) + +# Re-export every name this module historically carried. +# pylint: disable-next=unused-import +from dashscope.applications.finetune.reinforcement import ( # noqa: F401 + DASHSCOPE_HTTP_BASE_URL, + AgenticRLFunctionComponent, + AgenticRLTuning, + Dataset, + DatasetsType, + FunctionType, + GroupRewardInput, + RewardFunctionComponent, + RewardInput, + RolloutFunctionComponent, + RolloutInput, + TrainingDataset, + TrainingType, + TuningModel, + ValidationDataset, + deep_remove_none, + generate_random_id, + get_func_type_id, + logger, + set_api_key, +) + +# pylint: disable-next=unused-import +from dashscope.applications.finetune.reinforcement.common.errors import ( + AgenticRLError as _CoreAgenticRLError, +) +from dashscope.legacy_1x.finetune.reinforcement.common import ( + errors as _legacy_errors, +) +from dashscope.legacy_1x.finetune.reinforcement.common.errors import ( # noqa: F401 + AgenticRLError, + DatasetsError, + InstanceQueryError, + RegistrationError, + RuntimeErrorWithCode, + ValidationError, + ValueErrorWithCode, +) + + +def _to_legacy_error(exc: _CoreAgenticRLError) -> AgenticRLError: + """Rehydrate a core v2 error as the historical 1.x class. + + Bypassing ``__init__`` avoids applying the old message prefixes twice; + the shared core already populated the complete compatibility attributes. + """ + legacy_cls = getattr( + _legacy_errors, + type(exc).__name__, + AgenticRLError, + ) + if not isinstance(legacy_cls, type) or not issubclass( + legacy_cls, + BaseException, + ): + legacy_cls = AgenticRLError + legacy = legacy_cls.__new__(legacy_cls) + Exception.__init__(legacy, *exc.args) + legacy.__dict__.update(exc.__dict__) + legacy.message = getattr(exc, "message", str(exc)) + legacy.error_code = getattr(exc, "error_code", 1000) + return cast(AgenticRLError, legacy) + + +def _translate_sync(method): + @functools.wraps(method) + def wrapped(*args, **kwargs): + try: + return method(*args, **kwargs) + except _CoreAgenticRLError as exc: + raise _to_legacy_error(exc) from exc + + return wrapped + + +def _translate_async(method): + @functools.wraps(method) + async def wrapped(*args, **kwargs): + try: + return await method(*args, **kwargs) + except _CoreAgenticRLError as exc: + raise _to_legacy_error(exc) from exc + + return wrapped + + +__all__ = [ + "AgenticRL", + "DASHSCOPE_HTTP_BASE_URL", + "FineTune", + "FineTuneCancel", + "FineTuneDelete", + "FineTuneList", + "FineTunes", + "SubmitOutput", + "SubmitResult", +] + + +class AgenticRL(_AgenticRLBase): + """The 1.x surface: ``FineTune`` returns plus the ``FineTunes`` verbs. + + The five verbs below are 1.x-only. ``Client(...).rl.jobs`` already + implements the same lifecycle natively on the 2.0 transport, so + duplicating them into the 2.0 class would have given the SDK two + copies of every job verb. + """ + + def __init__(self, api_key: str = None): + # Keep the historical patch point and old error type. The core class + # uses the same AgenticRLTuning implementation, so no business logic + # is duplicated here. + super(_AgenticRLBase, self).__init__() + try: + set_api_key(api_key) + except Exception as exc: + raise ValueErrorWithCode( + "Invalid API key configuration", + error_code=3001, + ) from exc + self._api_key = api_key + + def submit_job( # type: ignore[override] + self, + model: Optional[str] = None, + datasets: Optional[List[Dataset]] = None, + functions: Optional[ + Union[ + List[ + Union[ + RolloutFunctionComponent, + RewardFunctionComponent, + AgenticRLFunctionComponent, + ] + ], + RolloutFunctionComponent, + RewardFunctionComponent, + AgenticRLFunctionComponent, + ] + ] = None, + hyper_parameters: Optional[Dict[str, str]] = None, + resources: Optional[Dict[str, str]] = None, + job_name: Optional[str] = None, + teacher_model: Optional[str] = None, + **kwargs, + ) -> FineTune: + """ + Submit RL tuning job to the platform. + """ + return FineTune( + **self._submit_envelope( + model=model, + datasets=datasets, + functions=functions, + hyper_parameters=hyper_parameters, + resources=resources, + job_name=job_name, + teacher_model=teacher_model, + **kwargs, + ), + ) + + @classmethod + def cancel( + cls, + job_id: str, + api_key: str = None, + workspace: str = None, + **kwargs, + ) -> FineTuneCancel: + """Cancel a running fine-tune job.""" + kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL + + return FineTunes.cancel( + job_id, + api_key=api_key, + workspace=workspace, + **kwargs, + ) + + @classmethod + def list( + cls, + page_no=1, + page_size=10, + api_key: str = None, + workspace: str = None, + **kwargs, + ) -> FineTuneList: + """List fine-tune jobs.""" + kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL + + return FineTunes.list( + page_no=page_no, + page_size=page_size, + api_key=api_key, + workspace=workspace, + **kwargs, + ) + + @classmethod + def get( + cls, + job_id: str, + api_key: str = None, + workspace: str = None, + **kwargs, + ) -> FineTune: + """Get fine-tune job information.""" + kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL + + return FineTunes.get( + job_id, + api_key=api_key, + workspace=workspace, + **kwargs, + ) + + @classmethod + def delete( + cls, + job_id: str, + api_key: str = None, + workspace: str = None, + **kwargs, + ) -> FineTuneDelete: + """Delete a fine-tune job.""" + kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL + + return FineTunes.delete( + job_id, + api_key=api_key, + workspace=workspace, + **kwargs, + ) + + @classmethod + def logs( + cls, + job_id: str, + offset: int = 1, + lines: int = 1000, + api_key: str = None, + workspace: str = None, + **kwargs, + ) -> FineTune: + """Get job logs.""" + kwargs["base_address"] = DASHSCOPE_HTTP_BASE_URL + + return FineTunes.logs( + job_id, + offset=offset, + line=lines, + api_key=api_key, + workspace=workspace, + **kwargs, + ) + + +# Every workflow entry point crosses the facade boundary here. The shared core +# raises the 2.0 family; the historical class translates it back to the exact +# 1.x exception class before it reaches user code. functools.wraps preserves +# the published signatures for inspect/help tooling. +for _method_name in ("init", "register_functions", "upload_datasets", "run"): + _method = getattr(_AgenticRLBase, _method_name) + _wrapper = ( + _translate_async(_method) + if inspect.iscoroutinefunction(_method) + else _translate_sync(_method) + ) + setattr(AgenticRL, _method_name, _wrapper) + +AgenticRL.submit_job = _translate_sync(AgenticRL.submit_job) + +for _method_name in ( + "test_functions", + "query_function_instance_logs", + "query_all_function_instance_logs", + "delete_function_instance", +): + _descriptor = _AgenticRLBase.__dict__[_method_name] + _method = _descriptor.__func__ + setattr(AgenticRL, _method_name, classmethod(_translate_async(_method))) + +del _descriptor, _method, _method_name, _wrapper diff --git a/dashscope/finetune/customize_types.py b/dashscope/legacy_1x/finetune/customize_types.py similarity index 98% rename from dashscope/finetune/customize_types.py rename to dashscope/legacy_1x/finetune/customize_types.py index 1fa101a8..53fa4de7 100644 --- a/dashscope/finetune/customize_types.py +++ b/dashscope/legacy_1x/finetune/customize_types.py @@ -5,7 +5,7 @@ from http import HTTPStatus from typing import Dict, List, Optional -from dashscope.common.base_type import BaseObjectMixin +from dashscope.legacy_1x.common.base_type import BaseObjectMixin __all__ = ["Deployment", "FineTune", "DeploymentList", "FineTuneList"] diff --git a/dashscope/finetune/deployments.py b/dashscope/legacy_1x/finetune/deployments.py similarity index 98% rename from dashscope/finetune/deployments.py rename to dashscope/legacy_1x/finetune/deployments.py index 4bfb916b..dcf0eff3 100644 --- a/dashscope/finetune/deployments.py +++ b/dashscope/legacy_1x/finetune/deployments.py @@ -2,7 +2,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. from typing import Optional -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( CreateMixin, DeleteMixin, GetMixin, @@ -10,7 +10,7 @@ PutMixin, StreamEventMixin, ) -from dashscope.finetune.customize_types import ( +from dashscope.legacy_1x.finetune.customize_types import ( Deployment, DeploymentDelete, DeploymentList, diff --git a/dashscope/finetune/finetunes.py b/dashscope/legacy_1x/finetune/finetunes.py similarity index 98% rename from dashscope/finetune/finetunes.py rename to dashscope/legacy_1x/finetune/finetunes.py index 8f061153..49f959da 100644 --- a/dashscope/finetune/finetunes.py +++ b/dashscope/legacy_1x/finetune/finetunes.py @@ -5,7 +5,7 @@ from http import HTTPStatus from typing import Iterator, Optional, Union -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( CancelMixin, CreateMixin, DeleteMixin, @@ -14,8 +14,8 @@ LogMixin, StreamEventMixin, ) -from dashscope.common.constants import TaskStatus -from dashscope.finetune.customize_types import ( +from dashscope.legacy_1x.common.constants import TaskStatus +from dashscope.legacy_1x.finetune.customize_types import ( FineTune, FineTuneCancel, FineTuneDelete, diff --git a/dashscope/legacy_1x/finetune/reinforcement/__init__.py b/dashscope/legacy_1x/finetune/reinforcement/__init__.py new file mode 100644 index 00000000..85947709 --- /dev/null +++ b/dashscope/legacy_1x/finetune/reinforcement/__init__.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The historical Agentic RL facade over the shared application core. + +Models, processors and workflow implementation remain single-sourced under +``dashscope.applications``. The 1.x path re-exports those objects but keeps the +original exception classes. +""" + +from dashscope.applications.finetune import reinforcement as _core +from dashscope.legacy_1x.finetune.reinforcement.common import errors as _errors + +_ERROR_NAMES = { + "AgenticRLError", + "BaseConnectionError", + "BasePermissionError", + "ConfigurationError", + "DatasetsError", + "DeploymentError", + "FunctionLayerError", + "FunctionLoadError", + "IOErrorWithCode", + "InputError", + "InstanceQueryError", + "InstanceWarmupError", + "OSSConnectionError", + "OSSUploadError", + "OutputError", + "RegistrationError", + "RuntimeErrorWithCode", + "ValidationError", + "ValueErrorWithCode", +} + +# Avoid resolving the lazy ``app`` attribute while the AgenticRL facade is +# importing: it imports the legacy CLI, which imports AgenticRL back. +for _name in _core.__all__: + if _name not in _ERROR_NAMES and _name != "app": + globals()[_name] = getattr(_core, _name) +for _name in _ERROR_NAMES: + globals()[_name] = getattr(_errors, _name) + +__all__ = list(_core.__all__) + + +def __getattr__(name): + if name == "app": + from dashscope.legacy_1x.cli.agentic_rl import app + + return app + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/dashscope/legacy_1x/finetune/reinforcement/common/__init__.py b/dashscope/legacy_1x/finetune/reinforcement/common/__init__.py new file mode 100644 index 00000000..4e32329b --- /dev/null +++ b/dashscope/legacy_1x/finetune/reinforcement/common/__init__.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Historical common namespace; only errors are version-specific.""" + +import importlib + +from dashscope.legacy_1x.finetune.reinforcement.common import errors + +__all__ = ["errors"] + + +def __getattr__(name): + if name == "errors": + return errors + try: + module = importlib.import_module( + f"dashscope.applications.finetune.reinforcement.common.{name}", + ) + except ModuleNotFoundError as exc: + raise AttributeError(name) from exc + globals()[name] = module + return module diff --git a/dashscope/legacy_1x/finetune/reinforcement/common/errors.py b/dashscope/legacy_1x/finetune/reinforcement/common/errors.py new file mode 100644 index 00000000..fdfdacfe --- /dev/null +++ b/dashscope/legacy_1x/finetune/reinforcement/common/errors.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The unchanged 1.x Agentic RL exception classes.""" + +from dashscope.applications.finetune.reinforcement.common._legacy_errors import ( + AgenticRLError, + BaseConnectionError, + BasePermissionError, + ConfigurationError, + DatasetsError, + DeploymentError, + FunctionLayerError, + FunctionLoadError, + IOErrorWithCode, + InputError, + InstanceQueryError, + InstanceWarmupError, + OSSConnectionError, + OSSUploadError, + OutputError, + RegistrationError, + RuntimeErrorWithCode, + ValidationError, + ValueErrorWithCode, +) + +__all__ = [ + "AgenticRLError", + "BaseConnectionError", + "BasePermissionError", + "ConfigurationError", + "DatasetsError", + "DeploymentError", + "FunctionLayerError", + "FunctionLoadError", + "IOErrorWithCode", + "InputError", + "InstanceQueryError", + "InstanceWarmupError", + "OSSConnectionError", + "OSSUploadError", + "OutputError", + "RegistrationError", + "RuntimeErrorWithCode", + "ValidationError", + "ValueErrorWithCode", +] + +# Preserve the historical module identity for repr/pickle/introspection even +# though the class bodies are single-sourced beside the shared core. +for _name in __all__: + globals()[_name].__module__ = __name__ +del _name diff --git a/dashscope/nlp/__init__.py b/dashscope/legacy_1x/io/__init__.py similarity index 100% rename from dashscope/nlp/__init__.py rename to dashscope/legacy_1x/io/__init__.py diff --git a/dashscope/io/input_output.py b/dashscope/legacy_1x/io/input_output.py similarity index 98% rename from dashscope/io/input_output.py rename to dashscope/legacy_1x/io/input_output.py index ea6caf27..688a5916 100644 --- a/dashscope/io/input_output.py +++ b/dashscope/legacy_1x/io/input_output.py @@ -5,7 +5,7 @@ import io from typing import Generator -from dashscope.common.error import UnsupportedDataType +from dashscope.legacy_1x.common.error import UnsupportedDataType class InputResolver: diff --git a/dashscope/model.py b/dashscope/legacy_1x/model.py similarity index 90% rename from dashscope/model.py rename to dashscope/legacy_1x/model.py index 6aea734d..2e5a2bdf 100644 --- a/dashscope/model.py +++ b/dashscope/legacy_1x/model.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import GetMixin, ListMixin +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import GetMixin, ListMixin class Model(ListMixin, GetMixin): diff --git a/dashscope/models.py b/dashscope/legacy_1x/models.py similarity index 90% rename from dashscope/models.py rename to dashscope/legacy_1x/models.py index fee38a4d..300a4931 100644 --- a/dashscope/models.py +++ b/dashscope/legacy_1x/models.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import GetMixin, ListMixin, _get -from dashscope.common.utils import get_api_module, join_url +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import GetMixin, ListMixin, _get +from dashscope.legacy_1x.common.utils import get_api_module, join_url import dashscope diff --git a/dashscope/multimodal/__init__.py b/dashscope/legacy_1x/multimodal/__init__.py similarity index 100% rename from dashscope/multimodal/__init__.py rename to dashscope/legacy_1x/multimodal/__init__.py diff --git a/dashscope/multimodal/dialog_state.py b/dashscope/legacy_1x/multimodal/dialog_state.py similarity index 100% rename from dashscope/multimodal/dialog_state.py rename to dashscope/legacy_1x/multimodal/dialog_state.py diff --git a/dashscope/multimodal/multimodal_constants.py b/dashscope/legacy_1x/multimodal/multimodal_constants.py similarity index 100% rename from dashscope/multimodal/multimodal_constants.py rename to dashscope/legacy_1x/multimodal/multimodal_constants.py diff --git a/dashscope/multimodal/multimodal_dialog.py b/dashscope/legacy_1x/multimodal/multimodal_dialog.py similarity index 98% rename from dashscope/multimodal/multimodal_dialog.py rename to dashscope/legacy_1x/multimodal/multimodal_dialog.py index 63a04f84..1b563c52 100644 --- a/dashscope/multimodal/multimodal_dialog.py +++ b/dashscope/legacy_1x/multimodal/multimodal_dialog.py @@ -7,12 +7,12 @@ import websocket import dashscope -from dashscope.common.env import resolve_base_url -from dashscope.common.logging import logger -from dashscope.common.error import InputRequired -from dashscope.common.utils import get_sdk_headers, get_user_agent -from dashscope.multimodal import dialog_state -from dashscope.multimodal.multimodal_constants import ( +from dashscope.legacy_1x.common.env import resolve_base_url +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.error import InputRequired +from dashscope.legacy_1x.common.utils import get_sdk_headers, get_user_agent +from dashscope.legacy_1x.multimodal import dialog_state +from dashscope.legacy_1x.multimodal.multimodal_constants import ( RESPONSE_NAME_STARTED, RESPONSE_NAME_STOPPED, RESPONSE_NAME_STATE_CHANGED, @@ -26,7 +26,7 @@ RESPONSE_NAME_ERROR, RESPONSE_NAME_HEART_BEAT, ) -from dashscope.multimodal.multimodal_request_params import ( +from dashscope.legacy_1x.multimodal.multimodal_request_params import ( RequestParameters, get_random_uuid, DashHeader, @@ -35,7 +35,7 @@ RequestToRespondParameters, RequestToRespondBodyInput, ) -from dashscope.protocol.websocket import ActionType +from dashscope.legacy_1x.protocol.websocket import ActionType class MultiModalCallback: diff --git a/dashscope/multimodal/multimodal_request_params.py b/dashscope/legacy_1x/multimodal/multimodal_request_params.py similarity index 100% rename from dashscope/multimodal/multimodal_request_params.py rename to dashscope/legacy_1x/multimodal/multimodal_request_params.py diff --git a/dashscope/multimodal/tingwu/__init__.py b/dashscope/legacy_1x/multimodal/tingwu/__init__.py similarity index 100% rename from dashscope/multimodal/tingwu/__init__.py rename to dashscope/legacy_1x/multimodal/tingwu/__init__.py diff --git a/dashscope/multimodal/tingwu/tingwu.py b/dashscope/legacy_1x/multimodal/tingwu/tingwu.py similarity index 88% rename from dashscope/multimodal/tingwu/tingwu.py rename to dashscope/legacy_1x/multimodal/tingwu/tingwu.py index fbceeb77..bf62aada 100644 --- a/dashscope/multimodal/tingwu/tingwu.py +++ b/dashscope/legacy_1x/multimodal/tingwu/tingwu.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- from typing import Dict, Any -from dashscope.api_entities.api_request_factory import _build_api_request -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseApi -from dashscope.common.error import ModelRequired +from dashscope.legacy_1x.api_entities.api_request_factory import ( + _build_api_request, +) +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.error import ModelRequired class TingWu(BaseApi): diff --git a/dashscope/multimodal/tingwu/tingwu_realtime.py b/dashscope/legacy_1x/multimodal/tingwu/tingwu_realtime.py similarity index 98% rename from dashscope/multimodal/tingwu/tingwu_realtime.py rename to dashscope/legacy_1x/multimodal/tingwu/tingwu_realtime.py index 899c09e9..577a8658 100644 --- a/dashscope/multimodal/tingwu/tingwu_realtime.py +++ b/dashscope/legacy_1x/multimodal/tingwu/tingwu_realtime.py @@ -8,15 +8,15 @@ from dataclasses import dataclass, field from queue import Queue import dashscope -from dashscope.client.base_api import BaseApi -from dashscope.common.error import InvalidParameter, ModelRequired -from dashscope.common.env import resolve_base_url +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.error import InvalidParameter, ModelRequired +from dashscope.legacy_1x.common.env import resolve_base_url import websocket # pylint: disable=wrong-import-order # pylint: disable=ungrouped-imports -from dashscope.common.logging import logger -from dashscope.common.utils import get_sdk_headers, get_user_agent -from dashscope.protocol.websocket import ActionType +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import get_sdk_headers, get_user_agent +from dashscope.legacy_1x.protocol.websocket import ActionType class TingWuRealtimeCallback: diff --git a/dashscope/protocol/__init__.py b/dashscope/legacy_1x/nlp/__init__.py similarity index 100% rename from dashscope/protocol/__init__.py rename to dashscope/legacy_1x/nlp/__init__.py diff --git a/dashscope/nlp/understanding.py b/dashscope/legacy_1x/nlp/understanding.py similarity index 89% rename from dashscope/nlp/understanding.py rename to dashscope/legacy_1x/nlp/understanding.py index e6be2944..b42db4a3 100644 --- a/dashscope/nlp/understanding.py +++ b/dashscope/legacy_1x/nlp/understanding.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseApi -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.logging import logger -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import _get_task_group_and_task class Understanding(BaseApi): diff --git a/dashscope/threads/messages/__init__.py b/dashscope/legacy_1x/protocol/__init__.py similarity index 100% rename from dashscope/threads/messages/__init__.py rename to dashscope/legacy_1x/protocol/__init__.py diff --git a/dashscope/protocol/websocket.py b/dashscope/legacy_1x/protocol/websocket.py similarity index 100% rename from dashscope/protocol/websocket.py rename to dashscope/legacy_1x/protocol/websocket.py diff --git a/dashscope/rerank/__init__.py b/dashscope/legacy_1x/rerank/__init__.py similarity index 60% rename from dashscope/rerank/__init__.py rename to dashscope/legacy_1x/rerank/__init__.py index 2f0829c3..a271a68e 100644 --- a/dashscope/rerank/__init__.py +++ b/dashscope/legacy_1x/rerank/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.rerank.text_rerank import AioTextReRank, TextReRank +from dashscope.legacy_1x.rerank.text_rerank import AioTextReRank, TextReRank __all__ = ["AioTextReRank", "TextReRank"] diff --git a/dashscope/rerank/text_rerank.py b/dashscope/legacy_1x/rerank/text_rerank.py similarity index 94% rename from dashscope/rerank/text_rerank.py rename to dashscope/legacy_1x/rerank/text_rerank.py index 960a8357..b3990562 100644 --- a/dashscope/rerank/text_rerank.py +++ b/dashscope/legacy_1x/rerank/text_rerank.py @@ -3,10 +3,10 @@ from typing import Any, Dict, List, Tuple -from dashscope.api_entities.dashscope_response import ReRankResponse -from dashscope.client.base_api import BaseAioApi, BaseApi -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.utils import _get_task_group_and_task +from dashscope.legacy_1x.api_entities.dashscope_response import ReRankResponse +from dashscope.legacy_1x.client.base_api import BaseAioApi, BaseApi +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.utils import _get_task_group_and_task __all__ = ["TextReRank", "AioTextReRank"] diff --git a/dashscope/threads/__init__.py b/dashscope/legacy_1x/threads/__init__.py similarity index 58% rename from dashscope/threads/__init__.py rename to dashscope/legacy_1x/threads/__init__.py index 5ae531f2..0249d6c8 100644 --- a/dashscope/threads/__init__.py +++ b/dashscope/legacy_1x/threads/__init__.py @@ -3,15 +3,15 @@ # yapf: disable -from dashscope.threads.messages.messages import Messages -from dashscope.threads.runs.runs import Runs -from dashscope.threads.runs.steps import Steps -from dashscope.threads.thread_types import ( +from dashscope.legacy_1x.threads.messages.messages import Messages +from dashscope.legacy_1x.threads.runs.runs import Runs +from dashscope.legacy_1x.threads.runs.steps import Steps +from dashscope.legacy_1x.threads.thread_types import ( MessageFile, Run, RunList, RunStep, RunStepList, Thread, ThreadMessage, ThreadMessageList, ) -from dashscope.threads.threads import Threads +from dashscope.legacy_1x.threads.threads import Threads __all__ = [ 'MessageFile', diff --git a/dashscope/threads/runs/__init__.py b/dashscope/legacy_1x/threads/messages/__init__.py similarity index 100% rename from dashscope/threads/runs/__init__.py rename to dashscope/legacy_1x/threads/messages/__init__.py diff --git a/dashscope/threads/messages/files.py b/dashscope/legacy_1x/threads/messages/files.py similarity index 94% rename from dashscope/threads/messages/files.py rename to dashscope/legacy_1x/threads/messages/files.py index 81e5c00c..54488dbb 100644 --- a/dashscope/threads/messages/files.py +++ b/dashscope/legacy_1x/threads/messages/files.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.client.base_api import GetStatusMixin, ListObjectMixin -from dashscope.common.error import InputRequired -from dashscope.threads.thread_types import MessageFile, MessageFileList +from dashscope.legacy_1x.client.base_api import GetStatusMixin, ListObjectMixin +from dashscope.legacy_1x.common.error import InputRequired +from dashscope.legacy_1x.threads.thread_types import ( + MessageFile, + MessageFileList, +) __all__ = ["Files"] diff --git a/dashscope/threads/messages/messages.py b/dashscope/legacy_1x/threads/messages/messages.py similarity index 96% rename from dashscope/threads/messages/messages.py rename to dashscope/legacy_1x/threads/messages/messages.py index 10953af4..8f2f2f0a 100644 --- a/dashscope/threads/messages/messages.py +++ b/dashscope/legacy_1x/threads/messages/messages.py @@ -4,20 +4,23 @@ import warnings from typing import Dict, List, Optional -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( CreateMixin, GetStatusMixin, ListObjectMixin, UpdateMixin, ) -from dashscope.common.error import InputRequired -from dashscope.threads.thread_types import ThreadMessage, ThreadMessageList +from dashscope.legacy_1x.common.error import InputRequired +from dashscope.legacy_1x.threads.thread_types import ( + ThreadMessage, + ThreadMessageList, +) __all__ = ["Messages"] # Deprecation warning message _DEPRECATION_MSG = ( - "The Assistants API (dashscope.threads) is deprecated and will be " + "The Assistants API (dashscope.legacy_1x.threads) is deprecated and will be " "removed in a future release. Please migrate to the Responses API. " "See https://help.aliyun.com/zh/model-studio/" "synchronous-call-api-reference for migration details." diff --git a/dashscope/utils/__init__.py b/dashscope/legacy_1x/threads/runs/__init__.py similarity index 100% rename from dashscope/utils/__init__.py rename to dashscope/legacy_1x/threads/runs/__init__.py diff --git a/dashscope/threads/runs/runs.py b/dashscope/legacy_1x/threads/runs/runs.py similarity index 98% rename from dashscope/threads/runs/runs.py rename to dashscope/legacy_1x/threads/runs/runs.py index 756d1ed9..904836ad 100644 --- a/dashscope/threads/runs/runs.py +++ b/dashscope/legacy_1x/threads/runs/runs.py @@ -6,20 +6,20 @@ from http import HTTPStatus from typing import Dict, List, Optional -from dashscope.client.base_api import ( +from dashscope.legacy_1x.client.base_api import ( CancelMixin, CreateMixin, GetStatusMixin, ListObjectMixin, UpdateMixin, ) -from dashscope.common.error import ( +from dashscope.legacy_1x.common.error import ( AssistantError, InputRequired, TimeoutException, ) -from dashscope.common.logging import logger -from dashscope.threads.thread_types import ( +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.threads.thread_types import ( Run, RunList, RunStep, @@ -33,7 +33,7 @@ # Deprecation warning message _DEPRECATION_MSG = ( - "The Assistants API (dashscope.threads) is deprecated and will be " + "The Assistants API (dashscope.legacy_1x.threads) is deprecated and will be " "removed in a future release. Please migrate to the Responses API. " "See https://help.aliyun.com/zh/model-studio/" "synchronous-call-api-reference for migration details." diff --git a/dashscope/threads/runs/steps.py b/dashscope/legacy_1x/threads/runs/steps.py similarity index 93% rename from dashscope/threads/runs/steps.py rename to dashscope/legacy_1x/threads/runs/steps.py index e7e0c86a..2dcc80d9 100644 --- a/dashscope/threads/runs/steps.py +++ b/dashscope/legacy_1x/threads/runs/steps.py @@ -3,15 +3,15 @@ import warnings -from dashscope.client.base_api import GetStatusMixin, ListObjectMixin -from dashscope.common.error import InputRequired -from dashscope.threads.thread_types import RunStep, RunStepList +from dashscope.legacy_1x.client.base_api import GetStatusMixin, ListObjectMixin +from dashscope.legacy_1x.common.error import InputRequired +from dashscope.legacy_1x.threads.thread_types import RunStep, RunStepList __all__ = ["Steps"] # Deprecation warning message _DEPRECATION_MSG = ( - "The Assistants API (dashscope.threads) is deprecated and will be " + "The Assistants API (dashscope.legacy_1x.threads) is deprecated and will be " "removed in a future release. Please migrate to the Responses API. " "See https://help.aliyun.com/zh/model-studio/" "synchronous-call-api-reference for migration details." diff --git a/dashscope/threads/thread_types.py b/dashscope/legacy_1x/threads/thread_types.py similarity index 99% rename from dashscope/threads/thread_types.py rename to dashscope/legacy_1x/threads/thread_types.py index b47ff070..aa552416 100644 --- a/dashscope/threads/thread_types.py +++ b/dashscope/legacy_1x/threads/thread_types.py @@ -6,10 +6,10 @@ from dataclasses import dataclass from typing import Dict, List, Literal, Optional, Union -from dashscope.assistants.assistant_types import ( +from dashscope.legacy_1x.assistants.assistant_types import ( Tool, convert_tools_dict_to_objects, ) -from dashscope.common.base_type import BaseList, BaseObjectMixin +from dashscope.legacy_1x.common.base_type import BaseList, BaseObjectMixin __all__ = [ 'MessageFile', 'MessageFileList', 'Usage', 'ImageFile', diff --git a/dashscope/threads/threads.py b/dashscope/legacy_1x/threads/threads.py similarity index 96% rename from dashscope/threads/threads.py rename to dashscope/legacy_1x/threads/threads.py index e4effe33..788a4e57 100644 --- a/dashscope/threads/threads.py +++ b/dashscope/legacy_1x/threads/threads.py @@ -4,20 +4,20 @@ import warnings from typing import Dict, List, Optional -from dashscope.assistants.assistant_types import DeleteResponse -from dashscope.client.base_api import ( +from dashscope.legacy_1x.assistants.assistant_types import DeleteResponse +from dashscope.legacy_1x.client.base_api import ( CreateMixin, DeleteMixin, GetStatusMixin, UpdateMixin, ) -from dashscope.common.error import InputRequired -from dashscope.threads.thread_types import Run, Thread +from dashscope.legacy_1x.common.error import InputRequired +from dashscope.legacy_1x.threads.thread_types import Run, Thread __all__ = ["Threads"] _DEPRECATION_MSG = ( - "The Assistants API (dashscope.threads) is deprecated and will be " + "The Assistants API (dashscope.legacy_1x.threads) is deprecated and will be " "removed in a future release. Please migrate to the Responses API. " "See https://help.aliyun.com/zh/model-studio/" "synchronous-call-api-reference for migration details." diff --git a/dashscope/tokenizers/__init__.py b/dashscope/legacy_1x/tokenizers/__init__.py similarity index 100% rename from dashscope/tokenizers/__init__.py rename to dashscope/legacy_1x/tokenizers/__init__.py diff --git a/dashscope/resources/qwen.tiktoken b/dashscope/legacy_1x/tokenizers/qwen.tiktoken similarity index 100% rename from dashscope/resources/qwen.tiktoken rename to dashscope/legacy_1x/tokenizers/qwen.tiktoken diff --git a/dashscope/tokenizers/qwen_tokenizer.py b/dashscope/legacy_1x/tokenizers/qwen_tokenizer.py similarity index 100% rename from dashscope/tokenizers/qwen_tokenizer.py rename to dashscope/legacy_1x/tokenizers/qwen_tokenizer.py diff --git a/dashscope/tokenizers/tokenization.py b/dashscope/legacy_1x/tokenizers/tokenization.py similarity index 94% rename from dashscope/tokenizers/tokenization.py rename to dashscope/legacy_1x/tokenizers/tokenization.py index 9a29de53..ba40b69f 100644 --- a/dashscope/tokenizers/tokenization.py +++ b/dashscope/legacy_1x/tokenizers/tokenization.py @@ -4,21 +4,21 @@ import copy from typing import Any, List, Optional -from dashscope.api_entities.dashscope_response import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( DashScopeAPIResponse, Message, Role, ) -from dashscope.client.base_api import BaseApi -from dashscope.common.constants import ( +from dashscope.legacy_1x.client.base_api import BaseApi +from dashscope.legacy_1x.common.constants import ( CUSTOMIZED_MODEL_ID, DEPRECATED_MESSAGE, HISTORY, MESSAGES, PROMPT, ) -from dashscope.common.error import InputRequired, ModelRequired -from dashscope.common.logging import logger +from dashscope.legacy_1x.common.error import InputRequired, ModelRequired +from dashscope.legacy_1x.common.logging import logger class Tokenization(BaseApi): diff --git a/dashscope/tokenizers/tokenizer.py b/dashscope/legacy_1x/tokenizers/tokenizer.py similarity index 50% rename from dashscope/tokenizers/tokenizer.py rename to dashscope/legacy_1x/tokenizers/tokenizer.py index 05ba3b0f..eb8d128a 100644 --- a/dashscope/tokenizers/tokenizer.py +++ b/dashscope/legacy_1x/tokenizers/tokenizer.py @@ -4,14 +4,16 @@ import os from typing import List -from dashscope.common.error import UnsupportedModel -from dashscope.tokenizers.qwen_tokenizer import QwenTokenizer - +from dashscope.legacy_1x.common.error import UnsupportedModel +from .qwen_tokenizer import QwenTokenizer from .tokenizer_base import Tokenizer QWEN_SERIALS = ["qwen-7b-chat", "qwen-turbo", "qwen-plus", "qwen-max"] -current_path = os.path.dirname(os.path.abspath(__file__)) -root_path = os.path.dirname(current_path) +# The vocab ships next to this module, so the leaf stays relocatable. +_VOCAB_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "qwen.tiktoken", +) def get_tokenizer(model: str) -> Tokenizer: @@ -26,18 +28,11 @@ def get_tokenizer(model: str) -> Tokenizer: Returns: Tokenizer: The `Tokenizer` of the model. """ - if model in QWEN_SERIALS: - return QwenTokenizer( - os.path.join(root_path, "resources", "qwen.tiktoken"), - ) - elif model.startswith("qwen"): - return QwenTokenizer( - os.path.join(root_path, "resources", "qwen.tiktoken"), - ) - else: - raise UnsupportedModel( - f"Not support model: {model}, currently only support qwen models.", - ) + if model.startswith("qwen"): + return QwenTokenizer(_VOCAB_PATH) + raise UnsupportedModel( + f"Not support model: {model}, currently only support qwen models.", + ) def list_tokenizers() -> List[str]: diff --git a/dashscope/tokenizers/tokenizer_base.py b/dashscope/legacy_1x/tokenizers/tokenizer_base.py similarity index 100% rename from dashscope/tokenizers/tokenizer_base.py rename to dashscope/legacy_1x/tokenizers/tokenizer_base.py diff --git a/tests/unit/__init__.py b/dashscope/legacy_1x/utils/__init__.py similarity index 100% rename from tests/unit/__init__.py rename to dashscope/legacy_1x/utils/__init__.py diff --git a/dashscope/utils/message_utils.py b/dashscope/legacy_1x/utils/message_utils.py similarity index 100% rename from dashscope/utils/message_utils.py rename to dashscope/legacy_1x/utils/message_utils.py diff --git a/dashscope/utils/oss_utils.py b/dashscope/legacy_1x/utils/oss_utils.py similarity index 96% rename from dashscope/utils/oss_utils.py rename to dashscope/legacy_1x/utils/oss_utils.py index 95187dff..65410244 100644 --- a/dashscope/utils/oss_utils.py +++ b/dashscope/legacy_1x/utils/oss_utils.py @@ -11,12 +11,14 @@ import requests -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import GetMixin -from dashscope.common.constants import FILE_PATH_SCHEMA -from dashscope.common.error import InvalidInput, UploadFileException -from dashscope.common.logging import logger -from dashscope.common.utils import get_user_agent +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import GetMixin +from dashscope.legacy_1x.common.constants import FILE_PATH_SCHEMA +from dashscope.legacy_1x.common.error import InvalidInput, UploadFileException +from dashscope.legacy_1x.common.logging import logger +from dashscope.legacy_1x.common.utils import get_user_agent class OssUtils(GetMixin): diff --git a/dashscope/utils/param_utils.py b/dashscope/legacy_1x/utils/param_utils.py similarity index 100% rename from dashscope/utils/param_utils.py rename to dashscope/legacy_1x/utils/param_utils.py diff --git a/dashscope/v2/protocols/__init__.py b/dashscope/protocols/__init__.py similarity index 79% rename from dashscope/v2/protocols/__init__.py rename to dashscope/protocols/__init__.py index 78178b78..d0fe4a7b 100644 --- a/dashscope/v2/protocols/__init__.py +++ b/dashscope/protocols/__init__.py @@ -7,11 +7,11 @@ 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 +from dashscope._config import VALID_PROTOCOLS +from dashscope.protocols.anthropic import AnthropicProtocol +from dashscope.protocols.base import ChatProtocol, ChatRequest +from dashscope.protocols.native import NativeProtocol +from dashscope.protocols.openai import OpenAIProtocol __all__ = [ "AnthropicProtocol", diff --git a/dashscope/v2/protocols/anthropic.py b/dashscope/protocols/anthropic.py similarity index 85% rename from dashscope/v2/protocols/anthropic.py rename to dashscope/protocols/anthropic.py index 6b7dc49e..a9947dea 100644 --- a/dashscope/v2/protocols/anthropic.py +++ b/dashscope/protocols/anthropic.py @@ -31,9 +31,9 @@ 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 ( +from dashscope.errors import BadRequestError, DashScopeError, ErrorCode +from dashscope.protocols.base import ChatProtocol, ChatRequest +from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ) @@ -162,9 +162,7 @@ def _translate_message(message: Dict[str, Any]) -> Dict[str, Any]: 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) + blocks.extend(_thinking_blocks(message)) if message.get("tool_calls") or blocks: if isinstance(content, list): # Parts lists keep their non-text parts; collapsing @@ -182,19 +180,26 @@ def _translate_message(message: Dict[str, Any]) -> Dict[str, Any]: 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. +def _thinking_blocks(message: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return signed thinking blocks exactly as received when available.""" + preserved = message.get("reasoning_blocks") + if preserved is not None: + if not isinstance(preserved, list) or not all( + isinstance(block, dict) for block in preserved + ): + raise ValueError("reasoning_blocks must be a list of objects") + return [dict(block) for block in preserved] thinking = message.get("reasoning_content") signature = message.get("reasoning_signature") if thinking and signature: - return { - "type": "thinking", - "thinking": thinking, - "signature": signature, - } - return None + return [ + { + "type": "thinking", + "thinking": thinking, + "signature": signature, + }, + ] + return [] def _translate_tools( @@ -275,7 +280,15 @@ def _flush_tool_results() -> None: elif isinstance(part, str): system_blocks.append({"type": "text", "text": part}) else: - system_blocks.append({"type": "text", "text": ""}) + part_type = ( + part.get("type") + if isinstance(part, dict) + else None + ) + raise ValueError( + "Anthropic system messages support text parts only; " + f"got {part_type!r}", + ) else: system_blocks.append( {"type": "text", "text": _text_of(content)}, @@ -377,6 +390,7 @@ class _StreamParser: def __init__(self) -> None: self._tools: Dict[int, Dict[str, Any]] = {} + self._thinking: Dict[int, Dict[str, Any]] = {} self._usage: Dict[str, int] = {} def parse(self, payload: Dict[str, Any]) -> Optional[ChatCompletionChunk]: @@ -393,6 +407,7 @@ def parse(self, payload: Dict[str, Any]) -> Optional[ChatCompletionChunk]: # resets per-message tool state. if payload.get("type") == "message_stop": self._tools = {} + self._thinking = {} return None parsed = handler(payload) if parsed is None: @@ -413,6 +428,7 @@ def parse(self, payload: Dict[str, Any]) -> Optional[ChatCompletionChunk]: def _message_start(self, payload: Dict[str, Any]): message = payload.get("message") or {} self._tools = {} + self._thinking = {} self._usage = {} usage: Optional[Dict[str, Any]] = None start_usage = message.get("usage") or {} @@ -428,7 +444,37 @@ def _message_start(self, payload: Dict[str, Any]): def _block_start(self, payload: Dict[str, Any]): block = payload.get("content_block") or {} - if block.get("type") != "tool_use": + block_type = block.get("type") + index = payload.get("index", 0) + if block_type == "thinking": + # Signature and text arrive via deltas; start an empty block + # so the cumulative snapshot keeps it in index order. + self._thinking[index] = { + "type": "thinking", + "thinking": "", + "signature": "", + } + return None + if block_type == "redacted_thinking": + # Arrives complete in content_block_start (no deltas follow); + # preserve it verbatim so the turn can be replayed faithfully. + self._thinking[index] = dict(block) + return ( + payload.get("id"), + payload.get("model"), + [ + { + "index": 0, + "delta": { + "reasoning_blocks": ( + self._cumulative_thinking_blocks() + ), + }, + }, + ], + None, + ) + if block_type != "tool_use": # Text/audio block starts carry no content yet; skip. return None choices: List[Dict[str, Any]] = [] @@ -450,13 +496,24 @@ def _block_start(self, payload: Dict[str, Any]): def _block_delta(self, payload: Dict[str, Any]): delta_payload = payload.get("delta") or {} delta_type = delta_payload.get("type") + index = payload.get("index", 0) delta: Dict[str, Any] = {} if delta_type == "text_delta": delta["content"] = delta_payload.get("text", "") elif delta_type == "thinking_delta": + block = self._thinking.get(index) + if block is not None and block.get("type") == "thinking": + block["thinking"] += delta_payload.get("thinking", "") delta["reasoning_content"] = delta_payload.get("thinking", "") + if self._thinking: + delta["reasoning_blocks"] = self._cumulative_thinking_blocks() elif delta_type == "signature_delta": + block = self._thinking.get(index) + if block is not None and block.get("type") == "thinking": + block["signature"] = delta_payload.get("signature", "") delta["reasoning_signature"] = delta_payload.get("signature", "") + if self._thinking: + delta["reasoning_blocks"] = self._cumulative_thinking_blocks() elif delta_type == "input_json_delta": tool = self._tools.get(payload.get("index", 0)) if tool is not None: @@ -468,6 +525,23 @@ def _block_delta(self, payload: Dict[str, Any]): choices = [{"index": 0, "delta": delta}] return payload.get("id"), payload.get("model"), choices, None + def _cumulative_thinking_blocks(self) -> List[Dict[str, Any]]: + """Thinking/redacted_thinking blocks accumulated so far, in order.""" + blocks: List[Dict[str, Any]] = [] + for index in sorted(self._thinking): + block = self._thinking[index] + if block.get("type") == "thinking": + blocks.append( + { + "type": "thinking", + "thinking": block.get("thinking", ""), + "signature": block.get("signature", ""), + }, + ) + else: + blocks.append(dict(block)) + return blocks + def _message_delta(self, payload: Dict[str, Any]): choices: List[Dict[str, Any]] = [] usage: Optional[Dict[str, Any]] = None @@ -632,7 +706,7 @@ def build_chat_request( def parse_completion(self, payload: Dict[str, Any]) -> ChatCompletion: text_parts: List[str] = [] thinking_parts: List[str] = [] - signature_parts: List[str] = [] + thinking_blocks: List[Dict[str, Any]] = [] tool_calls: List[Dict[str, Any]] = [] for block in payload.get("content") or []: if block.get("type") == "text": @@ -644,8 +718,9 @@ def parse_completion(self, payload: Dict[str, Any]) -> ChatCompletion: # 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"]) + thinking_blocks.append(dict(block)) + elif block.get("type") == "redacted_thinking": + thinking_blocks.append(dict(block)) elif block.get("type") == "tool_use": tool_calls.append( { @@ -667,12 +742,15 @@ def parse_completion(self, payload: Dict[str, Any]) -> ChatCompletion: 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 thinking_blocks: + message["reasoning_blocks"] = thinking_blocks + signed = [ + block + for block in thinking_blocks + if block.get("type") == "thinking" and block.get("signature") + ] + if len(signed) == 1: + message["reasoning_signature"] = signed[0]["signature"] if tool_calls: message["tool_calls"] = tool_calls stop_reason = payload.get("stop_reason") diff --git a/dashscope/v2/protocols/base.py b/dashscope/protocols/base.py similarity index 98% rename from dashscope/v2/protocols/base.py rename to dashscope/protocols/base.py index 5e6a09d6..05ebdd85 100644 --- a/dashscope/v2/protocols/base.py +++ b/dashscope/protocols/base.py @@ -24,7 +24,7 @@ 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 ( + from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ) diff --git a/dashscope/v2/protocols/native.py b/dashscope/protocols/native.py similarity index 98% rename from dashscope/v2/protocols/native.py rename to dashscope/protocols/native.py index 59976a33..f3135316 100644 --- a/dashscope/v2/protocols/native.py +++ b/dashscope/protocols/native.py @@ -6,8 +6,8 @@ from typing import Any, Dict, List, Optional, Union -from dashscope.v2.protocols.base import ChatProtocol, ChatRequest -from dashscope.v2.types.completions import ( +from dashscope.protocols.base import ChatProtocol, ChatRequest +from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, parse_chunk, diff --git a/dashscope/v2/protocols/openai.py b/dashscope/protocols/openai.py similarity index 98% rename from dashscope/v2/protocols/openai.py rename to dashscope/protocols/openai.py index 4a2f1a68..049ad6a9 100644 --- a/dashscope/v2/protocols/openai.py +++ b/dashscope/protocols/openai.py @@ -13,9 +13,9 @@ 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 ( +from dashscope.errors import DashScopeError +from dashscope.protocols.base import ChatProtocol, ChatRequest +from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ) diff --git a/dashscope/v2/resources/__init__.py b/dashscope/resources/__init__.py similarity index 100% rename from dashscope/v2/resources/__init__.py rename to dashscope/resources/__init__.py diff --git a/dashscope/resources/_blocking.py b/dashscope/resources/_blocking.py new file mode 100644 index 00000000..2754d748 --- /dev/null +++ b/dashscope/resources/_blocking.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Run the blocking upload path on behalf of an async resource. + +Local-file → OSS upload is written once, synchronously: it fetches the +upload policy over the client transport, then posts the file with a +blocking httpx client. Async resources offload that work with +``asyncio.to_thread``, but a worker thread cannot ``await`` the +``AsyncTransport`` — calling it there returns an un-awaited coroutine +instead of a response. + +So the worker gets a *bridge* client: same client for everything except the +transport, whose ``request`` schedules the coroutine back onto the running +loop with ``run_coroutine_threadsafe`` and blocks on the future. The upload +keeps the async client's connection pool, retries and per-call credentials, +and the event loop stays free to run the coroutine it was handed. +""" + +from __future__ import annotations + +import asyncio +import contextvars +import functools +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable + +__all__ = ["bridge_client", "run_blocking"] + + +# Keep bridge workers separate from the event loop default executor. Async +# transports may need that default executor themselves (for example DNS +# resolution); occupying it with workers that synchronously wait for those +# transports can otherwise deadlock under concurrent uploads. +_BLOCKING_EXECUTOR = ThreadPoolExecutor( + thread_name_prefix="dashscope-blocking", +) + + +class _BridgeTransport: + def __init__(self, transport, loop: asyncio.AbstractEventLoop) -> None: + self._transport = transport + self._loop = loop + + def request(self, *args, **kwargs): + return asyncio.run_coroutine_threadsafe( + self._transport.request(*args, **kwargs), + self._loop, + ).result() + + +class _BridgeClient: + """Stand-in client whose ``transport`` answers synchronously.""" + + def __init__(self, client, loop: asyncio.AbstractEventLoop) -> None: + self._client = client + self.transport = _BridgeTransport(client.transport, loop) + + def __getattr__(self, name: str) -> Any: + return getattr(self._client, name) + + +def bridge_client(client) -> Any: + """Wrap ``client`` so its transport can be used from a worker thread. + + Must be called from the event loop that owns the client's transport; + the loop is captured here because a worker thread has no way to find it. + """ + return _BridgeClient(client, asyncio.get_running_loop()) + + +async def run_blocking( + client, + fn: Callable[..., Any], + *args, + **kwargs, +) -> Any: + """Run ``fn`` with a bridged client on the isolated worker pool. + + ``fn`` takes the client as its first argument, as every blocking helper + on the upload path does. Context variables are propagated like + :func:`asyncio.to_thread`, without consuming the loop default executor. + """ + loop = asyncio.get_running_loop() + bound = functools.partial(fn, bridge_client(client), *args, **kwargs) + context = contextvars.copy_context() + call = functools.partial(context.run, bound) + return await loop.run_in_executor(_BLOCKING_EXECUTOR, call) diff --git a/dashscope/v2/resources/_jobs.py b/dashscope/resources/_jobs.py similarity index 98% rename from dashscope/v2/resources/_jobs.py rename to dashscope/resources/_jobs.py index 61b42547..be8dd1f9 100644 --- a/dashscope/v2/resources/_jobs.py +++ b/dashscope/resources/_jobs.py @@ -17,12 +17,13 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field -from dashscope.v2.errors import ( +from dashscope.errors import ( DashScopeError, check_response, error_from_business_code, + response_json, ) -from dashscope.v2.types.common import ( +from dashscope.types.common import ( ResponseBase, Usage, flatten_envelope, @@ -360,7 +361,10 @@ def list( # pylint: disable=redefined-builtin workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(JobList.from_payload(response.json()), response) + return with_headers( + JobList.from_payload(response_json(response)), + response, + ) def wait( self, @@ -413,7 +417,7 @@ def wait( return self._check_canceled(current) def _parse(self, response: Any) -> Job: - payload = response.json() + payload = response_json(response) job = with_headers(Job.from_payload(payload), response) if job.status == FAILED and self._client.raise_on_error: raise error_from_business_code( @@ -557,7 +561,10 @@ async def list( # pylint: disable=redefined-builtin workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(JobList.from_payload(response.json()), response) + return with_headers( + JobList.from_payload(response_json(response)), + response, + ) async def wait( self, @@ -605,7 +612,7 @@ async def wait( return self._check_canceled(current) def _parse(self, response: Any) -> Job: - payload = response.json() + payload = response_json(response) job = with_headers(Job.from_payload(payload), response) if job.status == FAILED and self._client.raise_on_error: raise error_from_business_code( diff --git a/dashscope/v2/resources/_oss.py b/dashscope/resources/_oss.py similarity index 76% rename from dashscope/v2/resources/_oss.py rename to dashscope/resources/_oss.py index 2235e4a0..004f5978 100644 --- a/dashscope/v2/resources/_oss.py +++ b/dashscope/resources/_oss.py @@ -4,16 +4,15 @@ 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. +OSS resolution. The uploader itself is ``_oss_upload``. -Two things are anchored to the v2 client rather than to 1.x globals: +Two things are anchored to the v2 client rather than to module 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 upload certificate is fetched from the client's own ``base_url``, 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. A 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. @@ -23,8 +22,8 @@ from typing import Any, Dict, List, Optional, Tuple -from dashscope.utils.oss_utils import check_and_upload_local -from dashscope.v2.types.messages import ( +from dashscope.resources._oss_upload import check_and_upload_local +from dashscope.types.messages import ( AudioPart, ImagePart, Message, @@ -48,17 +47,16 @@ def _effective( client, api_key: Optional[str], workspace: Optional[str], -) -> Tuple[Optional[str], Optional[str], str]: +) -> Tuple[Optional[str], Optional[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. + Returns ``(api_key, workspace)``. The certificate fetch and the upload + both run under these, so the asset lands in the same account the request + is sent to; the endpoint comes from the client itself. """ return ( api_key or client.config.api_key, workspace or client.config.workspace, - client.config.base_url, ) @@ -74,7 +72,7 @@ def upload_message_media( 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) + key, space = _effective(client, api_key, workspace) has_upload = False cert: Optional[dict] = None for message in messages: @@ -87,12 +85,12 @@ def upload_message_media( # Inline base64 audio has no URL to upload. continue is_up, new, cert = check_and_upload_local( + client, model, part.url, - key, - cert, - base_address=base_url, + api_key=key, workspace=space, + certificate=cert, ) if is_up: part.url = new @@ -100,12 +98,12 @@ def upload_message_media( elif isinstance(part, VideoPart): if part.url is not None: is_up, new, cert = check_and_upload_local( + client, model, part.url, - key, - cert, - base_address=base_url, + api_key=key, workspace=space, + certificate=cert, ) if is_up: part.url = new @@ -114,12 +112,12 @@ def upload_message_media( frames = [] for frame in part.frames: is_up, new, cert = check_and_upload_local( + client, model, frame, - key, - cert, - base_address=base_url, + api_key=key, workspace=space, + certificate=cert, ) has_upload = has_upload or is_up frames.append(new) @@ -128,21 +126,21 @@ def upload_message_media( def _upload_value( + client, 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( + client, model, value, - api_key, - cert, - base_address=base_url, + api_key=api_key, workspace=workspace, + certificate=cert, ) return new, is_up, cert if isinstance(value, list): @@ -150,11 +148,11 @@ def _upload_value( out = [] for item in value: new_item, is_up, cert = _upload_value( + client, item, model, api_key, workspace, - base_url, cert, ) has_upload = has_upload or is_up @@ -165,11 +163,11 @@ def _upload_value( out = {} for key, item in value.items(): new_item, is_up, cert = _upload_value( + client, item, model, api_key, workspace, - base_url, cert, ) has_upload = has_upload or is_up @@ -188,17 +186,17 @@ def upload_media_value( """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 + ``extra_input``); each string goes through the 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) + key, space = _effective(client, api_key, workspace) new_value, has_upload, _ = _upload_value( + client, value, model, key, space, - base_url, None, ) return new_value, has_upload @@ -212,19 +210,19 @@ def upload_embedding_media( 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) + key, space = _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( + client, model, field, - key, - cert, - base_address=base_url, + api_key=key, workspace=space, + certificate=cert, ) if is_up: item[field_key] = new diff --git a/dashscope/resources/_oss_upload.py b/dashscope/resources/_oss_upload.py new file mode 100644 index 00000000..e1e92d88 --- /dev/null +++ b/dashscope/resources/_oss_upload.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Local-file → OSS upload, native to the 2.0 SDK. + +DashScope serves media from URLs, so a local path or ``file://`` URI has to +be posted to OSS first and the resulting ``oss://`` URL sent instead. The +upload is two hops with opposite trust levels: + +* the policy fetch (``GET /uploads?action=getPolicy``) goes to DashScope and + **does** carry the credential, so it rides the client transport and picks + up its connection pool, retries and per-call api_key/workspace; +* the post-object upload goes to the OSS host and must **not** carry it. The + transport merges its default ``Authorization: Bearer`` into every request, + and httpx only strips that header on cross-origin *redirects* -- posting an + absolute OSS URL straight through the transport would hand the API key to + the storage host. Hence the separate auth-free client in ``_post_to_oss``. +""" + +from __future__ import annotations + +import mimetypes +import os +from typing import Any, Dict, Optional, Tuple +from urllib.parse import unquote_plus, urlparse + +import httpx + +from dashscope._logging import get_logger +from dashscope.errors import ( + check_response, + error_from_status, + internal_response_error, + response_json, +) +from dashscope.transport.http import default_user_agent + +__all__ = [ + "check_and_upload_local", + "get_upload_certificate", + "upload_local_file", +] + +_FILE_URI_SCHEME = "file://" +_OSS_SCHEME = "oss://" +_CERT_PATH = "/uploads" +# The client default (300s) is far too short for a large video. +_OSS_POST_TIMEOUT = 3600.0 + +logger = get_logger("oss") + + +def _resolve_file_uri_path(file_uri: str) -> str: + """Turn a ``file://`` URI into a filesystem path.""" + parsed = urlparse(file_uri) + if parsed.netloc: + file_path = parsed.netloc + unquote_plus(parsed.path) + else: + file_path = unquote_plus(parsed.path) + + # A Windows URI parses to "/C:/dir/file"; drop the leading slash. + if ( + file_path.startswith("/") + and len(file_path) > 2 + and file_path[2] == ":" + ): + file_path = file_path[1:] + + return os.path.expanduser(file_path) + + +def _policy_from_response(response: httpx.Response) -> Dict[str, Any]: + """Pull the policy out of the getPolicy envelope. + + ``data`` is the key the gateway answers with -- and the one 1.x read + first. Accepting ``output`` too would let an unrelated body (an + embeddings response, say) through as a "policy", deferring the failure + to a ``KeyError`` on some policy field far from here. + """ + body = response_json(response) + policy = body.get("data") + if not isinstance(policy, dict): + raise internal_response_error(response, body) + return policy + + +def get_upload_certificate( + client, + model: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, +) -> Dict[str, Any]: + """Fetch the OSS post-object policy for ``model``. + + Raises even when the client was built with ``raise_on_error=False``: + without a policy there is no URL to upload to and nothing to hand back. + + Blocking, like the rest of this module: an ``AsyncClient`` reaches it + through the bridge in :mod:`dashscope.resources._blocking`, which is + what lets ``client.transport.request`` return a response here instead + of a coroutine. + """ + response = client.transport.request( + "GET", + _CERT_PATH, + params={"action": "getPolicy", "model": model}, + api_key=api_key, + workspace=workspace, + ) + check_response(response) + return _policy_from_response(response) + + +def _post_to_oss( + upload_host: str, + form: Dict[str, Any], + file_name: str, + file_path: str, + transport: Optional[httpx.BaseTransport] = None, +) -> httpx.Response: + """Post one file to OSS with a client that carries no DashScope auth. + + The file is streamed from a live handle rather than read into memory, so + a large video does not have to fit in the process. ``transport`` exists + for tests to inject an ``httpx.MockTransport``. + """ + headers = { + "Accept": "application/json", + "User-Agent": default_user_agent(), + } + with httpx.Client( + timeout=_OSS_POST_TIMEOUT, + transport=transport, + ) as oss_client: + with open(file_path, "rb") as handle: + return oss_client.post( + upload_host, + data=form, + files={"file": (file_name, handle)}, + headers=headers, + ) + + +def upload_local_file( + client, + model: str, + file_path: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + certificate: Optional[Dict[str, Any]] = None, +) -> Tuple[str, Dict[str, Any]]: + """Upload one local file and return ``(oss_url, certificate)``. + + Pass the returned certificate back in to reuse it across a batch instead + of fetching a fresh policy per file. + """ + cert = certificate + if cert is None: + cert = get_upload_certificate( + client, + model, + api_key=api_key, + workspace=workspace, + ) + key = cert["upload_dir"] + "/" + os.path.basename(file_path) + form = { + "OSSAccessKeyId": cert["oss_access_key_id"], + "Signature": cert["signature"], + "policy": cert["policy"], + "key": key, + "x-oss-object-acl": cert["x_oss_object_acl"], + "x-oss-forbid-overwrite": cert["x_oss_forbid_overwrite"], + "success_action_status": "200", + "x-oss-content-type": ( + mimetypes.guess_type(file_path)[0] or "application/octet-stream" + ), + } + response = _post_to_oss( + cert["upload_host"], + form, + os.path.basename(file_path), + file_path, + ) + if not response.is_success: + logger.error( + "uploading file %s to oss failed, status %s", + file_path, + response.status_code, + ) + # OSS answers with XML, so there is no business code to read and the + # error is mapped from the status code alone. + raise error_from_status( + response.status_code, + message=response.text, + headers=response.headers, + ) + return _OSS_SCHEME + key, cert + + +def check_and_upload_local( + client, + model: str, + content: str, + api_key: Optional[str] = None, + workspace: Optional[str] = None, + certificate: Optional[Dict[str, Any]] = None, +) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Upload ``content`` to OSS when it names a local file. + + Returns ``(is_upload, url_or_content, certificate)``. An existing + ``oss://`` URL passes through untouched but still reports + ``is_upload=True``, so the caller opts the request into OSS resolution; + ``http(s)://`` and anything unrecognized pass through as ``False``. + """ + if content.startswith(_FILE_URI_SCHEME): + file_path = _resolve_file_uri_path(content) + if not os.path.isfile(file_path): + raise ValueError(f"The file: {file_path} is not exists!") + url, cert = upload_local_file( + client, + model, + file_path, + api_key=api_key, + workspace=workspace, + certificate=certificate, + ) + return True, url, cert + if content.startswith(_OSS_SCHEME): + return True, content, certificate + if not content.startswith("http"): + content = os.path.expanduser(content) + if os.path.isfile(content): + url, cert = upload_local_file( + client, + model, + content, + api_key=api_key, + workspace=workspace, + certificate=certificate, + ) + return True, url, cert + return False, content, certificate diff --git a/dashscope/v2/resources/_sse.py b/dashscope/resources/_sse.py similarity index 98% rename from dashscope/v2/resources/_sse.py rename to dashscope/resources/_sse.py index d30104ee..2d4f1de3 100644 --- a/dashscope/v2/resources/_sse.py +++ b/dashscope/resources/_sse.py @@ -13,7 +13,7 @@ import json from typing import Any, AsyncIterator, Iterator, Optional -from dashscope.v2.errors import ( +from dashscope.errors import ( DashScopeError, error_from_business_code, error_from_status, diff --git a/dashscope/v2/resources/apps.py b/dashscope/resources/apps.py similarity index 98% rename from dashscope/v2/resources/apps.py rename to dashscope/resources/apps.py index 58025941..422ec276 100644 --- a/dashscope/v2/resources/apps.py +++ b/dashscope/resources/apps.py @@ -23,11 +23,12 @@ from pydantic import BaseModel, ConfigDict -from dashscope.v2.errors import ( +from dashscope.errors import ( check_response, error_from_business_code, + response_json, ) -from dashscope.v2.types.common import ( +from dashscope.types.common import ( ResponseBase, Usage, flatten_envelope, @@ -445,7 +446,7 @@ def create( # pylint: disable=too-many-locals workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) def _events(self, response) -> Iterator[AppCompletion]: try: @@ -543,7 +544,7 @@ async def create( # pylint: disable=too-many-locals workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) async def _events(self, response) -> AsyncIterator[AppCompletion]: try: diff --git a/dashscope/v2/resources/audio/__init__.py b/dashscope/resources/audio/__init__.py similarity index 55% rename from dashscope/v2/resources/audio/__init__.py rename to dashscope/resources/audio/__init__.py index 300bff25..71a972a2 100644 --- a/dashscope/v2/resources/audio/__init__.py +++ b/dashscope/resources/audio/__init__.py @@ -10,12 +10,13 @@ ``voices`` / ``voice_design``. """ -from dashscope.v2.resources.audio.asr import ( +from dashscope.resources._blocking import run_blocking +from dashscope.resources.audio.asr import ( Asr, AsyncAsr, Recognition, ) -from dashscope.v2.resources.audio.customization import ( +from dashscope.resources.audio.customization import ( DesignedVoice, DesignedVoiceList, Vocabularies, @@ -26,23 +27,25 @@ VoiceList, Voices, ) -from dashscope.v2.resources.audio.http_tts import ( +from dashscope.resources.audio.http_tts import ( + AsyncHttpTts, HttpSynthesis, HttpSynthesisChunk, HttpSynthesisStream, HttpTts, ) -from dashscope.v2.resources.audio.phrases import ( +from dashscope.resources.audio.phrases import ( Phrase, PhraseList, Phrases, ) -from dashscope.v2.resources.audio.realtime import Realtime, RealtimeSession -from dashscope.v2.resources.audio.tts import SynthesisSession, Tts +from dashscope.resources.audio.realtime import Realtime, RealtimeSession +from dashscope.resources.audio.tts import SynthesisSession, Tts __all__ = [ "Asr", "AsyncAsr", + "AsyncHttpTts", "AsyncAudio", "Audio", "DesignedVoice", @@ -69,12 +72,39 @@ ] -class Audio: - """Sync audio surface. +def _call_resource(client, resource_cls, name, args, kwargs): + resource = resource_cls(client) + return getattr(resource, name)(*args, **kwargs) + + +class _AsyncResourceProxy: + """Run sync response parsing in a worker while reusing AsyncTransport.""" + + def __init__(self, client, resource_cls) -> None: + self._client = client + self._resource_cls = resource_cls + + def __getattr__(self, name): + async def call(*args, **kwargs): + return await run_blocking( + self._client, + _call_resource, + self._resource_cls, + name, + args, + kwargs, + ) + + return call - Preview: the 2.0 audio API surface is not final and may change in - a future release. - """ + +class _AsyncTts: + def __init__(self, client) -> None: + self.http = AsyncHttpTts(client) + + +class Audio: + """Sync audio surface.""" def __init__(self, client) -> None: self.asr = Asr(client) @@ -92,10 +122,12 @@ 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: self.asr = AsyncAsr(client) + self.tts = _AsyncTts(client) + self.vocabularies = _AsyncResourceProxy(client, Vocabularies) + self.phrases = _AsyncResourceProxy(client, Phrases) + self.voices = _AsyncResourceProxy(client, Voices) + self.voice_design = _AsyncResourceProxy(client, VoiceDesign) diff --git a/dashscope/v2/resources/audio/_callbacks.py b/dashscope/resources/audio/_callbacks.py similarity index 97% rename from dashscope/v2/resources/audio/_callbacks.py rename to dashscope/resources/audio/_callbacks.py index aea8fc1f..145f76c9 100644 --- a/dashscope/v2/resources/audio/_callbacks.py +++ b/dashscope/resources/audio/_callbacks.py @@ -13,7 +13,7 @@ from typing import Any, Iterable -from dashscope.v2.transport.ws import EVENT_FINISHED +from dashscope.transport.ws import EVENT_FINISHED __all__ = [ "run_with_callback", diff --git a/dashscope/v2/resources/audio/asr.py b/dashscope/resources/audio/asr.py similarity index 98% rename from dashscope/v2/resources/audio/asr.py rename to dashscope/resources/audio/asr.py index 6e49078f..81f00822 100644 --- a/dashscope/v2/resources/audio/asr.py +++ b/dashscope/resources/audio/asr.py @@ -28,8 +28,8 @@ from pydantic import BaseModel, ConfigDict, Field -from dashscope.v2.errors import DashScopeError, error_from_business_code -from dashscope.v2.resources._jobs import ( +from dashscope.errors import DashScopeError, error_from_business_code +from dashscope.resources._jobs import ( CANCELED, DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, @@ -39,8 +39,8 @@ Job, Jobs, ) -from dashscope.v2.transport.ws import Connector, EVENT_GENERATED, WsSession -from dashscope.v2.transport.ws_connection import WsConnection, WsTask +from dashscope.transport.ws import Connector, EVENT_GENERATED, WsSession +from dashscope.transport.ws_connection import WsConnection, WsTask __all__ = [ "Asr", diff --git a/dashscope/v2/resources/audio/customization.py b/dashscope/resources/audio/customization.py similarity index 84% rename from dashscope/v2/resources/audio/customization.py rename to dashscope/resources/audio/customization.py index 89675473..953363b2 100644 --- a/dashscope/v2/resources/audio/customization.py +++ b/dashscope/resources/audio/customization.py @@ -4,12 +4,17 @@ 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 +cloning, ``audio/tts_v2/enrollment.py``), plus voice design (no 1.x SDK +class). All three ride the same envelope: ``POST /services/audio/{asr|tts}/ +customization`` with ``{model, input: {action, ...}, parameters?}``. + +Cloning and design are two doors onto one registry: both post +``model="voice-enrollment"`` to the tts path with the same action words and +the same ``voice_id``, and they share a single per-account voice quota. +They differ only in how a voice is created -- from a sample ``url``, or from +a ``voice_prompt`` describing the wanted sound. + +1.x retried transport failures inside these services; v2 leaves that to the transport's retry loop. """ @@ -19,7 +24,7 @@ from pydantic import AliasChoices, Field -from dashscope.v2.types.common import ( +from dashscope.types.common import ( JobStatus, ResponseBase, flatten_envelope, @@ -77,12 +82,21 @@ class VoiceList(ResponseBase): class DesignedVoice(ResponseBase): - """One designed voice; its id field is ``voice`` on the wire.""" + """One designed voice. + + The same registry entry as a cloned :class:`Voice` -- one endpoint, one + ``voice_id``, one shared quota -- plus the two texts it was designed + from. + """ - voice: Optional[str] = None + voice_id: Optional[str] = None + status: Optional[str] = None preview_audio: Optional[Dict[str, Any]] = None - language: Optional[str] = None target_model: Optional[str] = None + voice_prompt: Optional[str] = None + preview_text: Optional[str] = None + gmt_create: Optional[str] = None + gmt_modified: Optional[str] = None class DesignedVoiceList(ResponseBase): @@ -293,7 +307,7 @@ def delete( class Voices(_CustomizationBase): - """``client.audio.voices`` -- voice cloning (声音复刻).""" + """``client.audio.voices`` -- voice cloning.""" PATH = "/services/audio/tts/customization" DEFAULT_MODEL = "voice-enrollment" @@ -453,24 +467,25 @@ def delete( class VoiceDesign(_CustomizationBase): - """``client.audio.voice_design`` -- qwen voice design (声音设计). + """``client.audio.voice_design`` -- voices described in words, not audio. - 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``. + Shares the voice-enrollment registry with :class:`Voices`: same path, + same ``model``, same action words, same ``voice_id``, one quota. Only + ``create`` differs -- a text prompt where cloning has a sample ``url`` + -- so ``list`` returns cloned voices as well as designed ones. """ PATH = "/services/audio/tts/customization" - DEFAULT_MODEL = "qwen-voice-design" + DEFAULT_MODEL = "voice-enrollment" def create( self, *, target_model: str, + prefix: str, voice_prompt: str, preview_text: str, - preferred_name: Optional[str] = None, - language: Optional[str] = None, + language_hints: Optional[List[str]] = None, extra_input: Optional[Dict[str, Any]] = None, model: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None, @@ -479,21 +494,28 @@ def create( api_key: Optional[str] = None, workspace: Optional[str] = None, ) -> DesignedVoice: - """Design a voice from a text prompt; carries ``voice`` + preview.""" + """Design a voice from a text prompt; carries ``voice_id`` + preview. + + ``prefix`` becomes part of the generated id, + ``{target_model}-vd-{prefix}-{unique}``. Only the first element of + ``language_hints`` is honoured, and it must match the language of + ``preview_text``. The voice is unusable until its ``status`` leaves + ``DEPLOYING``. + """ 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, "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 + if language_hints is not None: + fields["language_hints"] = language_hints response = self._call( - "create", + "create_voice", fields, model=model, parameters=parameters, @@ -510,6 +532,7 @@ def create( def list( self, *, + prefix: Optional[str] = None, page_index: int = 0, page_size: int = 10, model: Optional[str] = None, @@ -518,10 +541,16 @@ def list( api_key: Optional[str] = None, workspace: Optional[str] = None, ) -> DesignedVoiceList: - """List designed voices.""" + """List 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", - {"page_index": page_index, "page_size": page_size}, + "list_voice", + fields, model=model, timeout=timeout, headers=headers, @@ -535,7 +564,7 @@ def list( def get( self, - voice: str, + voice_id: str, *, model: Optional[str] = None, timeout: Optional[float] = None, @@ -543,11 +572,11 @@ def get( api_key: Optional[str] = None, workspace: Optional[str] = None, ) -> DesignedVoice: - """Fetch one designed voice (wire action ``query``).""" - _require_id(voice, "voice") + """Fetch one voice's details, including its review ``status``.""" + _require_id(voice_id, "voice_id") response = self._call( - "query", - {"voice": voice}, + "query_voice", + {"voice_id": voice_id}, model=model, timeout=timeout, headers=headers, @@ -561,7 +590,7 @@ def get( def delete( self, - voice: str, + voice_id: str, *, model: Optional[str] = None, timeout: Optional[float] = None, @@ -569,11 +598,11 @@ def delete( api_key: Optional[str] = None, workspace: Optional[str] = None, ) -> JobStatus: - """Delete one designed voice.""" - _require_id(voice, "voice") + """Delete one designed voice, freeing its quota.""" + _require_id(voice_id, "voice_id") response = self._call( - "delete", - {"voice": voice}, + "delete_voice", + {"voice_id": voice_id}, model=model, timeout=timeout, headers=headers, diff --git a/dashscope/v2/resources/audio/http_tts.py b/dashscope/resources/audio/http_tts.py similarity index 73% rename from dashscope/v2/resources/audio/http_tts.py rename to dashscope/resources/audio/http_tts.py index cc0edadc..002439fe 100644 --- a/dashscope/v2/resources/audio/http_tts.py +++ b/dashscope/resources/audio/http_tts.py @@ -20,6 +20,7 @@ import json from typing import ( Any, + AsyncIterator, Dict, Generator, Iterator, @@ -29,14 +30,14 @@ Tuple, ) -from dashscope.v2.errors import ( +from dashscope.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 +from dashscope.resources._sse import _try_json +from dashscope.types.common import ResponseBase, response_body, with_headers __all__ = [ "HttpSynthesis", @@ -331,6 +332,145 @@ def close(self) -> None: self._response.close() +class AsyncHttpSynthesisStream: + """Async typed iterator over HTTP synthesis SSE parts.""" + + def __init__( + self, + inner: AsyncIterator[HttpSynthesisChunk], + response, + ) -> None: + self._inner = inner + self._response = response + + def __aiter__(self): + return self._inner + + @property + def headers(self) -> Dict[str, str]: + return dict(self._response.headers) + + async def aclose(self) -> None: + await self._response.aclose() + + +class AsyncHttpTts: + """Async HTTP TTS using the AsyncClient's own connection pool.""" + + def __init__(self, client) -> None: + self._client = client + + async def synthesize(self, text: str, **kwargs) -> HttpSynthesis: + model = kwargs.pop("model") + voice = kwargs.pop("voice") + response = await self._client.transport.request( + "POST", + _path_for(model), + json=_build_body( + text, + model=model, + voice=voice, + **{ + key: kwargs.pop(key, None) + for key in ( + "format", + "sample_rate", + "language_type", + "parameters", + "extra_input", + ) + }, + ), + headers=kwargs.pop("headers", None), + timeout=kwargs.pop("timeout", None), + sdk_module=MODULE, + api_key=kwargs.pop("api_key", None), + workspace=kwargs.pop("workspace", None), + ) + return with_headers( + _synthesis_from_body( + response_body( + response, + raise_on_error=self._client.raise_on_error, + ), + response.headers.get("x-request-id", ""), + ), + response, + ) + + async def stream(self, text: str, **kwargs) -> AsyncHttpSynthesisStream: + model = kwargs.pop("model") + voice = kwargs.pop("voice") + headers = dict(_SSE_HEADER) + headers.update(kwargs.pop("headers", None) or {}) + response = await self._client.transport.open_stream( + "POST", + _path_for(model), + json=_build_body( + text, + model=model, + voice=voice, + **{ + key: kwargs.pop(key, None) + for key in ( + "format", + "sample_rate", + "language_type", + "parameters", + "extra_input", + ) + }, + ), + headers=headers, + timeout=kwargs.pop("timeout", None), + sdk_module=MODULE, + api_key=kwargs.pop("api_key", None), + workspace=kwargs.pop("workspace", None), + ) + + async def events(): + decoder = _SSEDecoder() + header_id = response.headers.get("x-request-id", "") + try: + media_type = response.headers.get("content-type", "") + media_type = media_type.split(";", 1)[0].strip().lower() + if media_type == "application/json" or media_type.endswith( + "+json", + ): + # A 200 JSON envelope is a service failure, not audio + # SSE; mirror _iter_audio_sse so the error surfaces + # instead of yielding zero chunks silently. + await response.aread() + response_body(response) + return + async for line in response.aiter_lines(): + frame = decoder.feed(line) + if frame is None: + continue + is_error, status, data = frame + if is_error: + raise _audio_stream_error( + status, + data, + response.headers, + ) + yield _chunk_from_data(data, header_id) + frame = decoder.feed("") + if frame is not None: + is_error, status, data = frame + if is_error: + raise _audio_stream_error( + status, + data, + response.headers, + ) + yield _chunk_from_data(data, header_id) + finally: + await response.aclose() + + return AsyncHttpSynthesisStream(events(), response) + + class HttpTts: """``client.audio.tts.http`` -- HTTP synthesis for both families.""" diff --git a/dashscope/v2/resources/audio/phrases.py b/dashscope/resources/audio/phrases.py similarity index 99% rename from dashscope/v2/resources/audio/phrases.py rename to dashscope/resources/audio/phrases.py index 6af26877..d6b8f74d 100644 --- a/dashscope/v2/resources/audio/phrases.py +++ b/dashscope/resources/audio/phrases.py @@ -17,7 +17,7 @@ from pydantic import AliasChoices, Field -from dashscope.v2.types.common import ( +from dashscope.types.common import ( JobStatus, ResponseBase, flatten_envelope, diff --git a/dashscope/v2/resources/audio/realtime.py b/dashscope/resources/audio/realtime.py similarity index 97% rename from dashscope/v2/resources/audio/realtime.py rename to dashscope/resources/audio/realtime.py index a45fca85..576a3987 100644 --- a/dashscope/v2/resources/audio/realtime.py +++ b/dashscope/resources/audio/realtime.py @@ -12,11 +12,15 @@ 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 +from dashscope._config import WS_REALTIME_PATH, workspace_ws_url +from dashscope.errors import ( + APIConnectionError, + APITimeoutError, + error_from_business_code, +) +from dashscope.transport import default_user_agent +from dashscope.transport._ident import sdk_headers +from dashscope.transport.ws import Connector, _close_websocket __all__ = [ "Realtime", @@ -180,9 +184,13 @@ def connect(self) -> "RealtimeSession": self._ws = self._connector(self._url, self._headers) if self._timeout is not None: self._ws.settimeout(self._timeout) - except BaseException: + except BaseException as exc: self.close() self._reset_state() + from websocket import WebSocketTimeoutException + + if isinstance(exc, WebSocketTimeoutException): + raise APITimeoutError(detail=str(exc)) from exc raise self._reset_state() return self @@ -659,6 +667,7 @@ def recv_event(self) -> Optional[Dict[str, Any]]: """Receive a raw event; unfinished responses cannot end silently.""" from websocket import ( # pylint: disable=import-outside-toplevel WebSocketConnectionClosedException, + WebSocketTimeoutException, ) # Network reads must not hold the lock needed by send_event() or close(). @@ -676,6 +685,8 @@ def recv_event(self) -> Optional[Dict[str, Any]]: raw = ws.recv() except WebSocketConnectionClosedException: raw = None + except WebSocketTimeoutException as exc: + raise APITimeoutError(detail=str(exc)) from exc with self._lock: if generation != self._generation: return None diff --git a/dashscope/v2/resources/audio/tts.py b/dashscope/resources/audio/tts.py similarity index 98% rename from dashscope/v2/resources/audio/tts.py rename to dashscope/resources/audio/tts.py index cf8c5321..383585f0 100644 --- a/dashscope/v2/resources/audio/tts.py +++ b/dashscope/resources/audio/tts.py @@ -28,9 +28,9 @@ 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 +from dashscope.resources.audio.http_tts import HttpTts +from dashscope.transport.ws import Connector, WsSession +from dashscope.transport.ws_connection import WsConnection, WsTask __all__ = [ "SynthesisSession", diff --git a/dashscope/v2/resources/chat/__init__.py b/dashscope/resources/chat/__init__.py similarity index 82% rename from dashscope/v2/resources/chat/__init__.py rename to dashscope/resources/chat/__init__.py index 60af2252..539f3ad3 100644 --- a/dashscope/v2/resources/chat/__init__.py +++ b/dashscope/resources/chat/__init__.py @@ -2,12 +2,12 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """client.chat resource.""" -from dashscope.v2.resources.chat.completions import ( +from dashscope.resources.chat.completions import ( AsyncCompletions, Completions, ) -from dashscope.v2.types.common import Usage -from dashscope.v2.types.completions import ( +from dashscope.types.common import Usage +from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ChatCompletionMessage, diff --git a/dashscope/resources/chat/_params.py b/dashscope/resources/chat/_params.py new file mode 100644 index 00000000..134a0e66 --- /dev/null +++ b/dashscope/resources/chat/_params.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Request-parameter rules that depend on the model name.""" + +from __future__ import annotations + +# tts / omni / qwen-deep-research honor incremental_output=False on the +# server; every other model streams incrementally only, so the SDK has to +# keep the wire incremental and assemble the cumulative view itself. +_SERVER_SIDE_MODELS = ("tts", "omni", "qwen-deep-research") + + +def should_modify_incremental_output(model_name: str) -> bool: + """Whether the SDK must build cumulative frames client-side. + + ``False`` for the models that honor ``incremental_output`` server-side. + """ + if not isinstance(model_name, str): + return True + lowered = model_name.lower() + return not any(name in lowered for name in _SERVER_SIDE_MODELS) diff --git a/dashscope/v2/resources/chat/completions.py b/dashscope/resources/chat/completions.py similarity index 89% rename from dashscope/v2/resources/chat/completions.py rename to dashscope/resources/chat/completions.py index 17c2b1c2..ac195e79 100644 --- a/dashscope/v2/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -8,21 +8,23 @@ 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 ( +from dashscope.errors import response_error, response_json +from dashscope.protocols.base import ChatRequest +from dashscope.resources._blocking import run_blocking +from dashscope.resources._oss import ( OSS_RESOLVE_HEADERS, upload_message_media, ) -from dashscope.v2.types.common import Usage, with_headers -from dashscope.v2.types.completions import ( +from dashscope.resources.chat._params import ( + should_modify_incremental_output, +) +from dashscope.types.common import Usage, with_headers +from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ChatCompletionMessage, @@ -30,8 +32,8 @@ SearchInfo, StreamChoice, ) -from dashscope.v2.transport.sse import AsyncStream, Stream -from dashscope.v2.types.messages import ( +from dashscope.transport.sse import AsyncStream, Stream +from dashscope.types.messages import ( Message, MessageInput, ToolCall, @@ -103,7 +105,7 @@ def _build_request( ) -> 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. + # in dashscope.legacy_1x.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 @@ -114,7 +116,7 @@ def _build_request( 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) + server_side = not 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 @@ -382,6 +384,27 @@ def _merge_tool_calls( return merged +def _signature_from_blocks(blocks: Any) -> Optional[str]: + """Replay signature only when exactly one signed thinking block exists. + + Mirrors the non-streaming Anthropic invariant: concatenating the + per-block signatures of an interleaved-thinking turn yields a single + invalid signature the API would reject, so emit none in that case. + """ + if not isinstance(blocks, list): + return None + signed = [ + block + for block in blocks + if isinstance(block, dict) + and block.get("type") == "thinking" + and block.get("signature") + ] + if len(signed) == 1: + return signed[0]["signature"] + return None + + def _cumulative_chunk( acc: Dict[int, Dict[str, Any]], chunk: ChatCompletionChunk, @@ -396,6 +419,7 @@ def _cumulative_chunk( "content": "", "reasoning": "", "signature": "", + "reasoning_blocks": None, "tool_calls": None, "logprobs": None, }, @@ -409,6 +433,10 @@ def _cumulative_chunk( slot["reasoning"] += delta.reasoning_content if delta.reasoning_signature: slot["signature"] += delta.reasoning_signature + blocks = (delta.model_extra or {}).get("reasoning_blocks") + if blocks is not None: + # Anthropic emits a cumulative snapshot; keep the latest. + slot["reasoning_blocks"] = blocks if delta.tool_calls: slot["tool_calls"] = _merge_tool_calls( slot["tool_calls"], @@ -421,20 +449,27 @@ def _cumulative_chunk( slot["logprobs"], (choice.model_extra or {}).get("logprobs"), ) + # When verbatim thinking blocks are present, derive the replay + # signature from them (single-signed-block rule) instead of the + # concatenated per-block fragments, which would be invalid. + signature = slot["signature"] or None + if slot["reasoning_blocks"] is not None: + signature = _signature_from_blocks(slot["reasoning_blocks"]) # model_copy keeps provider extras the rebuild would otherwise # drop (delta-level omni audio); accumulated logprobs override # the current frame's fragment. - 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"], - }, - ), - } + new_delta = choice.delta.model_copy( + update={ + "role": slot["role"], + "content": slot["content"] or None, + "reasoning_content": slot["reasoning"] or None, + "reasoning_signature": signature, + "tool_calls": slot["tool_calls"], + }, + ) + if slot["reasoning_blocks"] is not None: + new_delta.reasoning_blocks = slot["reasoning_blocks"] + update: Dict[str, Any] = {"delta": new_delta} if slot["logprobs"] is not None: update["logprobs"] = slot["logprobs"] choices.append(choice.model_copy(update=update)) @@ -521,6 +556,7 @@ def _aggregate(chunks) -> ChatCompletion: # pylint: disable=too-many-branches "content": [], "reasoning": [], "signature": [], + "reasoning_blocks": None, "message": {}, "logprobs": None, }, @@ -534,6 +570,10 @@ def _aggregate(chunks) -> ChatCompletion: # pylint: disable=too-many-branches slot["reasoning"].append(delta.reasoning_content) if delta.reasoning_signature: slot["signature"].append(delta.reasoning_signature) + blocks = (delta.model_extra or {}).get("reasoning_blocks") + if blocks is not None: + # Anthropic emits a cumulative snapshot; keep the latest. + slot["reasoning_blocks"] = blocks if delta.tool_calls: slot["message"]["tool_calls"] = _merge_tool_calls( slot["message"].get("tool_calls"), @@ -550,22 +590,30 @@ def _aggregate(chunks) -> ChatCompletion: # pylint: disable=too-many-branches choices = [] for index in sorted(acc): slot = acc[index] + signature = "".join(slot["signature"]) or None + message_extras: Dict[str, Any] = {} + if slot["reasoning_blocks"] is not None: + # Verbatim thinking blocks win: derive the replay signature + # from them and carry them so the turn replays faithfully. + message_extras["reasoning_blocks"] = slot["reasoning_blocks"] + signature = _signature_from_blocks(slot["reasoning_blocks"]) 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, + reasoning_signature=signature, tool_calls=slot["message"].get("tool_calls"), + **message_extras, ) - extras: Dict[str, Any] = {} + choice_extras: Dict[str, Any] = {} if slot["logprobs"] is not None: - extras["logprobs"] = slot["logprobs"] + choice_extras["logprobs"] = slot["logprobs"] choices.append( Choice( index=index, finish_reason=slot["message"].get("finish_reason"), message=message, - **extras, + **choice_extras, ), ) return ChatCompletion( @@ -643,7 +691,7 @@ def _send( if error_result is not None: return error_result return with_headers( - client.protocol.parse_completion(response.json()), + client.protocol.parse_completion(response_json(response)), response, ) @@ -694,7 +742,7 @@ async def _asend( if error_result is not None: return error_result return with_headers( - client.protocol.parse_completion(response.json()), + client.protocol.parse_completion(response_json(response)), response, ) @@ -949,11 +997,12 @@ async def create( 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, + # The upload path is blocking (file post + getPolicy), so the whole + # request build runs in a worker thread -- over a transport bridged + # back to this loop, which is what lets it serve the policy fetch. + built = await run_blocking( self._client, + _build_request, multimodal=False, model=model, messages=messages, diff --git a/dashscope/v2/resources/decision.py b/dashscope/resources/decision.py similarity index 97% rename from dashscope/v2/resources/decision.py rename to dashscope/resources/decision.py index a89f9519..9a444d64 100644 --- a/dashscope/v2/resources/decision.py +++ b/dashscope/resources/decision.py @@ -24,9 +24,9 @@ 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 +from dashscope._config import Config, compatible_mode_base_url +from dashscope.errors import response_error, response_json +from dashscope.types.common import ResponseBase, Usage, with_headers _PATH = "/systemone" # Functional-area tag for x-dashscope-sdk-client. The decision model is @@ -193,7 +193,7 @@ def _checked_response(client, response) -> DecisionResponse: """ error = response_error(response) if error is None: - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) if client.raise_on_error: raise error return with_headers( diff --git a/dashscope/v2/resources/deployments.py b/dashscope/resources/deployments.py similarity index 93% rename from dashscope/v2/resources/deployments.py rename to dashscope/resources/deployments.py index ba68dfa3..34c29241 100644 --- a/dashscope/v2/resources/deployments.py +++ b/dashscope/resources/deployments.py @@ -22,14 +22,14 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field -from dashscope.v2.errors import check_response -from dashscope.v2.resources._sse import ( +from dashscope.errors import check_response, response_json +from dashscope.resources._sse import ( _aiter_sse, _iter_sse, _stream_error, _try_json, ) -from dashscope.v2.types.common import ( +from dashscope.types.common import ( JobStatus, ResponseBase, flatten_envelope, @@ -184,7 +184,7 @@ def create( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) def get( self, @@ -203,7 +203,7 @@ def get( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) def list( self, @@ -224,7 +224,7 @@ def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) def scale( self, @@ -245,7 +245,7 @@ def scale( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) def delete( self, @@ -265,7 +265,7 @@ def delete( ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**flatten_envelope(response_json(response))), response, ) @@ -339,7 +339,7 @@ async def create( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) async def get( self, @@ -358,7 +358,7 @@ async def get( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) async def list( self, @@ -379,7 +379,7 @@ async def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) async def scale( self, @@ -400,7 +400,7 @@ async def scale( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) async def delete( self, @@ -420,7 +420,7 @@ async def delete( ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - JobStatus(**flatten_envelope(response.json())), + JobStatus(**flatten_envelope(response_json(response))), response, ) diff --git a/dashscope/v2/resources/embeddings.py b/dashscope/resources/embeddings.py similarity index 97% rename from dashscope/v2/resources/embeddings.py rename to dashscope/resources/embeddings.py index f5614de5..b2ae1508 100644 --- a/dashscope/v2/resources/embeddings.py +++ b/dashscope/resources/embeddings.py @@ -14,8 +14,8 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field -from dashscope.v2.errors import check_response -from dashscope.v2.resources._jobs import ( +from dashscope.errors import check_response, response_json +from dashscope.resources._jobs import ( DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, @@ -24,7 +24,7 @@ JobList, Jobs, ) -from dashscope.v2.types.common import ( +from dashscope.types.common import ( ResponseBase, Usage, response_envelope, @@ -380,7 +380,7 @@ def create( ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - parse_embedding_response(response.json()), + parse_embedding_response(response_json(response)), response, ) @@ -424,6 +424,6 @@ async def create( ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - parse_embedding_response(response.json()), + parse_embedding_response(response_json(response)), response, ) diff --git a/dashscope/v2/resources/files.py b/dashscope/resources/files.py similarity index 87% rename from dashscope/v2/resources/files.py rename to dashscope/resources/files.py index 3d1b94e2..e064350c 100644 --- a/dashscope/v2/resources/files.py +++ b/dashscope/resources/files.py @@ -11,13 +11,15 @@ from pydantic import AliasChoices, Field -from dashscope.v2.errors import ( +from dashscope.errors import ( BadRequestError, NotFoundError, check_response, response_error, + sdk_error, + response_json, ) -from dashscope.v2.types.common import ( +from dashscope.types.common import ( ResponseBase, response_envelope, with_headers, @@ -123,15 +125,24 @@ def _parse_delete(body: Dict[str, Any]) -> DeletionStatus: 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 + try: + with open(file_path, encoding="utf-8") as f: + for line in f: + try: + json.loads(line) + except json.decoder.JSONDecodeError: + return False + except OSError as exc: + raise _io_error(exc) from exc return True +def _io_error(exc: BaseException) -> Exception: + # Local filesystem failures are SDK-side, not gateway errors: the + # catalog's sdk.IoError entry supplies message/code/solution. + return sdk_error("sdk.IoError", {"detail": str(exc)}) + + def _validate_upload(file_path: str, purpose: str) -> None: if purpose in ("fine-tune", "fine_tune"): if not _is_valid_jsonl(file_path): @@ -139,17 +150,20 @@ def _validate_upload(file_path: str, purpose: str) -> None: 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}") + raise _io_error(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() + try: + 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() + except OSError as exc: + raise _io_error(exc) from exc return data, {"files": (os.path.basename(file_path), content)} @@ -219,7 +233,7 @@ def upload( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_upload(response.json()), response) + return with_headers(_parse_upload(response_json(response)), response) def list( self, @@ -240,7 +254,7 @@ def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) def get( self, @@ -263,7 +277,7 @@ def get( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_file(response.json()), response) + return with_headers(_parse_file(response_json(response)), response) def delete( self, @@ -286,7 +300,7 @@ def delete( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_delete(response.json()), response) + return with_headers(_parse_delete(response_json(response)), response) class AsyncFiles: @@ -316,7 +330,7 @@ async def upload( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_upload(response.json()), response) + return with_headers(_parse_upload(response_json(response)), response) async def list( self, @@ -337,7 +351,7 @@ async def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) async def get( self, @@ -360,7 +374,7 @@ async def get( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_file(response.json()), response) + return with_headers(_parse_file(response_json(response)), response) async def delete( self, @@ -383,4 +397,4 @@ async def delete( response, raise_on_error=self._client.raise_on_error, ) - return with_headers(_parse_delete(response.json()), response) + return with_headers(_parse_delete(response_json(response)), response) diff --git a/dashscope/v2/resources/fine_tunes.py b/dashscope/resources/fine_tunes.py similarity index 94% rename from dashscope/v2/resources/fine_tunes.py rename to dashscope/resources/fine_tunes.py index e9551869..6946ff55 100644 --- a/dashscope/v2/resources/fine_tunes.py +++ b/dashscope/resources/fine_tunes.py @@ -35,12 +35,13 @@ field_validator, ) -from dashscope.v2.errors import ( +from dashscope.errors import ( DashScopeError, check_response, error_from_business_code, + response_json, ) -from dashscope.v2.resources._jobs import ( +from dashscope.resources._jobs import ( CANCELED, DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, @@ -48,13 +49,13 @@ FAILED, SUCCEEDED, ) -from dashscope.v2.resources._sse import ( +from dashscope.resources._sse import ( _aiter_sse, _iter_sse, _stream_error, _try_json, ) -from dashscope.v2.types.common import ( +from dashscope.types.common import ( JobStatus, ResponseBase, Usage, @@ -271,7 +272,7 @@ def create( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response.json()), response) + return with_headers(_parse_job(response_json(response)), response) def get( self, @@ -290,7 +291,7 @@ def get( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response.json()), response) + return with_headers(_parse_job(response_json(response)), response) def list( self, @@ -311,7 +312,7 @@ def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) def cancel( self, @@ -330,7 +331,7 @@ def cancel( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) def delete( self, @@ -349,7 +350,7 @@ def delete( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) def logs( self, @@ -372,7 +373,10 @@ def logs( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_logs(job_id, response.json()), response) + return with_headers( + _parse_logs(job_id, response_json(response)), + response, + ) def stream_events( self, @@ -496,7 +500,7 @@ async def create( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response.json()), response) + return with_headers(_parse_job(response_json(response)), response) async def get( self, @@ -515,7 +519,7 @@ async def get( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response.json()), response) + return with_headers(_parse_job(response_json(response)), response) async def list( self, @@ -536,7 +540,7 @@ async def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) async def cancel( self, @@ -555,7 +559,7 @@ async def cancel( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) async def delete( self, @@ -574,7 +578,7 @@ async def delete( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) async def logs( self, @@ -597,7 +601,10 @@ async def logs( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_logs(job_id, response.json()), response) + return with_headers( + _parse_logs(job_id, response_json(response)), + response, + ) async def stream_events( self, diff --git a/dashscope/v2/resources/images.py b/dashscope/resources/images.py similarity index 96% rename from dashscope/v2/resources/images.py rename to dashscope/resources/images.py index 7821121b..926cc0a9 100644 --- a/dashscope/v2/resources/images.py +++ b/dashscope/resources/images.py @@ -22,11 +22,11 @@ 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 ( +from dashscope.errors import check_response, response_json +from dashscope.resources._blocking import run_blocking +from dashscope.resources._jobs import ( DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, @@ -36,11 +36,11 @@ JobList, Jobs, ) -from dashscope.v2.resources._oss import ( +from dashscope.resources._oss import ( OSS_RESOLVE_HEADERS, upload_media_value, ) -from dashscope.v2.types.common import with_headers +from dashscope.types.common import with_headers __all__ = [ "AsyncImages", @@ -513,7 +513,7 @@ def generate( raise_on_error=self._client.raise_on_error, ) return with_headers( - _parse_sync_job(response.json()), + _parse_sync_job(response_json(response)), response, ) job = self.jobs.create( @@ -572,11 +572,12 @@ async def create( 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, + # The upload path is blocking (file post + getPolicy), so it runs in + # a worker thread -- over a transport bridged back to this loop, + # which is what lets it serve the policy fetch. + image_urls, extra_input, has_upload = await run_blocking( self._client, + _upload_inputs, model, image_urls, extra_input, @@ -741,11 +742,12 @@ async def generate( ) -> 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, + # The upload path is blocking (file post + getPolicy), so it runs + # in a worker thread -- over a transport bridged back to this + # loop, which is what lets it serve the policy fetch. + image_urls, extra_input, has_upload = await run_blocking( self._client, + _upload_inputs, model, image_urls, extra_input, @@ -781,7 +783,7 @@ async def generate( raise_on_error=self._client.raise_on_error, ) return with_headers( - _parse_sync_job(response.json()), + _parse_sync_job(response_json(response)), response, ) job = await self.jobs.create( diff --git a/dashscope/v2/resources/models.py b/dashscope/resources/models.py similarity index 96% rename from dashscope/v2/resources/models.py rename to dashscope/resources/models.py index 066e10e6..633229ac 100644 --- a/dashscope/v2/resources/models.py +++ b/dashscope/resources/models.py @@ -8,12 +8,13 @@ from pydantic import AliasChoices, Field -from dashscope.v2.errors import ( +from dashscope.errors import ( ErrorCode, check_response, error_from_business_code, + response_json, ) -from dashscope.v2.types.common import ( +from dashscope.types.common import ( ResponseBase, response_envelope, with_headers, @@ -118,7 +119,7 @@ def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list(response.json()) + result = _parse_list(response_json(response)) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -144,7 +145,7 @@ def get( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - body = response.json() + body = response_json(response) models = _parse_get_models(body) if not models: if self._client.raise_on_error: @@ -185,7 +186,7 @@ async def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - result = _parse_list(response.json()) + result = _parse_list(response_json(response)) if provider: result.models = [ m for m in result.models if _match_provider(m, provider) @@ -211,7 +212,7 @@ async def get( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - body = response.json() + body = response_json(response) models = _parse_get_models(body) if not models: if self._client.raise_on_error: diff --git a/dashscope/v2/resources/multimodal/__init__.py b/dashscope/resources/multimodal/__init__.py similarity index 85% rename from dashscope/v2/resources/multimodal/__init__.py rename to dashscope/resources/multimodal/__init__.py index 4569217d..07f7c3b3 100644 --- a/dashscope/v2/resources/multimodal/__init__.py +++ b/dashscope/resources/multimodal/__init__.py @@ -2,11 +2,11 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """client.multimodal resource.""" -from dashscope.v2.resources.multimodal.chat import ( +from dashscope.resources.multimodal.chat import ( AsyncMultimodalChat, MultimodalChat, ) -from dashscope.v2.resources.multimodal.embeddings import ( +from dashscope.resources.multimodal.embeddings import ( AsyncMultimodalEmbeddings, MultimodalEmbeddings, ) diff --git a/dashscope/v2/resources/multimodal/chat.py b/dashscope/resources/multimodal/chat.py similarity index 96% rename from dashscope/v2/resources/multimodal/chat.py rename to dashscope/resources/multimodal/chat.py index 2490f086..2ca02e3d 100644 --- a/dashscope/v2/resources/multimodal/chat.py +++ b/dashscope/resources/multimodal/chat.py @@ -15,20 +15,20 @@ 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 ( +from dashscope.resources._blocking import run_blocking +from dashscope.resources.chat.completions import ( AsyncChatStream, ChatStream, _asend, _build_request, _send, ) -from dashscope.v2.types.completions import ChatCompletion -from dashscope.v2.types.messages import MessageInput +from dashscope.types.completions import ChatCompletion +from dashscope.types.messages import MessageInput __all__ = [ "AsyncMultimodalChat", @@ -286,11 +286,12 @@ async def create( 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, + # The upload path is blocking (file post + getPolicy), so the whole + # request build runs in a worker thread -- over a transport bridged + # back to this loop, which is what lets it serve the policy fetch. + built = await run_blocking( self._client, + _build_request, multimodal=True, model=model, messages=messages, diff --git a/dashscope/v2/resources/multimodal/embeddings.py b/dashscope/resources/multimodal/embeddings.py similarity index 91% rename from dashscope/v2/resources/multimodal/embeddings.py rename to dashscope/resources/multimodal/embeddings.py index f214c01d..aee7379d 100644 --- a/dashscope/v2/resources/multimodal/embeddings.py +++ b/dashscope/resources/multimodal/embeddings.py @@ -7,18 +7,18 @@ 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 ( +from dashscope.errors import check_response, response_json +from dashscope.resources._blocking import run_blocking +from dashscope.resources._oss import upload_embedding_media +from dashscope.resources.embeddings import ( EmbeddingResponse, parse_embedding_response, ) -from dashscope.v2.types.common import with_headers +from dashscope.types.common import with_headers __all__ = [ "AsyncMultimodalEmbeddings", @@ -178,7 +178,7 @@ def create( # pylint: disable=too-many-arguments ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - parse_embedding_response(response.json()), + parse_embedding_response(response_json(response)), response, ) @@ -205,11 +205,12 @@ async def create( # pylint: disable=too-many-arguments 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, + # The upload path is blocking (file post + getPolicy), so it runs in + # a worker thread -- over a transport bridged back to this loop, + # which is what lets it serve the policy fetch. + await run_blocking( self._client, + upload_embedding_media, contents, model, api_key, @@ -239,6 +240,6 @@ async def create( # pylint: disable=too-many-arguments ) check_response(response, raise_on_error=self._client.raise_on_error) return with_headers( - parse_embedding_response(response.json()), + parse_embedding_response(response_json(response)), response, ) diff --git a/dashscope/v2/resources/reinforcement_learning.py b/dashscope/resources/reinforcement_learning.py similarity index 74% rename from dashscope/v2/resources/reinforcement_learning.py rename to dashscope/resources/reinforcement_learning.py index ce80824c..35d9f3fd 100644 --- a/dashscope/v2/resources/reinforcement_learning.py +++ b/dashscope/resources/reinforcement_learning.py @@ -2,12 +2,10 @@ # 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. +The agentic-RL workflow lives in the 2.0 package +(``dashscope.applications.finetune``); this layer only mounts it on the 2.0 Client. +Job management and workflow steps both raise ``dashscope.errors`` +natively, so one ``except DashScopeError`` covers both. Two halves, matching the rest of the SDK: ``rl.jobs`` is the training job lifecycle (``create`` submits and never blocks, ``wait`` blocks, @@ -15,38 +13,44 @@ itself carries the RL-specific workflow steps that have no ``fine_tunes`` equivalent, including the function-instance queries. +The two halves also identify themselves differently on the wire, and +that split predates this layer: ``rl.jobs`` rides the v2 transport and +sends the marked ``finetune2`` module tag, while the workflow/submit +path keeps sending the unmarked ``finetune`` tag the feature has always +sent. Both are backend statistics contracts, so neither is folded into +the other. + ``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. +public methods without ``timeout=``: the workflow implementation they +wrap accepts none, so declaring it here would silently do nothing. The +other ``jobs`` methods take ``timeout=`` as everywhere else. Those same +workflow-wrapping methods accept a per-call ``api_key`` but no +``workspace``: ``AgenticRL`` 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 ( +from dashscope.errors import ( DashScopeError, check_response, error_from_business_code, - error_from_sdk_exception, + response_json, ) -from dashscope.v2.resources._jobs import ( +from dashscope.resources._jobs import ( CANCELED, DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, FAILED, ) -from dashscope.v2.resources.fine_tunes import ( +from dashscope.resources.fine_tunes import ( MODULE, FineTune, FineTuneList, @@ -58,7 +62,7 @@ _parse_logs, _parse_status, ) -from dashscope.v2.types.common import JobStatus, with_headers +from dashscope.types.common import JobStatus, with_headers __all__ = [ "AsyncRl", @@ -69,12 +73,13 @@ "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, +# Imported below via the module object so tests can monkeypatch +# the shared application core without traversing the historical 1.x alias. +from dashscope.applications.finetune import ( + agentic_rl as _agentic_rl, +) # noqa: E402 +from dashscope.applications.finetune import ( # noqa: E402 + reinforcement as _reinforcement, ) @@ -108,35 +113,6 @@ class UploadedData(BaseModel): 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()] @@ -157,9 +133,9 @@ def _new_agentic_rl( def _to_job(result: Any) -> FineTune: - """Adapt the 1.x ``FineTune`` dataclass into the v2 model. + """Adapt the RL submit result into the v2 job model. - The 1.x submit returns ``output`` as a nested object and spells the + The submit returns ``output`` as a nested object and spells the timestamps ``create_time`` / ``end_time``; the v2 model's aliases absorb both once the envelope is flattened by hand here. """ @@ -239,14 +215,14 @@ async def _register_functions_workflow( class RlJobs: """``client.rl.jobs``: the agentic-RL training job lifecycle. - ``create`` runs the whole 1.x workflow — register functions, upload + ``create`` runs the whole 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. + like every other resource. The workflow/submit path is the + exception: it posts to the management-plane host its own constants + name, as it always has. """ def __init__(self, client) -> None: @@ -265,12 +241,11 @@ def create( ``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()) + 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( @@ -290,7 +265,7 @@ def fetch( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response.json()), response) + return with_headers(_parse_job(response_json(response)), response) def wait( self, @@ -355,7 +330,7 @@ def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) def cancel( self, @@ -374,7 +349,7 @@ def cancel( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) def delete( self, @@ -393,7 +368,7 @@ def delete( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) def logs( self, @@ -416,14 +391,17 @@ def logs( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_logs(job_id, response.json()), response) + return with_headers( + _parse_logs(job_id, response_json(response)), + response, + ) class Rl: - """Sync mount of the 1.x agentic-RL workflow. + """Sync mount of the agentic-RL workflow. The workflow methods (``register_functions``/``test_functions``/ - ``upload_data``/``jobs.create``) wrap the 1.x async implementation + ``upload_data``/``jobs.create``) wrap the 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. @@ -443,18 +421,17 @@ def register_functions( 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, - ), - ) + 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, @@ -471,22 +448,21 @@ def test_functions( """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 + parameters forward to the 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, - ), - ) + 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, @@ -495,14 +471,13 @@ def upload_data( *, 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, - ), - ) + 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 [], @@ -524,18 +499,17 @@ def query_function_instance_logs( 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 "", - ), - ) + 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, @@ -552,17 +526,16 @@ def query_all_function_instance_logs( 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 "", - ), - ) + 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, @@ -575,13 +548,12 @@ def delete_function_instance( 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 "", - ), - ) + 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: @@ -603,12 +575,11 @@ async def create( ``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() + 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( @@ -628,7 +599,7 @@ async def fetch( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_job(response.json()), response) + return with_headers(_parse_job(response_json(response)), response) async def wait( self, @@ -697,7 +668,7 @@ async def list( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_list(response.json()), response) + return with_headers(_parse_list(response_json(response)), response) async def cancel( self, @@ -716,7 +687,7 @@ async def cancel( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) async def delete( self, @@ -735,7 +706,7 @@ async def delete( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_status(response.json()), response) + return with_headers(_parse_status(response_json(response)), response) async def logs( self, @@ -758,11 +729,14 @@ async def logs( workspace=workspace, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse_logs(job_id, response.json()), response) + return with_headers( + _parse_logs(job_id, response_json(response)), + response, + ) class AsyncRl: - """Async mount of the 1.x agentic-RL workflow.""" + """Async mount of the agentic-RL workflow.""" def __init__(self, client) -> None: self._client = client @@ -778,16 +752,15 @@ async def register_functions( 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, - ) + 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, @@ -804,20 +777,19 @@ async def test_functions( """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 + parameters forward to the 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, - ) + 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, @@ -826,12 +798,11 @@ async def upload_data( *, 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, - ) + 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 [], @@ -849,16 +820,15 @@ async def query_function_instance_logs( 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 "", - ) + 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, @@ -871,17 +841,14 @@ async def query_all_function_instance_logs( 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 "", - ) - ) + 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, @@ -890,8 +857,7 @@ async def delete_function_instance( 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 "", - ) + 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/resources/rerank.py similarity index 94% rename from dashscope/v2/resources/rerank.py rename to dashscope/resources/rerank.py index cb862984..8e4c5936 100644 --- a/dashscope/v2/resources/rerank.py +++ b/dashscope/resources/rerank.py @@ -8,8 +8,8 @@ from pydantic import BaseModel, ConfigDict -from dashscope.v2.errors import check_response -from dashscope.v2.types.common import ( +from dashscope.errors import check_response, response_json +from dashscope.types.common import ( ResponseBase, Usage, response_envelope, @@ -125,7 +125,7 @@ def create( sdk_module=MODULE, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) class AsyncRerank: @@ -165,4 +165,4 @@ async def create( sdk_module=MODULE, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) diff --git a/dashscope/v2/resources/tingwu.py b/dashscope/resources/tingwu.py similarity index 96% rename from dashscope/v2/resources/tingwu.py rename to dashscope/resources/tingwu.py index c776c97e..763354dc 100644 --- a/dashscope/v2/resources/tingwu.py +++ b/dashscope/resources/tingwu.py @@ -5,7 +5,7 @@ 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. +``dashscope.legacy_1x.multimodal.tingwu`` pair. """ # "input" is the wire-protocol field name for task payloads. @@ -17,10 +17,14 @@ 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 ( +from dashscope.errors import ( + check_response, + error_from_business_code, + response_json, +) +from dashscope.transport.ws import Connector, WsSession +from dashscope.transport.ws_connection import WsConnection, WsTask +from dashscope.types.common import ( ResponseBase, Usage, response_envelope, @@ -245,7 +249,7 @@ def call( sdk_module=MODULE, ) check_response(response, raise_on_error=self._client.raise_on_error) - envelope = response.json() + envelope = response_json(response) data = response_envelope(envelope) output = envelope.get("output") data["output"] = output if isinstance(output, dict) else {} @@ -331,7 +335,7 @@ async def call( sdk_module=MODULE, ) check_response(response, raise_on_error=self._client.raise_on_error) - envelope = response.json() + envelope = response_json(response) data = response_envelope(envelope) output = envelope.get("output") data["output"] = output if isinstance(output, dict) else {} diff --git a/dashscope/v2/resources/tokenization.py b/dashscope/resources/tokenization.py similarity index 92% rename from dashscope/v2/resources/tokenization.py rename to dashscope/resources/tokenization.py index d71c47a5..62e609ad 100644 --- a/dashscope/v2/resources/tokenization.py +++ b/dashscope/resources/tokenization.py @@ -6,14 +6,14 @@ from typing import Any, Dict, List, Optional, Union -from dashscope.v2.errors import check_response -from dashscope.v2.types.common import ( +from dashscope.errors import check_response, response_json +from dashscope.types.common import ( ResponseBase, Usage, response_envelope, with_headers, ) -from dashscope.v2.types.messages import MessageInput, normalize_messages +from dashscope.types.messages import MessageInput, normalize_messages _PATH = "/tokenizer" # Functional-area tag for x-dashscope-sdk-client (matches 1.x ``tokenizers``). @@ -105,7 +105,7 @@ def count( sdk_module=MODULE, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) class AsyncTokenization: @@ -139,4 +139,4 @@ async def count( sdk_module=MODULE, ) check_response(response, raise_on_error=self._client.raise_on_error) - return with_headers(_parse(response.json()), response) + return with_headers(_parse(response_json(response)), response) diff --git a/dashscope/v2/resources/videos.py b/dashscope/resources/videos.py similarity index 97% rename from dashscope/v2/resources/videos.py rename to dashscope/resources/videos.py index b88c5db1..a70636b8 100644 --- a/dashscope/v2/resources/videos.py +++ b/dashscope/resources/videos.py @@ -19,10 +19,10 @@ from __future__ import annotations -import asyncio from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple, Union -from dashscope.v2.resources._jobs import ( +from dashscope.resources._blocking import run_blocking +from dashscope.resources._jobs import ( DEFAULT_MAX_INTERVAL, DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, @@ -31,7 +31,7 @@ JobList, Jobs, ) -from dashscope.v2.resources._oss import ( +from dashscope.resources._oss import ( OSS_RESOLVE_HEADERS, upload_media_value, ) @@ -411,11 +411,12 @@ async def create( 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, + # The upload path is blocking (file post + getPolicy), so it runs in + # a worker thread -- over a transport bridged back to this loop, + # which is what lets it serve the policy fetch. + image_url, media, extra_input, has_upload = await run_blocking( self._client, + _upload_inputs, model, image_url, media, diff --git a/dashscope/v2/transport/__init__.py b/dashscope/transport/__init__.py similarity index 86% rename from dashscope/v2/transport/__init__.py rename to dashscope/transport/__init__.py index ecc95dac..d552f13f 100644 --- a/dashscope/v2/transport/__init__.py +++ b/dashscope/transport/__init__.py @@ -2,7 +2,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Transport layer for the dashscope 2.0 SDK.""" -from dashscope.v2.transport.http import ( +from dashscope.transport.http import ( AsyncTransport, SyncTransport, default_user_agent, diff --git a/dashscope/v2/transport/_ident.py b/dashscope/transport/_ident.py similarity index 88% rename from dashscope/v2/transport/_ident.py rename to dashscope/transport/_ident.py index 7d856e0d..5e1aa9d1 100644 --- a/dashscope/v2/transport/_ident.py +++ b/dashscope/transport/_ident.py @@ -18,10 +18,10 @@ 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 +# single SDK session is not split across ids when one process issues +# requests through both stacks. (acli keeps its own: it is a one-way +# sync copy, see _shared/_sdk_session.) +from dashscope._shared._sdk_session import sdk_session_id SDK_CLIENT_HEADER = "x-dashscope-sdk-client" SDK_SESSION_ID_HEADER = "x-dashscope-sdk-session-id" @@ -33,7 +33,8 @@ # ``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``) +# installed package (e.g. 2.0.0) ships both the 1.x surface +# (``dashscope.legacy_1x.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 @@ -43,8 +44,9 @@ 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. +# ``common.utils.get_sdk_headers`` emits the unmarked module, and so does +# AgentStudio, whose own transport builds its header from the shared +# ``_shared._sdk_ident`` because the backend already reports on that tag. V2_MODULE_MARKER = "2" diff --git a/dashscope/v2/transport/_retry.py b/dashscope/transport/_retry.py similarity index 100% rename from dashscope/v2/transport/_retry.py rename to dashscope/transport/_retry.py diff --git a/dashscope/v2/transport/http.py b/dashscope/transport/http.py similarity index 56% rename from dashscope/v2/transport/http.py rename to dashscope/transport/http.py index ffc58c46..2c9164d2 100644 --- a/dashscope/v2/transport/http.py +++ b/dashscope/transport/http.py @@ -10,16 +10,16 @@ import httpx -from dashscope.v2._config import DEFAULT_TIMEOUT -from dashscope.v2._logging import get_logger -from dashscope.v2.errors import ( +from dashscope._config import DEFAULT_TIMEOUT +from dashscope._logging import get_logger +from dashscope.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 +from dashscope.transport._ident import sdk_headers +from dashscope.transport._retry import backoff_delay, should_retry logger = get_logger("transport") @@ -28,10 +28,15 @@ def _map_transport_error(exc: httpx.TransportError) -> DashScopeError: - """Map raw httpx transport failures into the SDK error hierarchy.""" + """Map raw httpx transport failures into the SDK error hierarchy. + + The message text comes from the catalog entry behind each class + (``sdk.Timeout`` / ``sdk.ConnectionError``), with the httpx error as + the ``{detail}``. + """ if isinstance(exc, httpx.TimeoutException): - return APITimeoutError(f"request timed out: {exc}") - return APIConnectionError(f"connection error: {exc}") + return APITimeoutError(detail=str(exc)) + return APIConnectionError(detail=str(exc)) def _resolve_timeout( @@ -108,6 +113,24 @@ def _merge_auth_headers( ) +def _request_headers( + defaults: Mapping[str, str], + sdk: Mapping[str, str], + headers: Optional[Mapping[str, str]], + api_key: Optional[str], + workspace: Optional[str], +) -> dict: + """Merge Client defaults, SDK identity and per-call overrides. + + This is deliberately request-scoped so an injected ``http_client`` still + receives the Client's credentials and user agent. Caller headers and + per-call credentials win case-insensitively. + """ + per_call = _merge_auth_headers(headers, api_key, workspace) + configured = _with_sdk_headers(defaults, per_call) + return _with_sdk_headers(sdk, configured) + + 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 @@ -141,10 +164,18 @@ def __init__( limits: Optional[httpx.Limits] = None, ) -> None: self._owns_client = http_client is None + self._base_url = base_url.rstrip("/") + self._default_headers = _build_headers(api_key, workspace, user_agent) + self._default_timeout = _resolve_timeout(timeout, default_timeout) + self._request_timeout = ( + None + if http_client is not None and timeout is None + else self._default_timeout + ) 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), + base_url=self._base_url, + headers=self._default_headers, + timeout=self._default_timeout, **({"limits": limits} if limits is not None else {}), ) self._max_retries = max(max_retries, 0) @@ -171,7 +202,7 @@ def request( workspace: Optional[str] = None, sdk_module: Optional[str] = None, ) -> httpx.Response: - url = _abs_url(base_url, path) + url = _abs_url(base_url or self._base_url, path) for attempt in range(self._max_retries + 1): try: response = self._client.request( @@ -182,11 +213,16 @@ def request( data=data, files=files, params=params, - headers=_with_sdk_headers( + headers=_request_headers( + self._default_headers, sdk_headers(sdk_module), - _merge_auth_headers(headers, api_key, workspace), + headers, + api_key, + workspace, + ), + **_timeout_kwarg( + self._request_timeout if timeout is None else timeout, ), - **_timeout_kwarg(timeout), ) except httpx.TransportError as exc: if attempt >= self._max_retries: @@ -252,44 +288,98 @@ def open_stream( 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), + """Open a stream, retrying only before a successful stream exists. + + Initial connection failures and retryable HTTP statuses are safe to + replay because no event has reached the caller yet. Once a successful + response is returned, ownership transfers to the caller and the SDK + never retries or replays stream contents. + """ + url = _abs_url(base_url or self._base_url, path) + for attempt in range(self._max_retries + 1): + try: + response = self._client.send( + self._client.build_request( + method, + url, + json=json, + params=params, + headers=_request_headers( + self._default_headers, + sdk_headers(sdk_module), + headers, + api_key, + workspace, + ), + **_timeout_kwarg( + self._request_timeout + if timeout is None + else timeout, + ), ), - **_timeout_kwarg(timeout), - ), - stream=True, + stream=True, + ) + except httpx.TransportError as exc: + if attempt >= self._max_retries: + raise _map_transport_error(exc) from exc + delay = backoff_delay(attempt, None) + logger.info( + "retrying stream %s %s in %.2fs (attempt %d/%d): %s: %s", + method, + path, + delay, + attempt + 1, + self._max_retries, + type(exc).__name__, + exc, + ) + _sleep(delay) + continue + + if should_retry(response.status_code, response.headers) and ( + attempt < self._max_retries + ): + delay = backoff_delay(attempt, response.headers) + request_id = response.headers.get("x-request-id") + try: + response.read() + except httpx.TransportError: + # The response was never exposed to the caller; a broken + # retryable error body does not make replay unsafe. + pass + finally: + response.close() + logger.info( + "retrying stream %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) + continue + + if response.status_code >= 400: + try: + response.read() + check_response(response) + except httpx.TransportError as exc: + raise _map_transport_error(exc) from exc + finally: + response.close() + logger.debug( + "stream %s %s -> %d (request_id=%s)", + method, + path, + response.status_code, + response.headers.get("x-request-id"), ) - except httpx.TransportError as exc: - raise _map_transport_error(exc) from exc - if response.status_code >= 400: - try: - response.read() - check_response(response) - finally: - response.close() - logger.debug( - "stream %s %s -> %d (request_id=%s)", - method, - path, - response.status_code, - response.headers.get("x-request-id"), - ) - return response + return response + raise AssertionError("unreachable") def close(self) -> None: if self._owns_client: @@ -311,10 +401,18 @@ def __init__( limits: Optional[httpx.Limits] = None, ) -> None: self._owns_client = http_client is None + self._base_url = base_url.rstrip("/") + self._default_headers = _build_headers(api_key, workspace, user_agent) + self._default_timeout = _resolve_timeout(timeout, default_timeout) + self._request_timeout = ( + None + if http_client is not None and timeout is None + else self._default_timeout + ) 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), + base_url=self._base_url, + headers=self._default_headers, + timeout=self._default_timeout, **({"limits": limits} if limits is not None else {}), ) self._max_retries = max(max_retries, 0) @@ -341,7 +439,7 @@ async def request( workspace: Optional[str] = None, sdk_module: Optional[str] = None, ) -> httpx.Response: - url = _abs_url(base_url, path) + url = _abs_url(base_url or self._base_url, path) for attempt in range(self._max_retries + 1): try: response = await self._client.request( @@ -352,11 +450,16 @@ async def request( data=data, files=files, params=params, - headers=_with_sdk_headers( + headers=_request_headers( + self._default_headers, sdk_headers(sdk_module), - _merge_auth_headers(headers, api_key, workspace), + headers, + api_key, + workspace, + ), + **_timeout_kwarg( + self._request_timeout if timeout is None else timeout, ), - **_timeout_kwarg(timeout), ) except httpx.TransportError as exc: if attempt >= self._max_retries: @@ -422,36 +525,86 @@ async def open_stream( 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: + """Async twin of :meth:`SyncTransport.open_stream`.""" + url = _abs_url(base_url or self._base_url, path) + for attempt in range(self._max_retries + 1): + request = self._client.build_request( + method, + url, + json=json, + params=params, + headers=_request_headers( + self._default_headers, + sdk_headers(sdk_module), + headers, + api_key, + workspace, + ), + **_timeout_kwarg( + self._request_timeout if timeout is None else timeout, + ), + ) try: - await response.aread() - check_response(response) - finally: - await response.aclose() - logger.debug( - "stream %s %s -> %d (request_id=%s)", - method, - path, - response.status_code, - response.headers.get("x-request-id"), - ) - return response + response = await self._client.send(request, stream=True) + except httpx.TransportError as exc: + if attempt >= self._max_retries: + raise _map_transport_error(exc) from exc + delay = backoff_delay(attempt, None) + logger.info( + "retrying stream %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 + + if should_retry(response.status_code, response.headers) and ( + attempt < self._max_retries + ): + delay = backoff_delay(attempt, response.headers) + request_id = response.headers.get("x-request-id") + try: + await response.aread() + except httpx.TransportError: + pass + finally: + await response.aclose() + logger.info( + "retrying stream %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) + continue + + if response.status_code >= 400: + try: + await response.aread() + check_response(response) + except httpx.TransportError as exc: + raise _map_transport_error(exc) from exc + finally: + await response.aclose() + logger.debug( + "stream %s %s -> %d (request_id=%s)", + method, + path, + response.status_code, + response.headers.get("x-request-id"), + ) + return response + raise AssertionError("unreachable") async def aclose(self) -> None: if self._owns_client: diff --git a/dashscope/v2/transport/sse.py b/dashscope/transport/sse.py similarity index 98% rename from dashscope/v2/transport/sse.py rename to dashscope/transport/sse.py index 7efccf7a..1dfb6245 100644 --- a/dashscope/v2/transport/sse.py +++ b/dashscope/transport/sse.py @@ -32,7 +32,7 @@ import httpx from httpx_sse import EventSource -from dashscope.v2.errors import DashScopeError, error_from_business_code +from dashscope.errors import DashScopeError, error_from_business_code T = TypeVar("T") diff --git a/dashscope/v2/transport/ws.py b/dashscope/transport/ws.py similarity index 87% rename from dashscope/v2/transport/ws.py rename to dashscope/transport/ws.py index 71b2c9fa..3a2201a7 100644 --- a/dashscope/v2/transport/ws.py +++ b/dashscope/transport/ws.py @@ -11,18 +11,19 @@ import uuid from typing import Any, Callable, Dict, List, Mapping, Optional, Union -from dashscope.v2._config import ( +from dashscope._config import ( WS_INFERENCE_PATH, settings, workspace_ws_url, ) -from dashscope.v2.errors import ( +from dashscope.errors import ( APIConnectionError, - DashScopeError, + WsEventError, + WsStateError, error_from_business_code, ) -from dashscope.v2.transport import default_user_agent -from dashscope.v2.transport._ident import sdk_headers +from dashscope.transport import default_user_agent +from dashscope.transport._ident import sdk_headers __all__ = [ "Connector", @@ -31,6 +32,7 @@ "EVENT_STARTED", "WsEventError", "WsSession", + "WsStateError", ] EVENT_STARTED = "task-started" @@ -41,10 +43,6 @@ 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], @@ -102,10 +100,19 @@ def _connect( 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 connector is None: + ws = _default_connector(url, headers, timeout=timeout) + else: + ws = connector(url, headers) + except BaseException as exc: + from websocket import WebSocketTimeoutException + + if isinstance(exc, WebSocketTimeoutException): + from dashscope.errors import APITimeoutError + + raise APITimeoutError(detail=str(exc)) from exc + raise try: if timeout is not None: ws.settimeout(timeout) @@ -126,12 +133,17 @@ def _recv_frame(ws: Any) -> Any: """ from websocket import ( # pylint: disable=import-outside-toplevel WebSocketConnectionClosedException, + WebSocketTimeoutException, ) try: return ws.recv() except WebSocketConnectionClosedException: return None + except WebSocketTimeoutException as exc: + from dashscope.errors import APITimeoutError + + raise APITimeoutError(detail=str(exc)) from exc class _EventProcessor: @@ -145,11 +157,11 @@ def _process_event(self, event: Dict[str, Any]) -> Dict[str, Any]: event.get("header"), dict, ): - raise WsEventError("expected a websocket event header") + raise WsEventError(detail="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", + detail="websocket event belongs to a different task", request_id=self._request_id, body=event, ) @@ -269,14 +281,14 @@ def run_task( frame["header"]["streaming"] = streaming with self._lock: if self._closed or self._run_sent: - raise RuntimeError("websocket task already started or closed") + raise WsStateError(detail="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 " + detail="expected task-started, got " f"{event['header'].get('event')!r}", request_id=self.request_id, ) @@ -288,9 +300,9 @@ def run_task( def _check_input(self) -> None: if self._closed or not self._started or self._finished: - raise RuntimeError("websocket task is not active") + raise WsStateError(detail="task is not active") if self._input_finished: - raise RuntimeError("websocket task input is finished") + raise WsStateError(detail="task input is finished") def send_binary(self, data: bytes) -> None: if not isinstance(data, (bytes, bytearray, memoryview)): @@ -312,7 +324,7 @@ def finish_task(self, input: Optional[Dict[str, Any]] = None) -> None: """ with self._lock: if self._closed or not self._started: - raise RuntimeError("websocket task is not active") + raise WsStateError(detail="task is not active") if self._finished: return cancel = input is not None and input.get("directive") == "cancel" @@ -327,7 +339,7 @@ def recv_event(self) -> Dict[str, Any]: while True: frame = self.recv() if frame is None: - raise RuntimeError("websocket task is finished") + raise WsStateError(detail="task is finished") if isinstance(frame, dict): return frame @@ -336,7 +348,7 @@ def recv(self) -> Union[Dict[str, Any], bytes, None]: if self._finished: return None if self._closed: - raise RuntimeError("websocket session is closed") + raise WsStateError(detail="session is closed") try: raw = _recv_frame(self._ws) if raw is None or raw == "": diff --git a/dashscope/v2/transport/ws_connection.py b/dashscope/transport/ws_connection.py similarity index 78% rename from dashscope/v2/transport/ws_connection.py rename to dashscope/transport/ws_connection.py index 450873dc..43165757 100644 --- a/dashscope/v2/transport/ws_connection.py +++ b/dashscope/transport/ws_connection.py @@ -35,13 +35,19 @@ import uuid from typing import Any, Dict, List, Mapping, Optional, Tuple, Union -from dashscope.v2._config import ( +from dashscope._config import ( WS_INFERENCE_PATH, settings, workspace_ws_url, ) -from dashscope.v2.errors import APIConnectionError -from dashscope.v2.transport.ws import ( +from dashscope.errors import ( + APIConnectionError, + APITimeoutError, + WsBinaryOwnerConflictError, + WsQueueCapacityExceededError, + WsStateError, +) +from dashscope.transport.ws import ( EVENT_FAILED, EVENT_FINISHED, EVENT_STARTED, @@ -74,11 +80,14 @@ def __init__( connection: "WsConnection", task_id: str, timeout: Optional[float], + queue_size: int, ) -> None: self._conn = connection self._task_id = task_id self._timeout = timeout - self._queue: "queue.SimpleQueue[_QueueItem]" = queue.SimpleQueue() + self._queue: "queue.Queue[_QueueItem]" = queue.Queue( + maxsize=queue_size, + ) self._request_id = task_id self._lock = threading.Lock() self._run_sent = False @@ -106,10 +115,28 @@ def finished(self) -> bool: def _enqueue(self, item: _QueueItem) -> None: """Called with the connection lock held; the first error ends input.""" - if not self._closed and self._error is None: - if item[0] == _ERROR: + if self._closed or self._error is not None: + return + if item[0] == _ERROR: + self._error = item[1] + try: + self._queue.put_nowait(item) + except queue.Full: + if item[0] != _ERROR: + # A slow consumer must not tear down the shared connection: + # fail only this task and let its reader surface the error. + item = ( + _ERROR, + WsQueueCapacityExceededError(), + ) self._error = item[1] - self._queue.put(item) + # Preserve terminal visibility without allowing an unbounded + # backlog to retain audio forever: evict the oldest frame. + try: + self._queue.get_nowait() + except queue.Empty: + pass + self._queue.put_nowait(item) def _frame(self, action: str, payload: Dict[str, Any]) -> Dict[str, Any]: return { @@ -149,14 +176,14 @@ def run_task( frame["header"]["streaming"] = streaming with self._lock: if self._closed or self._run_sent: - raise RuntimeError("websocket task already started or closed") + raise WsStateError(detail="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 " + detail="expected task-started, got " f"{event['header'].get('event')!r}", request_id=self.request_id, ) @@ -168,9 +195,9 @@ def run_task( def _check_input(self) -> None: if self._closed or not self._started or self._finished: - raise RuntimeError("websocket task is not active") + raise WsStateError(detail="task is not active") if self._input_finished: - raise RuntimeError("websocket task input is finished") + raise WsStateError(detail="task input is finished") def claim_binary_downlink(self) -> None: """Route binary output frames to this task. @@ -198,7 +225,7 @@ 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") + raise WsStateError(detail="task is not active") if self._finished: return cancel = input is not None and input.get("directive") == "cancel" @@ -215,7 +242,7 @@ def recv_event(self) -> Dict[str, Any]: while True: frame = self.recv() if frame is None: - raise RuntimeError("websocket task is finished") + raise WsStateError(detail="task is finished") if isinstance(frame, dict): return frame @@ -226,8 +253,8 @@ def recv(self) -> Union[Dict[str, Any], bytes, None]: try: kind, payload = self._queue.get(timeout=self._timeout) except queue.Empty: - raise TimeoutError( - "timed out waiting for a websocket event", + raise APITimeoutError( + detail="timed out waiting for a websocket event", ) from None if kind == _ERROR: # Requeue the terminal marker so subsequent readers also fail. @@ -261,15 +288,24 @@ def __init__( connector: Optional[Connector] = None, timeout: Optional[float] = None, module: Optional[str] = None, + task_queue_size: int = 1024, + ping_interval: Optional[float] = 30.0, ) -> None: if timeout is not None and timeout <= 0: raise ValueError("timeout must be positive") + if task_queue_size <= 0: + raise ValueError("task_queue_size must be positive") + if ping_interval is not None and ping_interval <= 0: + raise ValueError("ping_interval must be positive") self._url = ( url or settings.websocket_base_url or workspace_ws_url(workspace, WS_INFERENCE_PATH, region) ) self._timeout = timeout + self._task_queue_size = task_queue_size + self._ping_interval = ping_interval + self._heartbeat_stop = threading.Event() self._tasks: Dict[str, WsTask] = {} self._lock = threading.Lock() self._send_lock = threading.Lock() @@ -296,6 +332,14 @@ def __init__( daemon=True, ) self._reader.start() + ping = getattr(self._ws, "ping", None) + if self._ping_interval is not None and callable(ping): + self._heartbeat = threading.Thread( + target=self._heartbeat_main, + name="dashscope-ws-heartbeat", + daemon=True, + ) + self._heartbeat.start() except BaseException: self.close() raise @@ -309,11 +353,12 @@ def open_task(self, *, timeout: Optional[float] = None) -> WsTask: raise ValueError("timeout must be positive") with self._lock: if self._closed: - raise RuntimeError("websocket connection is closed") + raise WsStateError(detail="connection is closed") task = WsTask( self, uuid.uuid4().hex, self._timeout if timeout is None else timeout, + self._task_queue_size, ) self._tasks[task.task_id] = task return task @@ -321,7 +366,7 @@ def open_task(self, *, timeout: Optional[float] = None) -> WsTask: def _send(self, data: Any, *, opcode: int = 1) -> None: with self._send_lock: if self._closed: - raise RuntimeError("websocket connection is closed") + raise WsStateError(detail="connection is closed") try: if opcode == 2: self._ws.send(data, opcode=2) @@ -334,22 +379,23 @@ def _send(self, data: Any, *, opcode: int = 1) -> None: 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") + raise WsStateError(detail="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") + raise WsStateError(detail="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( - "another task owns the binary uplink on this connection", + raise WsBinaryOwnerConflictError( + detail="another task owns the binary uplink on this " + "connection", ) self._binary_uplink_owner = task @@ -358,8 +404,9 @@ def _claim_binary_downlink(self, task: WsTask) -> None: self._check_binary_owner(task) 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", + raise WsBinaryOwnerConflictError( + detail="another task owns the binary downlink on this " + "connection", ) self._binary_downlink_owner = task @@ -372,13 +419,25 @@ def _release_binary(self, task: WsTask) -> None: def _detach(self, task: WsTask) -> None: with self._lock: - task._enqueue((_ERROR, RuntimeError("websocket task is closed"))) + task._enqueue((_ERROR, WsStateError(detail="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 _heartbeat_main(self) -> None: + assert self._ping_interval is not None + while not self._heartbeat_stop.wait(self._ping_interval): + try: + with self._send_lock: + if self._closed: + return + self._ws.ping() + except BaseException as exc: + self._fail(APIConnectionError(str(exc))) + return + def _reader_main(self) -> None: try: while True: @@ -397,10 +456,10 @@ def _reader_main(self) -> None: def _route_event(self, event: Any) -> None: header = event.get("header") if isinstance(event, dict) else None if not isinstance(header, dict): - raise WsEventError("expected a websocket event header") + raise WsEventError(detail="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") + raise WsEventError(detail="expected a non-empty websocket task_id") with self._lock: task = self._tasks.get(task_id) if self._closed or task is None or task._routing_finished: @@ -431,8 +490,8 @@ def _route_binary(self, data: bytes) -> None: return if len(active) != 1: raise WsEventError( - "binary frame with multiple active tasks; claim the " - "downlink with claim_binary_downlink()", + detail="binary frame with multiple active tasks; " + "claim the downlink with claim_binary_downlink()", ) owner = active[0] self._binary_downlink_owner = owner @@ -445,6 +504,7 @@ def _fail(self, exc: BaseException) -> None: # just the shutdown echo. return self._closed = True + self._heartbeat_stop.set() self._binary_uplink_owner = None self._binary_downlink_owner = None _close_websocket(self._ws) @@ -466,3 +526,10 @@ def close(self) -> None: and threading.current_thread() is not reader ): reader.join(timeout=2) + heartbeat = getattr(self, "_heartbeat", None) + if ( + heartbeat is not None + and heartbeat.is_alive() + and threading.current_thread() is not heartbeat + ): + heartbeat.join(timeout=2) diff --git a/dashscope/v2/types/__init__.py b/dashscope/types/__init__.py similarity index 84% rename from dashscope/v2/types/__init__.py rename to dashscope/types/__init__.py index b9f5a59a..e6b5a0d7 100644 --- a/dashscope/v2/types/__init__.py +++ b/dashscope/types/__init__.py @@ -2,8 +2,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 ( +from dashscope.types.common import JobStatus, ResponseBase, Usage +from dashscope.types.completions import ( ChatCompletion, ChatCompletionChunk, ChatCompletionMessage, @@ -13,7 +13,7 @@ parse_chunk, parse_completion, ) -from dashscope.v2.types.messages import ( +from dashscope.types.messages import ( AudioPart, ImagePart, Message, diff --git a/dashscope/v2/types/common.py b/dashscope/types/common.py similarity index 96% rename from dashscope/v2/types/common.py rename to dashscope/types/common.py index 5f159042..0c08f563 100644 --- a/dashscope/v2/types/common.py +++ b/dashscope/types/common.py @@ -9,7 +9,11 @@ import httpx from pydantic import BaseModel, ConfigDict, Field, model_validator -from dashscope.v2.errors import error_from_business_code, error_from_status +from dashscope.errors import ( + error_from_business_code, + error_from_status, + response_json, +) __all__ = [ "JobStatus", @@ -144,12 +148,13 @@ def response_body( ) -> 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 success: + raw = response_json(response) + else: + try: + raw = response.json() + except ValueError: + raw = None if isinstance(raw, dict): body = raw else: diff --git a/dashscope/v2/types/completions.py b/dashscope/types/completions.py similarity index 97% rename from dashscope/v2/types/completions.py rename to dashscope/types/completions.py index 2d5dc086..9e579472 100644 --- a/dashscope/v2/types/completions.py +++ b/dashscope/types/completions.py @@ -8,8 +8,8 @@ from pydantic import BaseModel, ConfigDict -from dashscope.v2.types.common import ResponseBase, Usage -from dashscope.v2.types.messages import ToolCall +from dashscope.types.common import ResponseBase, Usage +from dashscope.types.messages import ToolCall __all__ = [ "ChatCompletion", diff --git a/dashscope/v2/types/messages.py b/dashscope/types/messages.py similarity index 98% rename from dashscope/v2/types/messages.py rename to dashscope/types/messages.py index 3d7c7043..d0fea9f5 100644 --- a/dashscope/v2/types/messages.py +++ b/dashscope/types/messages.py @@ -32,8 +32,8 @@ class TextPart(BaseModel): type: Literal["text"] = "text" text: str - # Explicit context-cache marker (Bailian 上下文缓存): a part-level - # sibling such as {"type": "ephemeral"} on the native and + # Explicit context-cache marker (Bailian context caching): 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. diff --git a/dashscope/v2/__init__.py b/dashscope/v2/__init__.py deleted file mode 100644 index 7217e39b..00000000 --- a/dashscope/v2/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- 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/_quickstart.py b/dashscope/v2/_quickstart.py deleted file mode 100644 index 78f46647..00000000 --- a/dashscope/v2/_quickstart.py +++ /dev/null @@ -1,38 +0,0 @@ -# -*- 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/resources/_agentstudio.py b/dashscope/v2/resources/_agentstudio.py deleted file mode 100644 index a58700b7..00000000 --- a/dashscope/v2/resources/_agentstudio.py +++ /dev/null @@ -1,384 +0,0 @@ -# -*- 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. -""" - -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, 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: - # 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 - error = _v2_errors.error_from_status( - _effective_status(exc), - message=getattr(exc, "message", None) or str(exc), - 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( - 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. - 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 lazy stream and pagination calls without changing model data.""" - - 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 - - -_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: - 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: - 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") - 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: - return _iterate_sync(object.__getattribute__(self, "_target")) - - -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: - 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") - 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) - - def __aiter__(self) -> Any: - return _iterate_async(object.__getattribute__(self, "_target")) - - -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") - 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): - 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) - object.__getattribute__(self, "_cache").pop(name, None) - - 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) - - -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.""" - - return AgentStudioProxy(client) diff --git a/dashscope/version.py b/dashscope/version.py index 43911e0d..a7866f56 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.2" +__version__ = "2.0.3" diff --git a/dev.md b/dev.md index b600396a..9d4fb9bd 100644 --- a/dev.md +++ b/dev.md @@ -41,3 +41,19 @@ pre-commit run --all-files `.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. + +## 2. Tests + +The current implementation lives at the package root. Archived +transitional suites are separated from current coverage: + +```bash +python -m pytest tests --ignore=tests/legacy_1x -q +coverage run -m pytest tests --ignore=tests/legacy_1x +coverage report -m +python -m pytest tests/legacy_1x -q +python -m pytest tests -q +``` + +Plain `pytest` runs both groups. The current and legacy groups also run as +separate jobs in `.github/workflows/unit_test.yml`. diff --git a/docs/source/api/dashscope.aigc.rst b/docs/source/api/dashscope.aigc.rst index e2a1c48a..7af21a3f 100644 --- a/docs/source/api/dashscope.aigc.rst +++ b/docs/source/api/dashscope.aigc.rst @@ -1,9 +1,9 @@ -dashscope.aigc -============== +dashscope.legacy_1x.aigc +======================== -.. automodule:: dashscope.aigc +.. automodule:: dashscope.legacy_1x.aigc -.. currentmodule:: dashscope.aigc +.. currentmodule:: dashscope.legacy_1x.aigc .. autosummary:: :toctree: generated diff --git a/docs/source/api/generated/dashscope.aigc.conversation.Conversation.rst b/docs/source/api/generated/dashscope.aigc.conversation.Conversation.rst index b2b16b2f..b418068b 100644 --- a/docs/source/api/generated/dashscope.aigc.conversation.Conversation.rst +++ b/docs/source/api/generated/dashscope.aigc.conversation.Conversation.rst @@ -1,4 +1,4 @@ -.. currentmodule:: dashscope.aigc.conversation +.. currentmodule:: dashscope.legacy_1x.aigc.conversation Conversation diff --git a/docs/source/api/generated/dashscope.aigc.conversation.History.rst b/docs/source/api/generated/dashscope.aigc.conversation.History.rst index 160dfda2..819bb62e 100644 --- a/docs/source/api/generated/dashscope.aigc.conversation.History.rst +++ b/docs/source/api/generated/dashscope.aigc.conversation.History.rst @@ -1,4 +1,4 @@ -.. currentmodule:: dashscope.aigc.conversation +.. currentmodule:: dashscope.legacy_1x.aigc.conversation History diff --git a/docs/source/api/generated/dashscope.aigc.conversation.HistoryItem.rst b/docs/source/api/generated/dashscope.aigc.conversation.HistoryItem.rst index 50daf956..edfa7fb4 100644 --- a/docs/source/api/generated/dashscope.aigc.conversation.HistoryItem.rst +++ b/docs/source/api/generated/dashscope.aigc.conversation.HistoryItem.rst @@ -1,4 +1,4 @@ -.. currentmodule:: dashscope.aigc.conversation +.. currentmodule:: dashscope.legacy_1x.aigc.conversation HistoryItem diff --git a/docs/source/develop.md b/docs/source/develop.md index ceafeea6..45ddc7c6 100644 --- a/docs/source/develop.md +++ b/docs/source/develop.md @@ -32,3 +32,17 @@ If you only want to format and lint your code, you can run ```bash make linter ``` + +## 2. Tests + +Run the current root implementation, archived compatibility suites, or the +complete suite with: + +```bash +python -m pytest tests --ignore=tests/legacy_1x -q +python -m pytest tests/legacy_1x -q +python -m pytest tests -q +``` + +Current coverage uses `coverage run -m pytest tests +--ignore=tests/legacy_1x`, followed by `coverage report -m`. diff --git a/examples/01_atomic_capabilities/audio/asr_file.py b/examples/01_atomic_capabilities/audio/asr_file.py index ac66cc89..7bdb13ca 100644 --- a/examples/01_atomic_capabilities/audio/asr_file.py +++ b/examples/01_atomic_capabilities/audio/asr_file.py @@ -10,7 +10,7 @@ import httpx from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import positive_seconds diff --git a/examples/01_atomic_capabilities/audio/asr_http_stream.py b/examples/01_atomic_capabilities/audio/asr_http_stream.py index f625ebf0..08b4517d 100644 --- a/examples/01_atomic_capabilities/audio/asr_http_stream.py +++ b/examples/01_atomic_capabilities/audio/asr_http_stream.py @@ -11,7 +11,7 @@ from httpx_sse import EventSource from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import open_wav, positive_seconds from .asr_file import https_url diff --git a/examples/01_atomic_capabilities/audio/direct_call/asr_file.py b/examples/01_atomic_capabilities/audio/direct_call/asr_file.py index 7394aefe..c771b940 100644 --- a/examples/01_atomic_capabilities/audio/direct_call/asr_file.py +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_file.py @@ -10,7 +10,7 @@ import httpx from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import positive_seconds 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 index 1f5c26d0..16950024 100644 --- a/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py +++ b/examples/01_atomic_capabilities/audio/direct_call/asr_http_stream.py @@ -10,7 +10,7 @@ from httpx_sse import EventSource from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import open_wav, positive_seconds from .asr_file import https_url diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py index 9651e402..bec52226 100644 --- a/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_omni.py @@ -10,7 +10,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( input_device, load_sounddevice, 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 index 55bb909a..87da80b6 100644 --- a/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_qwen_audio.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。""" +"""Single-turn realtime voice chat with Qwen-Audio: send WAV or microphone audio, save and play the reply.""" import argparse import base64 @@ -10,7 +10,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( input_device, load_sounddevice, diff --git a/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py b/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py index 4db13ed7..2f5a9720 100644 --- a/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py +++ b/examples/01_atomic_capabilities/audio/direct_call/realtime_translation.py @@ -10,7 +10,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( input_device, load_sounddevice, diff --git a/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py b/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py index aa89a8ea..84d99b8d 100644 --- a/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py +++ b/examples/01_atomic_capabilities/audio/direct_call/tts_websocket.py @@ -9,7 +9,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( play_wav, player_command, diff --git a/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py b/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py index a979bd95..6e1c80ca 100644 --- a/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py +++ b/examples/01_atomic_capabilities/audio/direct_call/voice_clone.py @@ -13,7 +13,7 @@ import httpx from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import positive_seconds diff --git a/examples/01_atomic_capabilities/audio/realtime_omni.py b/examples/01_atomic_capabilities/audio/realtime_omni.py index 9d7b72e4..e372c6dc 100644 --- a/examples/01_atomic_capabilities/audio/realtime_omni.py +++ b/examples/01_atomic_capabilities/audio/realtime_omni.py @@ -10,7 +10,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( input_device, load_sounddevice, diff --git a/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py b/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py index 6e4406d7..9a6df18e 100644 --- a/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py +++ b/examples/01_atomic_capabilities/audio/realtime_qwen_audio.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""Qwen-Audio 单轮实时语音对话:发送 WAV 或麦克风音频,保存并播放回答。""" +"""Single-turn realtime voice chat with Qwen-Audio: send WAV or microphone audio, save and play the reply.""" import argparse import base64 @@ -10,7 +10,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( input_device, load_sounddevice, diff --git a/examples/01_atomic_capabilities/audio/realtime_translation.py b/examples/01_atomic_capabilities/audio/realtime_translation.py index 186f9df2..46b016b1 100644 --- a/examples/01_atomic_capabilities/audio/realtime_translation.py +++ b/examples/01_atomic_capabilities/audio/realtime_translation.py @@ -10,7 +10,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( input_device, load_sounddevice, diff --git a/examples/01_atomic_capabilities/audio/tts_websocket.py b/examples/01_atomic_capabilities/audio/tts_websocket.py index b2a82593..49dffa08 100644 --- a/examples/01_atomic_capabilities/audio/tts_websocket.py +++ b/examples/01_atomic_capabilities/audio/tts_websocket.py @@ -10,7 +10,7 @@ from pathlib import Path from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import ( play_wav, player_command, diff --git a/examples/01_atomic_capabilities/audio/voice_clone.py b/examples/01_atomic_capabilities/audio/voice_clone.py index d6ab2b56..f7b2fbd8 100644 --- a/examples/01_atomic_capabilities/audio/voice_clone.py +++ b/examples/01_atomic_capabilities/audio/voice_clone.py @@ -13,7 +13,7 @@ import httpx from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError from ._common import positive_seconds diff --git a/examples/01_atomic_capabilities/decision/decision.py b/examples/01_atomic_capabilities/decision/decision.py index bd8af485..e0c3628a 100644 --- a/examples/01_atomic_capabilities/decision/decision.py +++ b/examples/01_atomic_capabilities/decision/decision.py @@ -34,7 +34,7 @@ print("----- decision request -----") # Question values may also be the typed models ChoiceQuestion / - # NoulQuestion / ScoreQuestion from dashscope.v2.resources.decision; + # NoulQuestion / ScoreQuestion from dashscope.resources.decision; # raw dicts below show the exact wire shape (and pass future fields # through untouched). resp = client.decision.create( diff --git a/examples/02_advanced_engineering/circuit_breaker_retry.py b/examples/02_advanced_engineering/circuit_breaker_retry.py index a4b97612..d76e82ff 100644 --- a/examples/02_advanced_engineering/circuit_breaker_retry.py +++ b/examples/02_advanced_engineering/circuit_breaker_retry.py @@ -7,7 +7,7 @@ 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. + and ``dashscope/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. @@ -23,7 +23,7 @@ import httpx import dashscope -from dashscope.v2 import errors +from dashscope import errors # Fail over across these regions, in order. Both have a dedicated # shared gateway, so no workspace is required for the demo. diff --git a/examples/02_advanced_engineering/local_mock_debugging.py b/examples/02_advanced_engineering/local_mock_debugging.py index 99526c93..f64ed379 100644 --- a/examples/02_advanced_engineering/local_mock_debugging.py +++ b/examples/02_advanced_engineering/local_mock_debugging.py @@ -16,7 +16,7 @@ import httpx import dashscope -from dashscope.v2 import errors +from dashscope import errors # A fake gateway URL. MockTransport intercepts every request, so no # traffic ever leaves this process and the host is never resolved. diff --git a/examples/03_agent_recipes/agentstudio.py b/examples/03_agent_recipes/agentstudio.py index 682855c7..68ef206f 100644 --- a/examples/03_agent_recipes/agentstudio.py +++ b/examples/03_agent_recipes/agentstudio.py @@ -8,7 +8,7 @@ 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 +``dashscope`` exception family, so one ``client`` covers both the 2.0 resources and AgentStudio. `memory_stores_demo` covers the memory store flow (create store -> @@ -22,7 +22,8 @@ import os import dashscope -from dashscope.agentstudio import ModelConfig, user_message +from dashscope.agentstudio import user_message +from dashscope.agentstudio.types import ModelConfig def make_client(): diff --git a/examples/04_real_world_scenarios/README.md b/examples/04_real_world_scenarios/README.md index 2636abe7..45c942fe 100644 --- a/examples/04_real_world_scenarios/README.md +++ b/examples/04_real_world_scenarios/README.md @@ -81,7 +81,7 @@ The demo runs a three-turn dialogue whose second turn — "can this order be ref ### 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. +> **Streaming requests do not go through the SDK's auto-retry.** `open_stream` in `dashscope/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. @@ -113,7 +113,7 @@ The offline demo runs in order: ## 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. +`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/test_tingwu.py`), so without a microphone or a key you can watch concurrency, reconnects and degradation actually happen. ### The four production dimensions @@ -126,7 +126,7 @@ The offline demo runs in order: ### 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. +> **The realtime family cannot be multiplexed.** The header comment of `dashscope/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. @@ -166,11 +166,11 @@ The offline demo runs 4 rooms concurrently, reports `completed` / `degraded` sep ### 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. +> **DashScope can report a failure with HTTP 200.** `ResponseBase` in `dashscope/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. +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/_config.py`), so changing the client's chat `protocol` does not move it. ### Run diff --git a/examples/04_real_world_scenarios/README_zh.md b/examples/04_real_world_scenarios/README_zh.md index 59c5dedc..f6ccc078 100644 --- a/examples/04_real_world_scenarios/README_zh.md +++ b/examples/04_real_world_scenarios/README_zh.md @@ -81,7 +81,7 @@ python3 examples/04_real_world_scenarios/customer_service_assistant.py ### 不读源码一定会踩的坑 -> **流式请求不走 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` 正是为这个缺口而存在。 +> **流式请求不走 SDK 的自动重试。** `dashscope/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 折叠进来。 @@ -113,7 +113,7 @@ RAG_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/production_ready_rag ## 案例:高并发实时会议转写底座 -`realtime_audio_meeting_harness.py` 把 legacy 听悟 realtime 脚本(`legacy_1x/tingwu_realtime.py`,回调 + `time.sleep` 轮询的测试风格)重建成**会议平台级并发监管器**:同时实时转写 N 个会议室,每个房间有自己的状态机和转写稿,任何一个房间的断线或审核拦截都不能拖垮其他房间。它同样默认离线运行——通过听悟 realtime 的 `connector=` 注入一条脚本化双工 socket(与 `tests/v2/test_tingwu.py` 用的是同一个接缝),没有麦克风、没有密钥也能看到并发、重连和降级真实发生。 +`realtime_audio_meeting_harness.py` 把 legacy 听悟 realtime 脚本(`legacy_1x/tingwu_realtime.py`,回调 + `time.sleep` 轮询的测试风格)重建成**会议平台级并发监管器**:同时实时转写 N 个会议室,每个房间有自己的状态机和转写稿,任何一个房间的断线或审核拦截都不能拖垮其他房间。它同样默认离线运行——通过听悟 realtime 的 `connector=` 注入一条脚本化双工 socket(与 `tests/test_tingwu.py` 用的是同一个接缝),没有麦克风、没有密钥也能看到并发、重连和降级真实发生。 ### 四个生产维度 @@ -126,7 +126,7 @@ RAG_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/production_ready_rag ### 不读源码一定会踩的坑 -> **realtime 家族无法复用连接。** `dashscope/v2/transport/ws_connection.py` 的头注释写明了:realtime 协议没有 task id——一条 socket 就是一个会话,会话结束即关闭——所以它总是为每个会话开一条独立连接。(可复用的 `WsConnection` 只服务 JSON 帧的 asr/tts/translation 任务,且二进制上行是单属主的。)因此"高并发实时音频"的正确形态是**每场会议一条独立连接 + 一个监管线程池**,而不是把多路音频塞进一条 socket——协议做不到。harness 的 `MeetingSupervisor` 用有界线程池把"并发"绑定到"活跃 socket / 爆炸半径"上,作为准入控制。 +> **realtime 家族无法复用连接。** `dashscope/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` 立即降级)分别演示了这两条路径。 @@ -166,11 +166,11 @@ MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_m ### 不读源码一定会踩的坑 -> **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` 打开时,这道防线必须已经就位。 +> **DashScope 可能用 HTTP 200 报告失败。** `dashscope/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` 不会移动它。 +还有一条路由说明:System One 端点在**每个网关的 compatible-mode 路径下**(由 `dashscope/_config.py` 的 `compatible_mode_base_url` 推导),所以改变客户端的聊天 `protocol` 不会移动它。 ### 运行 diff --git a/examples/04_real_world_scenarios/production_ready_rag_harness.py b/examples/04_real_world_scenarios/production_ready_rag_harness.py index 5e3a0878..34b8751b 100644 --- a/examples/04_real_world_scenarios/production_ready_rag_harness.py +++ b/examples/04_real_world_scenarios/production_ready_rag_harness.py @@ -15,7 +15,7 @@ 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 + status once and returns -- see dashscope/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 @@ -39,7 +39,7 @@ import httpx import dashscope -from dashscope.v2 import errors +from dashscope import errors # ---- configuration (override via env) ---- # EMBED_MODEL = os.environ.get("DASHSCOPE_EMBED_MODEL", "text-embedding-v4") 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 13ce5c36..56896024 100644 --- a/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +++ b/examples/04_real_world_scenarios/realtime_audio_meeting_harness.py @@ -12,7 +12,7 @@ 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 + module note in dashscope/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. @@ -33,7 +33,7 @@ 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, +seam tests/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``. @@ -45,7 +45,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed import dashscope -from dashscope.v2 import errors +from dashscope import errors # ---- configuration (override via env) ---- # MODEL = os.environ.get("DASHSCOPE_TINGWU_MODEL", "tingwu-turbo") @@ -307,7 +307,7 @@ def _recognize(text, sentence_id, end): class FakeDuplexSocket: """A scripted duplex websocket that replays a canned event inbox. - Mirrors tests/v2/test_tingwu.py's FakeWebSocket. ``recv`` returns the + Mirrors tests/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 diff --git a/examples/04_real_world_scenarios/ticket_triage_harness.py b/examples/04_real_world_scenarios/ticket_triage_harness.py index 38ba4cc7..b74afbd9 100644 --- a/examples/04_real_world_scenarios/ticket_triage_harness.py +++ b/examples/04_real_world_scenarios/ticket_triage_harness.py @@ -36,7 +36,7 @@ import os import dashscope -from dashscope.v2 import errors +from dashscope import errors # ---- configuration (override via env) ---- # DECISION_MODEL = os.environ.get("DASHSCOPE_MODEL", "decision-model-preview") @@ -60,7 +60,7 @@ # 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. +# dashscope.resources.decision add local validation if wanted. QUESTIONS = { "department": { "type": "choice", diff --git a/examples/README.md b/examples/README.md index 751647f3..8e1f1141 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ > **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/`. +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` inside the package); legacy 1.x examples are preserved intact under `legacy_1x/`. A qualified best-practice case must demonstrate three dimensions at once: @@ -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); 2.0 audio API is a preview and may change | +| `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 | diff --git a/examples/README_zh.md b/examples/README_zh.md index 37d00573..44ddb9fd 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -2,7 +2,7 @@ > [English](README.md) | **中文** -这不是一份"API 调用参考"。在 AI 辅助开发的时代,任何人都能几秒钟生成一行 `call(model="xxx")`——本目录的价值在于**提前、正确地把一个百炼应用推向生产所需的那几百行"脏活代码"写好**:每个进阶文件都是一个微型的生产级 harness 蓝图,带有严格的工程防御,面向一个具体的开发或业务场景。全部示例基于 2.0 资源风格客户端 `dashscope.Client()`(与包内 `dashscope.v2` 一致);旧版 1.x 示例完整归档在 `legacy_1x/` 下。 +这不是一份"API 调用参考"。在 AI 辅助开发的时代,任何人都能几秒钟生成一行 `call(model="xxx")`——本目录的价值在于**提前、正确地把一个百炼应用推向生产所需的那几百行"脏活代码"写好**:每个进阶文件都是一个微型的生产级 harness 蓝图,带有严格的工程防御,面向一个具体的开发或业务场景。全部示例基于 2.0 资源风格客户端 `dashscope.Client()`(与包内 `dashscope` 一致);旧版 1.x 示例完整归档在 `legacy_1x/` 下。 一个合格的最佳实践案例必须同时展现三个维度: @@ -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` 的副本);2.0 audio API 为预览,后续可能变更 | +| `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 流程 | diff --git a/examples/legacy_1x/aio_generation/basic.py b/examples/legacy_1x/aio_generation/basic.py index e58c610b..51794a95 100644 --- a/examples/legacy_1x/aio_generation/basic.py +++ b/examples/legacy_1x/aio_generation/basic.py @@ -5,7 +5,7 @@ import asyncio import os -from dashscope.aigc.generation import AioGeneration +from dashscope.legacy_1x.aigc.generation import AioGeneration # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended), or pass diff --git a/examples/legacy_1x/aio_generation/custom_session.py b/examples/legacy_1x/aio_generation/custom_session.py index 940457b3..cd647fb5 100644 --- a/examples/legacy_1x/aio_generation/custom_session.py +++ b/examples/legacy_1x/aio_generation/custom_session.py @@ -10,7 +10,7 @@ import aiohttp import certifi -from dashscope.aigc.generation import AioGeneration +from dashscope.legacy_1x.aigc.generation import AioGeneration # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended), or pass diff --git a/examples/legacy_1x/aio_generation/deep_research.py b/examples/legacy_1x/aio_generation/deep_research.py index e442c10c..8ce3ad50 100644 --- a/examples/legacy_1x/aio_generation/deep_research.py +++ b/examples/legacy_1x/aio_generation/deep_research.py @@ -16,7 +16,7 @@ import asyncio import os -from dashscope.aigc.generation import AioGeneration +from dashscope.legacy_1x.aigc.generation import AioGeneration # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended), or pass diff --git a/examples/legacy_1x/aio_generation/reasoning.py b/examples/legacy_1x/aio_generation/reasoning.py index 19e03d2e..c7534d36 100644 --- a/examples/legacy_1x/aio_generation/reasoning.py +++ b/examples/legacy_1x/aio_generation/reasoning.py @@ -6,7 +6,7 @@ import asyncio import os -from dashscope.aigc.generation import AioGeneration +from dashscope.legacy_1x.aigc.generation import AioGeneration # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended), or pass diff --git a/examples/legacy_1x/aio_generation/tool_calls.py b/examples/legacy_1x/aio_generation/tool_calls.py index c1992ab3..a76e8448 100644 --- a/examples/legacy_1x/aio_generation/tool_calls.py +++ b/examples/legacy_1x/aio_generation/tool_calls.py @@ -5,7 +5,7 @@ import asyncio import os -from dashscope.aigc.generation import AioGeneration +from dashscope.legacy_1x.aigc.generation import AioGeneration # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended), or pass diff --git a/examples/legacy_1x/aio_image_synthesis.py b/examples/legacy_1x/aio_image_synthesis.py index 1f096a4f..b60cf764 100644 --- a/examples/legacy_1x/aio_image_synthesis.py +++ b/examples/legacy_1x/aio_image_synthesis.py @@ -9,7 +9,7 @@ import os from http import HTTPStatus -from dashscope.aigc.image_synthesis import AioImageSynthesis +from dashscope.legacy_1x.aigc.image_synthesis import AioImageSynthesis # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended) diff --git a/examples/legacy_1x/aio_video_synthesis.py b/examples/legacy_1x/aio_video_synthesis.py index 92a60632..931c04da 100644 --- a/examples/legacy_1x/aio_video_synthesis.py +++ b/examples/legacy_1x/aio_video_synthesis.py @@ -10,7 +10,7 @@ import os from http import HTTPStatus -from dashscope.aigc.video_synthesis import AioVideoSynthesis +from dashscope.legacy_1x.aigc.video_synthesis import AioVideoSynthesis # 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 47bf5d52..6f0d770e 100644 --- a/examples/legacy_1x/assistant_api_file.py +++ b/examples/legacy_1x/assistant_api_file.py @@ -9,7 +9,7 @@ import os from dashscope import Assistants # pylint: disable=no-name-in-module -from dashscope.assistants.files import Files +from dashscope.legacy_1x.assistants.files import Files # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended), and the diff --git a/examples/legacy_1x/cv_models.py b/examples/legacy_1x/cv_models.py index f5e425c0..cc17d717 100644 --- a/examples/legacy_1x/cv_models.py +++ b/examples/legacy_1x/cv_models.py @@ -7,8 +7,8 @@ import os -from dashscope.client.base_api import BaseAsyncApi -from dashscope.utils.oss_utils import check_and_upload_local +from dashscope.legacy_1x.client.base_api import BaseAsyncApi +from dashscope.legacy_1x.utils.oss_utils import check_and_upload_local # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended) diff --git a/examples/legacy_1x/image_generation.py b/examples/legacy_1x/image_generation.py index 9b427d49..19224315 100644 --- a/examples/legacy_1x/image_generation.py +++ b/examples/legacy_1x/image_generation.py @@ -10,8 +10,8 @@ import os -from dashscope.aigc.image_generation import ImageGeneration -from dashscope.api_entities.dashscope_response import Message, Role +from dashscope.legacy_1x.aigc.image_generation import ImageGeneration +from dashscope.legacy_1x.api_entities.dashscope_response import Message, Role # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended), or pass diff --git a/examples/legacy_1x/set_region_example.py b/examples/legacy_1x/set_region_example.py index 45b45d54..c3a74028 100644 --- a/examples/legacy_1x/set_region_example.py +++ b/examples/legacy_1x/set_region_example.py @@ -11,8 +11,8 @@ """ import dashscope -from dashscope.aigc.chat_completion import Completions -from dashscope.aigc.generation import Generation +from dashscope.legacy_1x.aigc.chat_completion import Completions +from dashscope.legacy_1x.aigc.generation import Generation def example_basic_usage(): diff --git a/examples/legacy_1x/tingwu_realtime.py b/examples/legacy_1x/tingwu_realtime.py index 0ec959f1..97cba0e8 100644 --- a/examples/legacy_1x/tingwu_realtime.py +++ b/examples/legacy_1x/tingwu_realtime.py @@ -16,7 +16,7 @@ import os import time -from dashscope.multimodal.tingwu.tingwu_realtime import ( +from dashscope.legacy_1x.multimodal.tingwu.tingwu_realtime import ( TingWuRealtime, TingWuRealtimeCallback, ) diff --git a/examples/legacy_1x/tingwu_usages.py b/examples/legacy_1x/tingwu_usages.py index c5a84f44..f0ed25bd 100644 --- a/examples/legacy_1x/tingwu_usages.py +++ b/examples/legacy_1x/tingwu_usages.py @@ -3,7 +3,7 @@ import os -from dashscope.multimodal.tingwu.tingwu import TingWu +from dashscope.legacy_1x.multimodal.tingwu.tingwu import TingWu # Authentication: set your Model Studio API key via the # DASHSCOPE_API_KEY environment variable (recommended) diff --git a/requirements-test.txt b/requirements-test.txt index e034ab94..3e725519 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -9,7 +9,7 @@ coverage pydantic>=2.0,<3.0 tenacity typing_extensions -# Optional extras exercised by tests/unit (cli, acli) +# Optional extras exercised by tests/legacy_1x/unit (cli, acli) typer>=0.9.0 rich>=13.0.0 prompt-toolkit>=3.0 diff --git a/requirements.txt b/requirements.txt index 62c1e161..39e53a6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ cryptography certifi>=2024.7.4 typer>=0.9.0 rich>=13.0.0 +tomli>=1.1.0; python_version < "3.11" httpx>=0.27.0 httpx-sse>=0.4.0 typing_extensions>=4.16.0 diff --git a/setup.py b/setup.py index 12345c1b..d9a94d60 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,14 @@ def readme(): class build_py(_build_py): - """Ship the top-level ``examples/`` tree inside the wheel. + """Build one clean package tree and bundle the top-level examples. + + Setuptools incrementally copies files into ``build/lib`` but does not + delete modules removed from the source tree. That is unsafe for the 2.0 + package migration: a stale ``dashscope/client/__init__.py`` shadows the + new ``dashscope/client.py`` and makes ``from dashscope.client import + Client`` fail in the built wheel. Remove the package build directory + before every build, then copy the current sources and examples only. ``examples/`` lives at the repository root (a sibling of ``dashscope/``) for visibility, but a wheel can only bundle files @@ -50,6 +57,14 @@ class build_py(_build_py): """ def run(self): + source_package = os.path.abspath(os.path.join(package_root, name)) + built_package = os.path.abspath(os.path.join(self.build_lib, name)) + if built_package == source_package: + raise RuntimeError( + "build_lib must not point at the source package directory", + ) + if os.path.isdir(built_package): + shutil.rmtree(built_package) super().run() src = os.path.join(package_root, "examples") dst = os.path.join(self.build_lib, "dashscope", "examples") @@ -65,8 +80,8 @@ def run(self): # 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. +# static packaging audit in tests/legacy_1x/unit/test_packaging_metadata.py +# keeps seeing only literal-evaluable setup() kwargs. cmdclass = {"build_py": build_py} @@ -126,5 +141,7 @@ def run(self): }, zip_safe=False, cmdclass=cmdclass, - entry_points={"console_scripts": ["dashscope = dashscope.cli:main"]}, + entry_points={ + "console_scripts": ["dashscope = dashscope.legacy_1x.cli:main"], + }, ) diff --git a/specs/chat.yaml b/specs/chat.yaml index dbc9776e..fef65b1c 100644 --- a/specs/chat.yaml +++ b/specs/chat.yaml @@ -235,7 +235,7 @@ components: $ref: "#/components/schemas/Usage" x-python: class: ChatCompletion - module: dashscope.v2.types.completions + module: dashscope.types.completions convenience: output_text: output.choices[0].message.content @@ -283,7 +283,7 @@ components: $ref: "#/components/schemas/Usage" x-python: class: ChatCompletionChunk - module: dashscope.v2.types.completions + module: dashscope.types.completions ErrorResponse: type: object @@ -311,4 +311,4 @@ components: request_id: type: string x-python: - maps_to: dashscope.v2.errors.ErrorCode + maps_to: dashscope.errors.ErrorCode diff --git a/tests/legacy_1x/__init__.py b/tests/legacy_1x/__init__.py new file mode 100644 index 00000000..5ca34947 --- /dev/null +++ b/tests/legacy_1x/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Transitional legacy test suites.""" diff --git a/tests/integration/test_large_utf8_payload.py b/tests/legacy_1x/integration/test_large_utf8_payload.py similarity index 96% rename from tests/integration/test_large_utf8_payload.py rename to tests/legacy_1x/integration/test_large_utf8_payload.py index 9a52320a..2fdd2988 100644 --- a/tests/integration/test_large_utf8_payload.py +++ b/tests/legacy_1x/integration/test_large_utf8_payload.py @@ -3,7 +3,7 @@ Usage: export DASHSCOPE_API_KEY="sk-xxx" - python3 tests/integration/test_websearch_utf8.py + python3 tests/legacy_1x/integration/test_large_utf8_payload.py """ import asyncio @@ -13,10 +13,12 @@ import pytest -from dashscope.aigc.generation import Generation, AioGeneration +from dashscope.legacy_1x.aigc.generation import Generation, AioGeneration WEBSEARCH_JSON = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), + ), "websearch.json", ) diff --git a/tests/legacy/conftest.py b/tests/legacy_1x/legacy/conftest.py similarity index 90% rename from tests/legacy/conftest.py rename to tests/legacy_1x/legacy/conftest.py index e546841c..675bf3a8 100644 --- a/tests/legacy/conftest.py +++ b/tests/legacy_1x/legacy/conftest.py @@ -7,8 +7,8 @@ import pytest import dashscope -from dashscope.common.constants import DASHSCOPE_DISABLE_DATA_INSPECTION_ENV -from tests.unit.mock_server import create_mock_server, run_server +from dashscope.legacy_1x.common.constants import DASHSCOPE_DISABLE_DATA_INSPECTION_ENV +from tests.legacy_1x.unit.mock_server import create_mock_server, run_server @pytest.fixture(scope="session", autouse=True) diff --git a/tests/legacy/test_async_api.py b/tests/legacy_1x/legacy/test_async_api.py similarity index 94% rename from tests/legacy/test_async_api.py rename to tests/legacy_1x/legacy/test_async_api.py index e8b53594..848bc237 100644 --- a/tests/legacy/test_async_api.py +++ b/tests/legacy_1x/legacy/test_async_api.py @@ -6,10 +6,10 @@ import pytest from typing import Union -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseAsyncApi -from dashscope.common.constants import ApiProtocol, HTTPMethod, TaskStatus -from tests.unit.base_test import BaseTestEnvironment +from dashscope.legacy_1x.api_entities.dashscope_response import DashScopeAPIResponse +from dashscope.legacy_1x.client.base_api import BaseAsyncApi +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod, TaskStatus +from tests.legacy_1x.unit.base_test import BaseTestEnvironment class AsyncRequest(BaseAsyncApi): diff --git a/tests/legacy/test_encryption.py b/tests/legacy_1x/legacy/test_encryption.py similarity index 95% rename from tests/legacy/test_encryption.py rename to tests/legacy_1x/legacy/test_encryption.py index 5b199612..b1ffcd6f 100644 --- a/tests/legacy/test_encryption.py +++ b/tests/legacy_1x/legacy/test_encryption.py @@ -4,7 +4,7 @@ import pytest -from dashscope.api_entities.encryption import Encryption +from dashscope.legacy_1x.api_entities.encryption import Encryption class TestEncryption: diff --git a/tests/legacy/test_image_synthesis.py b/tests/legacy_1x/legacy/test_image_synthesis.py similarity index 96% rename from tests/legacy/test_image_synthesis.py rename to tests/legacy_1x/legacy/test_image_synthesis.py index 947297b4..8a9c0d0b 100644 --- a/tests/legacy/test_image_synthesis.py +++ b/tests/legacy_1x/legacy/test_image_synthesis.py @@ -6,8 +6,8 @@ import pytest from dashscope import ImageSynthesis -from dashscope.common.constants import TaskStatus -from tests.unit.base_test import BaseTestEnvironment +from dashscope.legacy_1x.common.constants import TaskStatus +from tests.legacy_1x.unit.base_test import BaseTestEnvironment @pytest.mark.skip(reason="Legacy integration test, requires real DashScope API") diff --git a/tests/legacy/test_speech_recognizer.py b/tests/legacy_1x/legacy/test_speech_recognizer.py similarity index 96% rename from tests/legacy/test_speech_recognizer.py rename to tests/legacy_1x/legacy/test_speech_recognizer.py index 3227a169..cdebb815 100644 --- a/tests/legacy/test_speech_recognizer.py +++ b/tests/legacy_1x/legacy/test_speech_recognizer.py @@ -11,12 +11,12 @@ import pytest import dashscope -from dashscope.audio.asr import ( +from dashscope.legacy_1x.audio.asr import ( Recognition, RecognitionCallback, RecognitionResult, ) -from tests.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.base_test import BaseTestEnvironment logger = logging.getLogger("dashscope") logger.setLevel(logging.DEBUG) @@ -119,10 +119,10 @@ def complete_url(url: str) -> str: parsed = urlparse(url) base_url = "".join([parsed.scheme, "://", parsed.netloc]) dashscope.base_websocket_api_url = "/".join( - [base_url, "api-ws", dashscope.common.env.api_version, "inference"], + [base_url, "api-ws", dashscope.legacy_1x.common.env.api_version, "inference"], ) dashscope.base_http_api_url = url = "/".join( - [base_url, "api", dashscope.common.env.api_version], + [base_url, "api", dashscope.legacy_1x.common.env.api_version], ) print("Set base_websocket_api_url: ", dashscope.base_websocket_api_url) print("Set base_http_api_url: ", dashscope.base_http_api_url) diff --git a/tests/legacy/test_speech_synthesis.py b/tests/legacy_1x/legacy/test_speech_synthesis.py similarity index 98% rename from tests/legacy/test_speech_synthesis.py rename to tests/legacy_1x/legacy/test_speech_synthesis.py index 1e4d059c..a95b917b 100644 --- a/tests/legacy/test_speech_synthesis.py +++ b/tests/legacy_1x/legacy/test_speech_synthesis.py @@ -10,13 +10,13 @@ import pytest import dashscope -from dashscope.api_entities.dashscope_response import SpeechSynthesisResponse -from dashscope.audio.tts import ( +from dashscope.legacy_1x.api_entities.dashscope_response import SpeechSynthesisResponse +from dashscope.legacy_1x.audio.tts import ( ResultCallback, SpeechSynthesisResult, SpeechSynthesizer, ) -from tests.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.base_test import BaseTestEnvironment logger = logging.getLogger("dashscope") logger.setLevel(logging.DEBUG) diff --git a/tests/legacy/test_speech_transcription.py b/tests/legacy_1x/legacy/test_speech_transcription.py similarity index 95% rename from tests/legacy/test_speech_transcription.py rename to tests/legacy_1x/legacy/test_speech_transcription.py index b196d526..d083b378 100644 --- a/tests/legacy/test_speech_transcription.py +++ b/tests/legacy_1x/legacy/test_speech_transcription.py @@ -10,9 +10,9 @@ import pytest import dashscope -from dashscope.audio.asr import Transcription -from dashscope.common.constants import TaskStatus -from tests.unit.base_test import BaseTestEnvironment +from dashscope.legacy_1x.audio.asr import Transcription +from dashscope.legacy_1x.common.constants import TaskStatus +from tests.legacy_1x.unit.base_test import BaseTestEnvironment HTTPS_16K_CH1_WAV = "https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example.wav" # noqa: * HTTPS_16K_CH2_WAV = "https://isv-data.oss-cn-hangzhou.aliyuncs.com/ics/MaaS/ASR/test_audio/asr_example_2ch.wav" # noqa: * @@ -132,10 +132,10 @@ def complete_url(url: str) -> str: parsed = urlparse(url) base_url = "".join([parsed.scheme, "://", parsed.netloc]) dashscope.base_websocket_api_url = "/".join( - [base_url, "api-ws", dashscope.common.env.api_version, "inference"], + [base_url, "api-ws", dashscope.legacy_1x.common.env.api_version, "inference"], ) dashscope.base_http_api_url = url = "/".join( - [base_url, "api", dashscope.common.env.api_version], + [base_url, "api", dashscope.legacy_1x.common.env.api_version], ) print("Set base_websocket_api_url: ", dashscope.base_websocket_api_url) print("Set base_http_api_url: ", dashscope.base_http_api_url) diff --git a/tests/legacy/test_video_synthesis.py b/tests/legacy_1x/legacy/test_video_synthesis.py similarity index 95% rename from tests/legacy/test_video_synthesis.py rename to tests/legacy_1x/legacy/test_video_synthesis.py index 8b197469..cfdec0ca 100644 --- a/tests/legacy/test_video_synthesis.py +++ b/tests/legacy_1x/legacy/test_video_synthesis.py @@ -6,8 +6,8 @@ import pytest from dashscope import VideoSynthesis -from dashscope.common.constants import TaskStatus -from tests.unit.base_test import BaseTestEnvironment +from dashscope.legacy_1x.common.constants import TaskStatus +from tests.legacy_1x.unit.base_test import BaseTestEnvironment @pytest.mark.skip(reason="Legacy integration test, requires real DashScope API") diff --git a/tests/legacy/test_websocket_async_api.py b/tests/legacy_1x/legacy/test_websocket_async_api.py similarity index 98% rename from tests/legacy/test_websocket_async_api.py rename to tests/legacy_1x/legacy/test_websocket_async_api.py index 00a6a970..ad32cdc8 100644 --- a/tests/legacy/test_websocket_async_api.py +++ b/tests/legacy_1x/legacy/test_websocket_async_api.py @@ -4,10 +4,10 @@ import pytest import dashscope -from dashscope.protocol.websocket import WebsocketStreamingMode -from tests.unit.base_test import BaseTestEnvironment -from tests.unit.constants import TestTasks -from tests.unit.websocket_task_request import WebSocketRequest +from dashscope.legacy_1x.protocol.websocket import WebsocketStreamingMode +from tests.legacy_1x.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.constants import TestTasks +from tests.legacy_1x.unit.websocket_task_request import WebSocketRequest # set mock server url. base_websocket_api_url = "ws://localhost:8080/ws/aigc/v1" diff --git a/tests/legacy/test_websocket_parameters.py b/tests/legacy_1x/legacy/test_websocket_parameters.py similarity index 92% rename from tests/legacy/test_websocket_parameters.py rename to tests/legacy_1x/legacy/test_websocket_parameters.py index 2211ec5a..b5381ab5 100644 --- a/tests/legacy/test_websocket_parameters.py +++ b/tests/legacy_1x/legacy/test_websocket_parameters.py @@ -2,14 +2,14 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import dashscope -from dashscope.protocol.websocket import WebsocketStreamingMode -from tests.unit.base_test import BaseTestEnvironment -from tests.unit.constants import ( +from dashscope.legacy_1x.protocol.websocket import WebsocketStreamingMode +from tests.legacy_1x.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.constants import ( TEST_DISABLE_DATA_INSPECTION_REQUEST_ID, TEST_ENABLE_DATA_INSPECTION_REQUEST_ID, TestTasks, ) -from tests.unit.websocket_task_request import WebSocketRequest +from tests.legacy_1x.unit.websocket_task_request import WebSocketRequest def pytest_generate_tests(metafunc): diff --git a/tests/legacy/test_websocket_sync_api.py b/tests/legacy_1x/legacy/test_websocket_sync_api.py similarity index 98% rename from tests/legacy/test_websocket_sync_api.py rename to tests/legacy_1x/legacy/test_websocket_sync_api.py index 46dede12..a94703ed 100644 --- a/tests/legacy/test_websocket_sync_api.py +++ b/tests/legacy_1x/legacy/test_websocket_sync_api.py @@ -2,10 +2,10 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import dashscope -from dashscope.protocol.websocket import WebsocketStreamingMode -from tests.unit.base_test import BaseTestEnvironment -from tests.unit.constants import TestTasks -from tests.unit.websocket_task_request import WebSocketRequest +from dashscope.legacy_1x.protocol.websocket import WebsocketStreamingMode +from tests.legacy_1x.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.constants import TestTasks +from tests.legacy_1x.unit.websocket_task_request import WebSocketRequest def pytest_generate_tests(metafunc): diff --git a/tests/legacy_1x/unit/__init__.py b/tests/legacy_1x/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/base_test.py b/tests/legacy_1x/unit/base_test.py similarity index 100% rename from tests/unit/base_test.py rename to tests/legacy_1x/unit/base_test.py diff --git a/tests/unit/conftest.py b/tests/legacy_1x/unit/conftest.py similarity index 91% rename from tests/unit/conftest.py rename to tests/legacy_1x/unit/conftest.py index 78068085..644dc3be 100644 --- a/tests/unit/conftest.py +++ b/tests/legacy_1x/unit/conftest.py @@ -6,8 +6,10 @@ import pytest -from dashscope.common.constants import DASHSCOPE_DISABLE_DATA_INSPECTION_ENV -from tests.unit.mock_server import create_mock_server, run_server +from dashscope.legacy_1x.common.constants import ( + DASHSCOPE_DISABLE_DATA_INSPECTION_ENV, +) +from tests.legacy_1x.unit.mock_server import create_mock_server, run_server @pytest.fixture(autouse=True) diff --git a/tests/unit/constants.py b/tests/legacy_1x/unit/constants.py similarity index 100% rename from tests/unit/constants.py rename to tests/legacy_1x/unit/constants.py diff --git a/tests/unit/handle_deployment_request.py b/tests/legacy_1x/unit/handle_deployment_request.py similarity index 98% rename from tests/unit/handle_deployment_request.py rename to tests/legacy_1x/unit/handle_deployment_request.py index 1930d02c..e3c4adbf 100644 --- a/tests/unit/handle_deployment_request.py +++ b/tests/legacy_1x/unit/handle_deployment_request.py @@ -6,7 +6,7 @@ import aiohttp from aiohttp import web -from tests.unit.constants import TEST_JOB_ID +from tests.legacy_1x.unit.constants import TEST_JOB_ID async def create_deployment_handler(request: aiohttp.request): diff --git a/tests/unit/handle_fine_tune_request.py b/tests/legacy_1x/unit/handle_fine_tune_request.py similarity index 98% rename from tests/unit/handle_fine_tune_request.py rename to tests/legacy_1x/unit/handle_fine_tune_request.py index c9bf07a1..404678a5 100644 --- a/tests/unit/handle_fine_tune_request.py +++ b/tests/legacy_1x/unit/handle_fine_tune_request.py @@ -7,7 +7,7 @@ import aiohttp from aiohttp import web -from tests.unit.mock_sse import sse_response +from tests.legacy_1x.unit.mock_sse import sse_response from .constants import TEST_JOB_ID # pylint: disable=relative-beyond-top-level diff --git a/tests/unit/helper.py b/tests/legacy_1x/unit/helper.py similarity index 100% rename from tests/unit/helper.py rename to tests/legacy_1x/unit/helper.py diff --git a/tests/unit/http_task_request.py b/tests/legacy_1x/unit/http_task_request.py similarity index 87% rename from tests/unit/http_task_request.py rename to tests/legacy_1x/unit/http_task_request.py index b8e893e6..47f4c05f 100644 --- a/tests/unit/http_task_request.py +++ b/tests/legacy_1x/unit/http_task_request.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseAioApi, BaseApi -from dashscope.common.constants import ApiProtocol, HTTPMethod +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import BaseAioApi, BaseApi +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod class HttpRequest(BaseApi, BaseAioApi): diff --git a/tests/unit/mock_request_base.py b/tests/legacy_1x/unit/mock_request_base.py similarity index 92% rename from tests/unit/mock_request_base.py rename to tests/legacy_1x/unit/mock_request_base.py index c5c016b0..57d441ae 100644 --- a/tests/unit/mock_request_base.py +++ b/tests/legacy_1x/unit/mock_request_base.py @@ -2,7 +2,7 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import dashscope -from tests.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.base_test import BaseTestEnvironment class MockRequestBase(BaseTestEnvironment): diff --git a/tests/unit/mock_server.py b/tests/legacy_1x/unit/mock_server.py similarity index 98% rename from tests/unit/mock_server.py rename to tests/legacy_1x/unit/mock_server.py index 0713088c..c6b91104 100644 --- a/tests/unit/mock_server.py +++ b/tests/legacy_1x/unit/mock_server.py @@ -11,20 +11,20 @@ import aiohttp from aiohttp import web -from dashscope.protocol.websocket import ActionType -from tests.unit.constants import ( +from dashscope.legacy_1x.protocol.websocket import ActionType +from tests.legacy_1x.unit.constants import ( TEST_DISABLE_DATA_INSPECTION_REQUEST_ID, TEST_ENABLE_DATA_INSPECTION_REQUEST_ID, TEST_JOB_ID, ) -from tests.unit.handle_deployment_request import ( +from tests.legacy_1x.unit.handle_deployment_request import ( create_deployment_handler, delete_deployment_handler, events_deployment_handler, get_deployment_handler, list_deployment_handler, ) -from tests.unit.handle_fine_tune_request import ( +from tests.legacy_1x.unit.handle_fine_tune_request import ( cancel_fine_tune_handler, create_fine_tune_handler, delete_fine_tune_handler, @@ -32,8 +32,8 @@ get_fine_tune_handler, list_fine_tune_handler, ) -from tests.unit.mock_sse import sse_response -from tests.unit.websocket_mock_server_task_handler import ( +from tests.legacy_1x.unit.mock_sse import sse_response +from tests.legacy_1x.unit.websocket_mock_server_task_handler import ( WebSocketTaskProcessor, ) diff --git a/tests/unit/mock_sse.py b/tests/legacy_1x/unit/mock_sse.py similarity index 100% rename from tests/unit/mock_sse.py rename to tests/legacy_1x/unit/mock_sse.py diff --git a/tests/unit/test_acli_acceptance.py b/tests/legacy_1x/unit/test_acli_acceptance.py similarity index 100% rename from tests/unit/test_acli_acceptance.py rename to tests/legacy_1x/unit/test_acli_acceptance.py diff --git a/tests/unit/test_acli_command_timeout.py b/tests/legacy_1x/unit/test_acli_command_timeout.py similarity index 100% rename from tests/unit/test_acli_command_timeout.py rename to tests/legacy_1x/unit/test_acli_command_timeout.py diff --git a/tests/unit/test_acli_command_trust.py b/tests/legacy_1x/unit/test_acli_command_trust.py similarity index 100% rename from tests/unit/test_acli_command_trust.py rename to tests/legacy_1x/unit/test_acli_command_trust.py diff --git a/tests/unit/test_acli_provider_retries.py b/tests/legacy_1x/unit/test_acli_provider_retries.py similarity index 100% rename from tests/unit/test_acli_provider_retries.py rename to tests/legacy_1x/unit/test_acli_provider_retries.py diff --git a/tests/unit/test_acli_reflection.py b/tests/legacy_1x/unit/test_acli_reflection.py similarity index 100% rename from tests/unit/test_acli_reflection.py rename to tests/legacy_1x/unit/test_acli_reflection.py diff --git a/tests/legacy_1x/unit/test_acli_sdk_contract.py b/tests/legacy_1x/unit/test_acli_sdk_contract.py new file mode 100644 index 00000000..e6bdc9ac --- /dev/null +++ b/tests/legacy_1x/unit/test_acli_sdk_contract.py @@ -0,0 +1,376 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The acli <-> SDK contract, pinned so the 1.x/2.0 layout move is safe. + +``dashscope/acli/`` is a one-way sync copy of the agenticCLI repo and a +neutral top-level product surface: it serves both stacks, belongs to +neither, and must not be refactored here because the next sync would +overwrite the edit. What this repo *does* own is the SDK surface acli +consumes, so that is what gets pinned. + +The dependency surface is derived from acli's own AST instead of being +hardcoded. acli is re-synced from upstream, so line numbers drift and a +sync can introduce new coupling; deriving the check from the tree means +the test reports that coupling rather than silently going stale. + +The load-bearing assertion is ``api_key``. ``acli/ui/tts.py`` writes the +1.x root global ``dashscope.api_key`` because the audio SDK reads it on +paths that do not consult the environment. Promoting 2.0 to the top +level rewrites ``dashscope/__init__.py``, and 2.0 takes the key as +``Client(api_key=...)`` with no root global -- dropping the attribute +would break acli's voice output at ``if not dashscope.api_key``, while +``is_available()`` (which only does ``import dashscope``) keeps +reporting True and defers the failure to the end user. + +acli's deliberately separate session id is covered by +``tests/legacy_1x/unit/test_sdk_headers.py``; it is not repeated here. +""" + +from __future__ import annotations + +import ast +import importlib +import importlib.util +import pathlib +from functools import lru_cache + +import dashscope + +_REPO = pathlib.Path(__file__).resolve().parents[3] +_ACLI = _REPO / "dashscope" / "acli" +_CURRENT = ( + _REPO / "dashscope" / "client.py", + _REPO / "dashscope" / "errors.py", + _REPO / "dashscope" / "protocols", + _REPO / "dashscope" / "resources", + _REPO / "dashscope" / "transport", + _REPO / "dashscope" / "types", + _REPO / "dashscope" / "applications" / "agentstudio", + _REPO / "dashscope" / "applications" / "finetune", +) +_ROOT_INIT = _REPO / "dashscope" / "__init__.py" + +# What acli is allowed to couple to. The bare ``dashscope`` entry is here +# because acli reads and writes the root global ``dashscope.api_key``; the +# two audio modules are the deep paths the design doc calls out. Anything +# else means a sync added coupling this repo should accept consciously. +_ALLOWLIST = frozenset( + { + "dashscope", + "dashscope.audio.asr", + "dashscope.audio.tts_v2", + }, +) + +_ACLI_EXTRAS = frozenset( + { + "acli", + "acli-all", + "acli-anthropic", + "acli-camera", + "acli-openai", + "acli-voice", + }, +) + + +def _is_acli_internal(module: str) -> bool: + parts = module.split(".") + return len(parts) > 1 and parts[1] == "acli" + + +def _parse(root: pathlib.Path): + """Parse every module under ``root`` (or ``root`` itself, if a file). + + A syntax error is a failure, not a skip: silently skipping would let + an unparseable file drop out of the survey and hollow out the guard. + """ + assert root.exists(), f"scan target does not exist: {root}" + paths = [root] if root.is_file() else sorted(root.rglob("*.py")) + assert paths, f"scan target contains no Python modules: {root}" + parsed = [] + for path in paths: + source = path.read_text(encoding="utf-8") + try: + parsed.append((path, ast.parse(source, str(path)))) + except SyntaxError as exc: + raise AssertionError(f"{path} does not parse: {exc}") from exc + return parsed + + +@lru_cache(maxsize=None) +def _parsed(root_str: str): + """Cached ``_parse``; four helpers walk the ~38k-line acli tree.""" + return tuple(_parse(pathlib.Path(root_str))) + + +@lru_cache(maxsize=None) +def _imports_under(root_str: str): + """{module: [file:line]} for absolute ``dashscope.*`` imports.""" + found = {} + for path, tree in _parsed(root_str): + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + if node.level != 0 or not node.module: + continue + modules = [node.module] + else: + continue + for module in modules: + if module.split(".")[0] != "dashscope": + continue + found.setdefault(module, []).append(f"{path}:{node.lineno}") + return found + + +@lru_cache(maxsize=None) +def _from_import_names(root_str: str): + """{module: sorted(names)} for ``from import a, b``. + + Only ``dashscope.*`` is collected: acli's own optional extras (openai, + playwright, PIL, sounddevice) are upstream's business and are not + installed here, so probing them would test this machine rather than + the contract. + """ + found = {} + for _, tree in _parsed(root_str): + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom): + continue + if node.level != 0 or not node.module: + continue + if node.module.split(".")[0] != "dashscope": + continue + names = [alias.name for alias in node.names if alias.name != "*"] + if names: + found.setdefault(node.module, set()).update(names) + return {mod: sorted(names) for mod, names in found.items()} + + +def _acli_external_imports(): + imports = _imports_under(str(_ACLI)) + return {m: loc for m, loc in imports.items() if not _is_acli_internal(m)} + + +def _acli_external_names(): + names = _from_import_names(str(_ACLI)) + return {m: n for m, n in names.items() if not _is_acli_internal(m)} + + +def _root_global_writes(): + """[(attr, file:line)] where acli assigns to ``dashscope.``.""" + writes = [] + for path, tree in _parsed(str(_ACLI)): + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + for target in node.targets: + if not isinstance(target, ast.Attribute): + continue + base = target.value + if isinstance(base, ast.Name) and base.id == "dashscope": + writes.append((target.attr, f"{path}:{node.lineno}")) + return writes + + +def _environ_setdefault_calls(): + """[file:line] of ``os.environ.setdefault("DASHSCOPE_API_KEY", ...)``.""" + calls = [] + for path, tree in _parsed(str(_ACLI)): + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not node.args: + continue + func = node.func + if not isinstance(func, ast.Attribute): + continue + if func.attr != "setdefault": + continue + env = func.value + if not ( + isinstance(env, ast.Attribute) + and env.attr == "environ" + and isinstance(env.value, ast.Name) + and env.value.id == "os" + ): + continue + key = node.args[0] + if isinstance(key, ast.Constant) and ( + key.value == "DASHSCOPE_API_KEY" + ): + calls.append(f"{path}:{node.lineno}") + return calls + + +# --------------------------------------------------------------------------- +# Dependency surface +# --------------------------------------------------------------------------- + + +def test_acli_external_dashscope_imports_match_allowlist(): + found = _acli_external_imports() + added = sorted(set(found) - _ALLOWLIST) + gone = sorted(_ALLOWLIST - set(found)) + assert not added and not gone, ( + "acli's coupling to the SDK changed.\n" + f" added by a sync (accept consciously, then extend _ALLOWLIST): " + f"{str({m: found[m] for m in added})}\n" + f" no longer imported (_ALLOWLIST is stale): {gone}" + ) + + +def test_acli_does_not_import_the_v2_package(): + v2 = { + m: loc + for m, loc in _acli_external_imports().items() + if m.split(".")[1:2] == ["v2"] + } + assert not v2, ( + "acli is a 1.x-side consumer today; moving it onto the 2.0 client " + f"is upstream agenticCLI work, not this repo's: {v2}" + ) + + +# --------------------------------------------------------------------------- +# Audio contract +# --------------------------------------------------------------------------- + + +def test_every_name_acli_imports_from_the_sdk_resolves(): + names = _acli_external_names() + assert names, "expected acli to import names from the SDK" + broken = {} + for module, wanted in sorted(names.items()): + try: + imported = importlib.import_module(module) + except ImportError as exc: + broken[module] = f"import failed: {exc}" + continue + absent = [name for name in wanted if not hasattr(imported, name)] + if absent: + broken[module] = absent + assert not broken, f"SDK surface acli relies on is incomplete: {broken}" + + +def test_tts_v2_exposes_the_names_acli_uses(): + module = importlib.import_module("dashscope.audio.tts_v2") + for name in ("AudioFormat", "ResultCallback", "SpeechSynthesizer"): + assert hasattr( + module, + name, + ), f"dashscope.audio.tts_v2.{name} is gone" + + +def test_asr_exposes_the_names_acli_uses(): + module = importlib.import_module("dashscope.audio.asr") + for name in ("Recognition", "RecognitionCallback"): + assert hasattr( + module, + name, + ), f"dashscope.audio.asr.{name} is gone" + + +# --------------------------------------------------------------------------- +# The root global acli writes +# --------------------------------------------------------------------------- + + +def test_root_api_key_is_readable_and_writable(monkeypatch): + assert hasattr(dashscope, "api_key"), ( + "the root package must keep exposing ``api_key``: acli's voice " + "output reads it and then assigns to it" + ) + monkeypatch.setattr(dashscope, "api_key", "sk-acli-contract") + assert dashscope.api_key == "sk-acli-contract" + + +def test_acli_tts_still_writes_the_root_api_key(): + attrs = {attr for attr, _ in _root_global_writes()} + assert "api_key" in attrs, ( + "acli no longer assigns ``dashscope.api_key``. If upstream moved " + "to the environment variable alone, the root-global requirement " + "has genuinely relaxed -- confirm that, then drop this test and " + "the one above it." + ) + + +def test_acli_also_sets_the_env_var(): + assert _environ_setdefault_calls(), ( + "acli is expected to os.environ.setdefault('DASHSCOPE_API_KEY', " + "...) alongside the root global, so both discovery paths work" + ) + + +# --------------------------------------------------------------------------- +# Neutrality: acli belongs to neither side +# --------------------------------------------------------------------------- + + +def test_current_implementation_does_not_import_acli(): + hits = {} + for root in _CURRENT: + hits.update( + { + module: locations + for module, locations in _imports_under(str(root)).items() + if _is_acli_internal(module) + }, + ) + assert ( + not hits + ), f"the current implementation must not depend on acli: {hits}" + + +def test_package_root_does_not_import_acli(): + hits = { + m: loc + for m, loc in _imports_under(str(_ROOT_INIT)).items() + if _is_acli_internal(m) + } + assert not hits, f"the 1.x package root must not depend on acli: {hits}" + + +def test_acli_lives_at_the_top_level(): + assert _ACLI.is_dir(), "dashscope/acli/ must stay at the top level" + moved_into_legacy = (_REPO / "dashscope" / "legacy_1x" / "acli").exists() + assert not moved_into_legacy, "acli must not land in the legacy bucket" + + +def test_acli_is_not_reexported_at_the_package_root(): + assert "acli" not in dashscope.__all__ + source = _ROOT_INIT.read_text(encoding="utf-8") + assert "acli" not in source, ( + "acli is an independently installed entry point, not part of the " + "1.x API layer" + ) + + +# --------------------------------------------------------------------------- +# Entry point and packaging +# --------------------------------------------------------------------------- + + +def test_module_entry_point_resolvable(): + spec = importlib.util.find_spec("dashscope.acli.__main__") + assert spec is not None, "``python -m dashscope.acli`` needs __main__.py" + main = importlib.import_module("dashscope.acli.cli").main + assert callable(main) + + +def test_setup_declares_the_acli_extras(): + tree = ast.parse( + (_REPO / "setup.py").read_text(encoding="utf-8"), + "setup.py", + ) + groups = None + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == "extras_require": + groups = { + key.value + for key in node.value.keys + if isinstance(key, ast.Constant) + } + assert groups is not None, "setup.py lost its extras_require" + missing = sorted(_ACLI_EXTRAS - groups) + assert not missing, f"setup.py extras_require is missing: {missing}" diff --git a/tests/unit/test_acli_skill_render.py b/tests/legacy_1x/unit/test_acli_skill_render.py similarity index 100% rename from tests/unit/test_acli_skill_render.py rename to tests/legacy_1x/unit/test_acli_skill_render.py diff --git a/tests/unit/test_acli_usage_report.py b/tests/legacy_1x/unit/test_acli_usage_report.py similarity index 100% rename from tests/unit/test_acli_usage_report.py rename to tests/legacy_1x/unit/test_acli_usage_report.py diff --git a/tests/unit/test_acli_vision_tool.py b/tests/legacy_1x/unit/test_acli_vision_tool.py similarity index 100% rename from tests/unit/test_acli_vision_tool.py rename to tests/legacy_1x/unit/test_acli_vision_tool.py diff --git a/tests/unit/test_add_resources.py b/tests/legacy_1x/unit/test_add_resources.py similarity index 96% rename from tests/unit/test_add_resources.py rename to tests/legacy_1x/unit/test_add_resources.py index b06165b2..ea180ff7 100644 --- a/tests/unit/test_add_resources.py +++ b/tests/legacy_1x/unit/test_add_resources.py @@ -5,9 +5,9 @@ from http import HTTPStatus from dashscope import Generation -from tests.unit.http_task_request import HttpRequest -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.http_task_request import HttpRequest +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer model = Generation.Models.qwen_turbo diff --git a/tests/unit/test_agentic_rl_cli_opd.py b/tests/legacy_1x/unit/test_agentic_rl_cli_opd.py similarity index 72% rename from tests/unit/test_agentic_rl_cli_opd.py rename to tests/legacy_1x/unit/test_agentic_rl_cli_opd.py index e217842e..1140ab98 100644 --- a/tests/unit/test_agentic_rl_cli_opd.py +++ b/tests/legacy_1x/unit/test_agentic_rl_cli_opd.py @@ -8,22 +8,24 @@ from typer.main import get_command from typer.testing import CliRunner -from dashscope.cli.agentic_rl import _run_workflow_async, app -from dashscope.finetune.agentic_rl import AgenticRL +from dashscope.legacy_1x.cli.agentic_rl import _run_workflow_async, app +from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL def test_run_help_explains_conditional_function_requirements(): result = CliRunner().invoke(app, ["run", "--help"]) assert result.exit_code == 0 - # 参数存在性走 typer 命令模型校验:渲染文本在不同终端宽度/平台下会被 - # rich 截断或连字符换行(CI Linux 曾因此误报 --teacher-model 缺失), - # 不断言渲染后的 help 文本里出现完整 option 名。 + # Parameter presence is asserted through typer's command model: rich + # truncates or hyphen-wraps rendered help at some terminal widths + # (CI Linux once false-reported --teacher-model as missing), so we do + # not assert full option names in the rendered text. run_cmd = get_command(app).commands["run"] assert "teacher_model" in {param.name for param in run_cmd.params} - # help 文档仍须说明 OPD 的条件化函数要求;剥离 ANSI 后按空白归一, - # 只断言散文式描述(按空格折行,归一化后稳定)。 + # The help text must still document OPD's conditional function + # requirements; strip ANSI and normalize whitespace, then assert only + # the prose description (folded at spaces, stable once normalized). help_text = " ".join(re.sub(r"\x1b\[[0-9;]*m", "", result.output).split()) assert ( "1. Configuration-driven: Use -c/--config to specify a YAML file" diff --git a/tests/unit/test_agentic_rl_components.py b/tests/legacy_1x/unit/test_agentic_rl_components.py similarity index 100% rename from tests/unit/test_agentic_rl_components.py rename to tests/legacy_1x/unit/test_agentic_rl_components.py diff --git a/tests/legacy_1x/unit/test_agentic_rl_layer_status.py b/tests/legacy_1x/unit/test_agentic_rl_layer_status.py new file mode 100644 index 00000000..cd50cfd9 --- /dev/null +++ b/tests/legacy_1x/unit/test_agentic_rl_layer_status.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +"""Layer readiness must gate instance loading instead of being swallowed. + +``get_layer`` used to catch its own ``FunctionLayerError`` and return +``"SUCCESS"``, so an unbuilt layer surfaced later as an unrelated +``Empty instance ID`` load failure. These cover the terminal states, the +stalled build, and the two error paths in ``load``. +""" + +import pytest + +from dashscope.finetune.reinforcement import ( + FunctionComponentModel, + FunctionComponentRuntime, + FunctionLayerError, + RolloutFunctionComponent, +) +from dashscope.finetune.reinforcement.common import model + +LAYER_CODE = "layer_c5669b3b602d4ae9b99bc8f576e4fdce" +ENTITY_ID = "ro-85a1dc67-cd85-49a9-855b-b2bb575d80ed" + +# The payload the FaaS gateway returns when a layer is missing in a region: +# HTTP 200 with the failure described in the body and no ``output.status``. +LAYER_NOT_FOUND_BODY = { + "request_id": "55bd69f2-3e3b-9cc0-989c-07b867aa9f17", + "message": "FaaS layer not found for region cn-wulanchabu", + "code": "BadRequest", + "status": {"code": 200, "message": "OK"}, + "output": {}, +} + +# A layer that is already built, followed by a successful instance load. +READY_LAYER_THEN_INSTANCE = [ + {"output": {"status": "SUCCESS"}}, + { + "output": { + "instanceId": "in-1", + "trigger_url": "https://example.com/invoke", + "trigger_token": "token", + }, + }, +] + + +def _stub_responses(monkeypatch, responses): + """Serve ``responses`` in order, repeating the last one, and count calls.""" + calls = {"count": 0, "payloads": []} + + async def fake_client_fc(*args, **kwargs): + index = calls["count"] + calls["count"] += 1 + calls["payloads"].append(args[2] if len(args) > 2 else kwargs) + response = responses[min(index, len(responses) - 1)] + if isinstance(response, Exception): + raise response + return response + + monkeypatch.setattr(model, "client_fc", fake_client_fc) + return calls + + +def _component(with_runtime=True): + component = RolloutFunctionComponent(name="rollout-1") + component.entity_id = ENTITY_ID + component.fcmodel = FunctionComponentModel() + if with_runtime: + component.runtime = FunctionComponentRuntime(layer_code=LAYER_CODE) + return component + + +async def test_get_layer_returns_ready_status(monkeypatch): + calls = _stub_responses(monkeypatch, [{"output": {"status": "SUCCESS"}}]) + + assert await FunctionComponentModel().get_layer(LAYER_CODE) == "SUCCESS" + assert calls["count"] == 1 + + +async def test_get_layer_waits_out_a_slow_build(monkeypatch): + calls = _stub_responses( + monkeypatch, + [ + {"output": {"status": "RUNNING"}}, + {"output": {"status": "RUNNING"}}, + {"output": {"status": "SUCCESS"}}, + ], + ) + monkeypatch.setattr(model, "FC_LAYER_STATUS_INTERVAL", 0) + + assert await FunctionComponentModel().get_layer(LAYER_CODE) == "SUCCESS" + assert calls["count"] == 3 + + +async def test_get_layer_raises_on_failed_build(monkeypatch): + _stub_responses( + monkeypatch, + [{"output": {"status": "FAILED"}, "message": "pip install exploded"}], + ) + + with pytest.raises(FunctionLayerError) as excinfo: + await FunctionComponentModel().get_layer(LAYER_CODE) + + message = str(excinfo.value) + assert "pip install exploded" in message + assert LAYER_CODE in message + # The class prefix names the subsystem, not the create stage, so a build + # failure must not read as "create failed: ... reported FAILED". + assert "Function layer failed: build reported FAILED" in message + assert excinfo.value.layer_code == LAYER_CODE + assert excinfo.value.layer_name is None + + +async def test_get_layer_raises_on_region_rejection(monkeypatch): + calls = _stub_responses(monkeypatch, [LAYER_NOT_FOUND_BODY]) + + with pytest.raises(FunctionLayerError) as excinfo: + await FunctionComponentModel().get_layer(LAYER_CODE) + + assert "cn-wulanchabu" in str(excinfo.value) + assert calls["count"] == 1 + + +async def test_get_layer_raises_when_build_stalls(monkeypatch): + calls = _stub_responses(monkeypatch, [{"output": {"status": "RUNNING"}}]) + monkeypatch.setattr(model, "FC_LAYER_STATUS_TIMEOUT", 0.2) + monkeypatch.setattr(model, "FC_LAYER_STATUS_INTERVAL", 0.05) + + with pytest.raises(FunctionLayerError) as excinfo: + await FunctionComponentModel().get_layer(LAYER_CODE) + + message = str(excinfo.value) + assert "timed out" in message + assert "RUNNING" in message + assert "FC_LAYER_STATUS_TIMEOUT" in message + assert calls["count"] > 3 + + +async def test_get_layer_raises_on_transport_error(monkeypatch): + _stub_responses(monkeypatch, [RuntimeError("connection reset")]) + + with pytest.raises(FunctionLayerError) as excinfo: + await FunctionComponentModel().get_layer(LAYER_CODE) + + assert "connection reset" in str(excinfo.value) + + +async def test_load_reports_layer_failure_as_such(monkeypatch): + _stub_responses(monkeypatch, [{"output": {"status": "FAILED"}}]) + monkeypatch.setattr(model, "FC_LAYER_USED", True) + + result = await _component().load(entity_id=ENTITY_ID) + + assert not result.status.success + assert result.status.name == "FunctionLayerError" + assert result.status.code == 526 + assert LAYER_CODE in result.status.message + assert "Empty instance ID" not in result.status.message + + +async def test_load_without_runtime_explains_missing_layer_code(monkeypatch): + _stub_responses(monkeypatch, [{"output": {"status": "SUCCESS"}}]) + monkeypatch.setattr(model, "FC_LAYER_USED", True) + + result = await _component(with_runtime=False).load(entity_id=ENTITY_ID) + + assert not result.status.success + assert "layer_code is required" in result.status.message + assert "NoneType" not in result.status.message + + +async def test_load_accepts_layer_code_from_the_runtime_argument(monkeypatch): + """The 2022 message tells callers to pass a runtime carrying layer_code, + so ``load`` must honour that argument when ``self.runtime`` is absent.""" + _stub_responses(monkeypatch, READY_LAYER_THEN_INSTANCE) + monkeypatch.setattr(model, "FC_LAYER_USED", True) + + result = await _component(with_runtime=False).load( + entity_id=ENTITY_ID, + runtime=FunctionComponentRuntime(layer_code=LAYER_CODE), + ) + + assert result.status.success + assert result.output["instance_id"] == "in-1" + + +async def test_load_waits_on_the_runtime_argument_layer(monkeypatch): + """The argument already overrides ``self.runtime`` in the request body, so + it must also decide which layer build is waited on.""" + calls = _stub_responses(monkeypatch, READY_LAYER_THEN_INSTANCE) + monkeypatch.setattr(model, "FC_LAYER_USED", True) + + override_code = "layer_0f3d9c1a77b24e08a5c6d4b1e9f07a23" + result = await _component().load( + entity_id=ENTITY_ID, + runtime=FunctionComponentRuntime(layer_code=override_code), + ) + + assert result.status.success + assert calls["payloads"][0] == {"layer_code": override_code} + assert calls["payloads"][1]["layer_code"] == override_code + + +async def test_load_reports_server_rejection_with_context(monkeypatch): + _stub_responses( + monkeypatch, + [{"output": {"status": "SUCCESS"}}, LAYER_NOT_FOUND_BODY], + ) + monkeypatch.setattr(model, "FC_LAYER_USED", True) + + result = await _component().load(entity_id=ENTITY_ID) + + assert not result.status.success + assert result.status.name == "FunctionLoadError" + assert ENTITY_ID in result.status.message + assert LAYER_CODE in result.status.message + assert "cn-wulanchabu" in result.status.message + + +async def test_load_succeeds_once_layer_is_ready(monkeypatch): + _stub_responses(monkeypatch, READY_LAYER_THEN_INSTANCE) + monkeypatch.setattr(model, "FC_LAYER_USED", True) + + result = await _component().load(entity_id=ENTITY_ID) + + assert result.status.success + assert result.output["instance_id"] == "in-1" diff --git a/tests/unit/test_agentic_rl_model.py b/tests/legacy_1x/unit/test_agentic_rl_model.py similarity index 100% rename from tests/unit/test_agentic_rl_model.py rename to tests/legacy_1x/unit/test_agentic_rl_model.py diff --git a/tests/unit/test_agentic_rl_submit_job.py b/tests/legacy_1x/unit/test_agentic_rl_submit_job.py similarity index 91% rename from tests/unit/test_agentic_rl_submit_job.py rename to tests/legacy_1x/unit/test_agentic_rl_submit_job.py index 9fc79ffb..113dfa96 100644 --- a/tests/unit/test_agentic_rl_submit_job.py +++ b/tests/legacy_1x/unit/test_agentic_rl_submit_job.py @@ -287,7 +287,7 @@ async def test_main_workflow_success( "dashscope.finetune.reinforcement.common.utils.set_api_key", ), patch.object( __import__( - "dashscope.finetune.agentic_rl", + "dashscope.legacy_1x.finetune.agentic_rl", fromlist=["AgenticRL"], ), "AgenticRL", @@ -296,7 +296,7 @@ async def test_main_workflow_success( instance.run = AsyncMock(return_value=mock_success_run_result) MockAgenticRL.get = MagicMock(return_value=mock_get_result) - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL client = AgenticRL() result = await client.run( @@ -347,7 +347,7 @@ async def test_main_workflow_success( @pytest.mark.asyncio async def test_main_workflow_submit_failure(self, mock_failure_run_result): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL with patch.object( AgenticRL, @@ -380,7 +380,7 @@ async def test_main_workflow_submit_failure(self, mock_failure_run_result): @pytest.mark.asyncio async def test_main_workflow_run_exception(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL with patch.object( AgenticRL, @@ -392,7 +392,10 @@ async def test_main_workflow_run_exception(self): new_callable=AsyncMock, side_effect=Exception("Network error"), ): + # astroid reads __new__ as a classmethod through the 1.x->v2 MRO + # pylint: disable=no-value-for-parameter client = AgenticRL.__new__(AgenticRL) + # pylint: enable=no-value-for-parameter with pytest.raises(Exception, match="Network error"): await client.run( model="qwen3.5-9b", @@ -417,7 +420,7 @@ async def test_yaml_workflow_success( mock_success_run_result, mock_get_result, ): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement import TuningModel with patch.object(AgenticRL, "init") as mock_init, patch.object( @@ -456,7 +459,7 @@ async def test_yaml_workflow_submit_failure( self, mock_failure_run_result, ): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL with patch.object(AgenticRL, "init"), patch.object( AgenticRL, @@ -477,7 +480,7 @@ async def test_yaml_workflow_submit_failure( @pytest.mark.asyncio async def test_yaml_workflow_to_yaml_export(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement import TuningModel client = AgenticRL() @@ -496,7 +499,7 @@ async def test_yaml_workflow_to_yaml_export(self): class TestAgenticRLClient: def test_init_with_valid_api_key(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL with patch( "dashscope.finetune.agentic_rl.set_api_key", @@ -506,7 +509,7 @@ def test_init_with_valid_api_key(self): assert isinstance(client, AgenticRL) def test_init_with_invalid_api_key(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement.common.errors import ( ValueErrorWithCode, ) @@ -519,7 +522,7 @@ def test_init_with_invalid_api_key(self): AgenticRL(api_key="invalid") def test_init_without_api_key(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL with patch( "dashscope.finetune.agentic_rl.set_api_key", @@ -528,7 +531,7 @@ def test_init_without_api_key(self): mock_set_key.assert_called_once_with(None) def test_init_with_api_key(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL with patch( "dashscope.finetune.agentic_rl.set_api_key", @@ -537,7 +540,7 @@ def test_init_with_api_key(self): mock_set_key.assert_called_once_with("your_api_key_here") def test_init_from_yaml(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement import TuningModel mock_tuning = MagicMock(spec=TuningModel) @@ -559,7 +562,7 @@ def test_init_from_yaml(self): assert result is client def test_init_from_yaml_default_path(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement import TuningModel mock_tuning = MagicMock(spec=TuningModel) @@ -574,7 +577,7 @@ def test_init_from_yaml_default_path(self): @pytest.mark.asyncio async def test_register_functions_success(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL client = AgenticRL() client.tuning = MagicMock() @@ -602,7 +605,7 @@ async def test_register_functions_success(self): @pytest.mark.asyncio async def test_register_functions_failure(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement.common.errors import ( RegistrationError, ) @@ -619,7 +622,7 @@ async def test_register_functions_failure(self): @pytest.mark.asyncio async def test_upload_datasets_success(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL client = AgenticRL() client.tuning = MagicMock() @@ -633,7 +636,7 @@ async def test_upload_datasets_success(self): @pytest.mark.asyncio async def test_upload_datasets_failure(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement.common.errors import ( DatasetsError, ) @@ -656,7 +659,7 @@ async def test_upload_datasets_failure(self): class TestSubmitJob: def test_submit_job_builds_correct_request(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL with patch( "dashscope.finetune.agentic_rl.generate_random_id", @@ -691,7 +694,7 @@ def test_submit_job_builds_correct_request(self): ) def test_submit_job_duplicate_function_names(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement.common.errors import ( ValueErrorWithCode, ) @@ -722,12 +725,12 @@ def test_submit_job_duplicate_function_names(self): class TestClassMethods: def test_get_job(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL mock_result = MagicMock() mock_result.status_code = 200 with patch( - "dashscope.finetune.agentic_rl.FineTunes.get", + "dashscope.legacy_1x.finetune.agentic_rl.FineTunes.get", return_value=mock_result, ) as mock_get: result = AgenticRL.get(job_id="ft-job-123") @@ -735,12 +738,12 @@ def test_get_job(self): mock_get.assert_called_once() def test_cancel_job(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL mock_result = MagicMock() mock_result.status_code = 200 with patch( - "dashscope.finetune.agentic_rl.FineTunes.cancel", + "dashscope.legacy_1x.finetune.agentic_rl.FineTunes.cancel", return_value=mock_result, ) as mock_cancel: result = AgenticRL.cancel(job_id="ft-job-123") @@ -748,12 +751,12 @@ def test_cancel_job(self): mock_cancel.assert_called_once() def test_list_jobs(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL mock_result = MagicMock() mock_result.status_code = 200 with patch( - "dashscope.finetune.agentic_rl.FineTunes.list", + "dashscope.legacy_1x.finetune.agentic_rl.FineTunes.list", return_value=mock_result, ) as mock_list: result = AgenticRL.list(page_no=1, page_size=10) @@ -761,12 +764,12 @@ def test_list_jobs(self): mock_list.assert_called_once() def test_delete_job(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL mock_result = MagicMock() mock_result.status_code = 200 with patch( - "dashscope.finetune.agentic_rl.FineTunes.delete", + "dashscope.legacy_1x.finetune.agentic_rl.FineTunes.delete", return_value=mock_result, ) as mock_delete: result = AgenticRL.delete(job_id="ft-job-123") @@ -774,12 +777,12 @@ def test_delete_job(self): mock_delete.assert_called_once() def test_logs_job(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL mock_result = MagicMock() mock_result.status_code = 200 with patch( - "dashscope.finetune.agentic_rl.FineTunes.logs", + "dashscope.legacy_1x.finetune.agentic_rl.FineTunes.logs", return_value=mock_result, ) as mock_logs: result = AgenticRL.logs( @@ -807,7 +810,7 @@ async def test_run_full_workflow( hyper_parameters, resources, ): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL mock_finetune = MagicMock() mock_finetune.status_code = 200 @@ -845,7 +848,7 @@ async def test_run_full_workflow( @pytest.mark.asyncio async def test_run_register_failure_raises(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement.common.errors import ( RuntimeErrorWithCode, ) @@ -864,7 +867,7 @@ async def test_run_register_failure_raises(self): @pytest.mark.asyncio async def test_run_upload_failure_raises(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement.common.errors import ( RuntimeErrorWithCode, ) @@ -887,7 +890,7 @@ async def test_run_upload_failure_raises(self): @pytest.mark.asyncio async def test_run_submit_failure_raises(self): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement.common.errors import ( RuntimeErrorWithCode, ) @@ -913,7 +916,7 @@ async def test_run_submit_failure_raises(self): @pytest.mark.asyncio async def test_run_with_no_validation_datasets(self, training_datasets): - from dashscope.finetune.agentic_rl import AgenticRL + from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL mock_finetune = MagicMock() mock_finetune.status_code = 200 diff --git a/tests/unit/test_agentic_rl_utils.py b/tests/legacy_1x/unit/test_agentic_rl_utils.py similarity index 100% rename from tests/unit/test_agentic_rl_utils.py rename to tests/legacy_1x/unit/test_agentic_rl_utils.py diff --git a/tests/unit/test_agentstudio_base_url.py b/tests/legacy_1x/unit/test_agentstudio_base_url.py similarity index 100% rename from tests/unit/test_agentstudio_base_url.py rename to tests/legacy_1x/unit/test_agentstudio_base_url.py diff --git a/tests/unit/test_agentstudio_deployments.py b/tests/legacy_1x/unit/test_agentstudio_deployments.py similarity index 100% rename from tests/unit/test_agentstudio_deployments.py rename to tests/legacy_1x/unit/test_agentstudio_deployments.py diff --git a/tests/unit/test_agentstudio_files.py b/tests/legacy_1x/unit/test_agentstudio_files.py similarity index 100% rename from tests/unit/test_agentstudio_files.py rename to tests/legacy_1x/unit/test_agentstudio_files.py diff --git a/tests/unit/test_agentstudio_memory_stores.py b/tests/legacy_1x/unit/test_agentstudio_memory_stores.py similarity index 100% rename from tests/unit/test_agentstudio_memory_stores.py rename to tests/legacy_1x/unit/test_agentstudio_memory_stores.py diff --git a/tests/unit/test_agentstudio_pagination.py b/tests/legacy_1x/unit/test_agentstudio_pagination.py similarity index 100% rename from tests/unit/test_agentstudio_pagination.py rename to tests/legacy_1x/unit/test_agentstudio_pagination.py diff --git a/tests/unit/test_agentstudio_skills.py b/tests/legacy_1x/unit/test_agentstudio_skills.py similarity index 100% rename from tests/unit/test_agentstudio_skills.py rename to tests/legacy_1x/unit/test_agentstudio_skills.py diff --git a/tests/unit/test_aio_session.py b/tests/legacy_1x/unit/test_aio_session.py similarity index 98% rename from tests/unit/test_aio_session.py rename to tests/legacy_1x/unit/test_aio_session.py index b82061a9..e236fd90 100644 --- a/tests/unit/test_aio_session.py +++ b/tests/legacy_1x/unit/test_aio_session.py @@ -16,7 +16,7 @@ import aiohttp import pytest -from dashscope.api_entities import aio_session +from dashscope.legacy_1x.api_entities import aio_session class TestSSLContextCaching: diff --git a/tests/unit/test_aio_session_cleanup.py b/tests/legacy_1x/unit/test_aio_session_cleanup.py similarity index 95% rename from tests/unit/test_aio_session_cleanup.py rename to tests/legacy_1x/unit/test_aio_session_cleanup.py index 2e2c4c0d..3d95cf3d 100644 --- a/tests/unit/test_aio_session_cleanup.py +++ b/tests/legacy_1x/unit/test_aio_session_cleanup.py @@ -33,7 +33,9 @@ def test_single_thread(): warnings.filterwarnings("always", category=ResourceWarning) async def simulate_multimodal_conversation(): - from dashscope.api_entities.aio_session import get_shared_aio_session + from dashscope.legacy_1x.api_entities.aio_session import ( + get_shared_aio_session, + ) print("[Step 1] Creating shared aiohttp session...") session = await get_shared_aio_session() @@ -63,7 +65,9 @@ def run_async_in_thread(thread_id): """Run async code in a separate thread with its own event loop.""" async def create_session(): - from dashscope.api_entities.aio_session import get_shared_aio_session + from dashscope.legacy_1x.api_entities.aio_session import ( + get_shared_aio_session, + ) session = await get_shared_aio_session() print(f" Thread {thread_id}: Session {id(session)} created") @@ -146,7 +150,9 @@ async def test_concurrent_tasks_same_loop(): print("=" * 70) print() - from dashscope.api_entities.aio_session import get_shared_aio_session + from dashscope.legacy_1x.api_entities.aio_session import ( + get_shared_aio_session, + ) num_tasks = 10 print(f"Creating {num_tasks} concurrent tasks in same event loop...") diff --git a/tests/unit/test_application.py b/tests/legacy_1x/unit/test_application.py similarity index 98% rename from tests/unit/test_application.py rename to tests/legacy_1x/unit/test_application.py index 480c0712..a6fa53c3 100644 --- a/tests/unit/test_application.py +++ b/tests/legacy_1x/unit/test_application.py @@ -10,9 +10,9 @@ from typing import Dict from dashscope import Application -from dashscope.app.application_response import ApplicationResponse -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from dashscope.legacy_1x.app.application_response import ApplicationResponse +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestCompletion(MockServerBase): diff --git a/tests/unit/test_asr_phrases.py b/tests/legacy_1x/unit/test_asr_phrases.py similarity index 95% rename from tests/unit/test_asr_phrases.py rename to tests/legacy_1x/unit/test_asr_phrases.py index 942b55bb..768924a3 100644 --- a/tests/unit/test_asr_phrases.py +++ b/tests/legacy_1x/unit/test_asr_phrases.py @@ -10,9 +10,9 @@ import pytest import dashscope -from dashscope.audio.asr import AsrPhraseManager -from tests.unit.constants import TEST_JOB_ID -from tests.unit.mock_request_base import MockRequestBase +from dashscope.legacy_1x.audio.asr import AsrPhraseManager +from tests.legacy_1x.unit.constants import TEST_JOB_ID +from tests.legacy_1x.unit.mock_request_base import MockRequestBase logger = logging.getLogger("dashscope") logger.setLevel(logging.DEBUG) @@ -115,10 +115,15 @@ def complete_url(url: str) -> None: parsed = urlparse(url) base_url = "".join([parsed.scheme, "://", parsed.netloc]) dashscope.base_websocket_api_url = "/".join( - [base_url, "api-ws", dashscope.common.env.api_version, "inference"], + [ + base_url, + "api-ws", + dashscope.legacy_1x.common.env.api_version, + "inference", + ], ) dashscope.base_http_api_url = "/".join( - [base_url, "api", dashscope.common.env.api_version], + [base_url, "api", dashscope.legacy_1x.common.env.api_version], ) print("Set base_websocket_api_url: ", dashscope.base_websocket_api_url) print("Set base_http_api_url: ", dashscope.base_http_api_url) diff --git a/tests/unit/test_assistant_files.py b/tests/legacy_1x/unit/test_assistant_files.py similarity index 95% rename from tests/unit/test_assistant_files.py rename to tests/legacy_1x/unit/test_assistant_files.py index 1ea08119..84522e83 100644 --- a/tests/unit/test_assistant_files.py +++ b/tests/legacy_1x/unit/test_assistant_files.py @@ -4,9 +4,9 @@ import json import uuid -from dashscope.assistants.files import Files -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from dashscope.legacy_1x.assistants.files import Files +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestAssistantFiles(MockServerBase): diff --git a/tests/unit/test_assistants.py b/tests/legacy_1x/unit/test_assistants.py similarity index 98% rename from tests/unit/test_assistants.py rename to tests/legacy_1x/unit/test_assistants.py index 883d053d..3f117779 100644 --- a/tests/unit/test_assistants.py +++ b/tests/legacy_1x/unit/test_assistants.py @@ -5,8 +5,8 @@ import uuid from dashscope import Assistants # pylint: disable=no-name-in-module -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestAssistants(MockServerBase): diff --git a/tests/unit/test_async_custom_session.py b/tests/legacy_1x/unit/test_async_custom_session.py similarity index 82% rename from tests/unit/test_async_custom_session.py rename to tests/legacy_1x/unit/test_async_custom_session.py index 10d31051..4d8f1757 100644 --- a/tests/unit/test_async_custom_session.py +++ b/tests/legacy_1x/unit/test_async_custom_session.py @@ -2,16 +2,16 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """ -异步 HTTP 自定义 Session 功能单元测试 +Unit tests for async HTTP custom session support. -测试范围: -1. HttpRequest 接受自定义 aiohttp.ClientSession 参数 -2. 自定义 aio_session 的使用和资源管理 -3. 临时 aio_session 的创建和清理 -4. Session 优先级逻辑 -5. 不同场景下的异步 Session 行为 +Scope: +1. HttpRequest accepts a custom aiohttp.ClientSession parameter +2. Usage and resource management of a custom aio_session +3. Creation and cleanup of temporary aio_session instances +4. Session priority logic +5. Async session behavior across different scenarios -注意:所有测试都不依赖真实的 API Key +Note: none of the tests rely on a real API key. """ # pylint: disable=protected-access,unused-argument,unused-variable @@ -24,17 +24,17 @@ import certifi import pytest -from dashscope.api_entities.http_request import HttpRequest -from dashscope.api_entities.api_request_data import ApiRequestData -from dashscope.common.constants import ApiProtocol, HTTPMethod +from dashscope.legacy_1x.api_entities.http_request import HttpRequest +from dashscope.legacy_1x.api_entities.api_request_data import ApiRequestData +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod class TestAsyncSessionBasics: - """测试异步 Session 基本功能""" + """Basic tests for async sessions""" @pytest.mark.asyncio async def test_http_request_accepts_aio_session_parameter(self): - """测试 HttpRequest 接受 aio_session 参数""" + """Test that HttpRequest accepts the aio_session parameter""" connector = aiohttp.TCPConnector() custom_session = aiohttp.ClientSession(connector=connector) @@ -53,7 +53,7 @@ async def test_http_request_accepts_aio_session_parameter(self): @pytest.mark.asyncio async def test_http_request_without_aio_session_parameter(self): - """测试 HttpRequest 不传 aio_session 参数""" + """Test HttpRequest without the aio_session parameter""" http_request = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -64,8 +64,8 @@ async def test_http_request_without_aio_session_parameter(self): @pytest.mark.asyncio async def test_aio_session_parameter_is_optional(self): - """测试 aio_session 参数是可选的""" - # 不传 aio_session 参数应该正常工作 + """Test that the aio_session parameter is optional""" + # Works without the aio_session parameter http_request = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -78,12 +78,12 @@ async def test_aio_session_parameter_is_optional(self): class TestAsyncSessionUsage: - """测试异步 Session 的实际使用""" + """Tests for actual async session usage""" @pytest.mark.asyncio async def test_custom_aio_session_is_used_for_request(self): - """测试自定义 aio_session 被实际用于请求""" - # 创建 mock session + """Test that the custom aio_session is actually used for the request""" + # Create a mock session mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 @@ -97,7 +97,7 @@ async def mock_request(*_args, **_kwargs): mock_session.request = mock_request - # 创建 HttpRequest 并传入自定义 session + # Create an HttpRequest with the custom session http_request = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -106,7 +106,7 @@ async def mock_request(*_args, **_kwargs): session=mock_session, ) - # 添加请求数据 + # Add request data request_data = ApiRequestData( model="test-model", task_group="test", @@ -119,7 +119,7 @@ async def mock_request(*_args, **_kwargs): ) http_request.data = request_data - # 执行请求 + # Execute the request async def mock_handle_response(_response): yield mock_response @@ -130,15 +130,16 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 验证自定义 session 没有被关闭 + # Verify the custom session was not closed mock_session.close.assert_not_called() @pytest.mark.asyncio async def test_shared_aio_session_is_used_when_no_custom_session( self, ): - """测试没有自定义 aio_session 时使用共享 session(不被关闭)""" - # 创建 mock shared session + """Test that the shared session is used (and not closed) when no + custom aio_session is given""" + # Create a mock shared session mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 @@ -147,7 +148,7 @@ async def test_shared_aio_session_is_used_when_no_custom_session( mock_response.__aexit__ = AsyncMock(return_value=None) mock_session.request.return_value = mock_response - # 创建 HttpRequest 不传 aio_session + # Create an HttpRequest without aio_session http_request = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -155,7 +156,7 @@ async def test_shared_aio_session_is_used_when_no_custom_session( stream=False, ) - # 添加请求数据 + # Add request data request_data = ApiRequestData( model="test-model", task_group="test", @@ -168,12 +169,12 @@ async def test_shared_aio_session_is_used_when_no_custom_session( ) http_request.data = request_data - # 执行请求 + # Execute the request async def mock_handle_response(_response): yield mock_response with patch( - "dashscope.api_entities.http_request.get_shared_aio_session", + "dashscope.legacy_1x.api_entities.http_request.get_shared_aio_session", return_value=mock_session, ): with patch.object( @@ -183,16 +184,17 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 共享 session 不应被关闭(由 aio_session 模块管理生命周期) + # The shared session must not be closed (lifecycle is managed + # by the aio_session module) mock_session.close.assert_not_called() class TestAsyncSessionResourceManagement: - """测试异步 Session 资源管理""" + """Tests for async session resource management""" @pytest.mark.asyncio async def test_custom_aio_session_not_closed_by_http_request(self): - """测试自定义 aio_session 不会被 HttpRequest 关闭""" + """Test that HttpRequest does not close the custom aio_session""" custom_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 @@ -236,12 +238,13 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 验证自定义 aio_session 没有被关闭 + # Verify the custom aio_session was not closed custom_session.close.assert_not_called() @pytest.mark.asyncio async def test_shared_aio_session_not_closed_on_success(self): - """测试共享 aio_session 在成功后不被关闭(由模块管理生命周期)""" + """Test that the shared aio_session is not closed on success + (lifecycle managed by the module)""" mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 @@ -273,7 +276,7 @@ async def mock_handle_response(_response): yield mock_response with patch( - "dashscope.api_entities.http_request.get_shared_aio_session", + "dashscope.legacy_1x.api_entities.http_request.get_shared_aio_session", return_value=mock_session, ): with patch.object( @@ -283,12 +286,12 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 共享 session 不应被关闭 + # The shared session must not be closed mock_session.close.assert_not_called() @pytest.mark.asyncio async def test_shared_aio_session_not_closed_on_exception(self): - """测试共享 aio_session 在异常时也不被关闭""" + """Test that the shared aio_session is not closed on exceptions either""" mock_session = AsyncMock() # Make request() raise an exception @@ -316,24 +319,24 @@ async def mock_request(*_args, **_kwargs): ) http_request.data = request_data - # 执行请求应该抛出异常 + # Executing the request should raise with patch( - "dashscope.api_entities.http_request.get_shared_aio_session", + "dashscope.legacy_1x.api_entities.http_request.get_shared_aio_session", return_value=mock_session, ): with pytest.raises(Exception, match="Network error"): _ = await http_request.aio_call() - # 共享 session 仍然不应被关闭(由模块管理生命周期) + # The shared session still must not be closed (lifecycle managed by the module) mock_session.close.assert_not_called() class TestAsyncSessionWithCustomConfiguration: - """测试自定义配置的异步 Session""" + """Tests for async sessions with custom configuration""" @pytest.mark.asyncio async def test_custom_aio_session_with_connector(self): - """测试带自定义 connector 的 aio_session""" + """Test an aio_session with a custom connector""" connector = aiohttp.TCPConnector( limit=100, limit_per_host=30, @@ -350,14 +353,14 @@ async def test_custom_aio_session_with_connector(self): ) assert http_request._external_aio_session is custom_session - # 验证 connector 已配置 + # Verify the connector is configured assert custom_session.connector is not None finally: await custom_session.close() @pytest.mark.asyncio async def test_custom_aio_session_with_headers(self): - """测试带自定义 headers 的 aio_session""" + """Test an aio_session with custom headers""" custom_headers = { "User-Agent": "Custom-Agent/1.0", "X-Custom-Header": "custom-value", @@ -373,14 +376,14 @@ async def test_custom_aio_session_with_headers(self): ) assert http_request._external_aio_session is custom_session - # 验证 headers 已配置 + # Verify the headers are configured assert "User-Agent" in custom_session.headers finally: await custom_session.close() @pytest.mark.asyncio async def test_custom_aio_session_with_timeout(self): - """测试带自定义 timeout 的 aio_session""" + """Test an aio_session with a custom timeout""" timeout = aiohttp.ClientTimeout(total=60) custom_session = aiohttp.ClientSession(timeout=timeout) @@ -393,18 +396,18 @@ async def test_custom_aio_session_with_timeout(self): ) assert http_request._external_aio_session is custom_session - # 验证 timeout 已配置 + # Verify the timeout is configured assert custom_session.timeout is not None finally: await custom_session.close() class TestAsyncSessionPriority: - """测试异步 Session 优先级""" + """Tests for async session priority""" @pytest.mark.asyncio async def test_custom_aio_session_has_priority(self): - """测试自定义 aio_session 优先于临时 aio_session""" + """Test that a custom aio_session takes priority over a temporary one""" connector = aiohttp.TCPConnector() custom_session = aiohttp.ClientSession(connector=connector) @@ -416,7 +419,7 @@ async def test_custom_aio_session_has_priority(self): session=custom_session, ) - # 验证存储了自定义 aio_session + # Verify the custom aio_session is stored assert http_request._external_aio_session is custom_session assert http_request._external_aio_session is not None finally: @@ -424,11 +427,11 @@ async def test_custom_aio_session_has_priority(self): class TestAsyncSessionWithDifferentMethods: - """测试不同 HTTP 方法的异步 Session 使用""" + """Tests for async session usage across HTTP methods""" @pytest.mark.asyncio async def test_custom_aio_session_with_post_request(self): - """测试 POST 请求使用自定义 session""" + """Test that POST requests use the custom session""" mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 @@ -475,7 +478,7 @@ async def mock_handle_response(_response): @pytest.mark.asyncio async def test_custom_aio_session_with_get_request(self): - """测试 GET 请求使用自定义 session""" + """Test that GET requests use the custom session""" mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 @@ -522,11 +525,11 @@ async def mock_handle_response(_response): class TestAsyncBackwardCompatibility: - """测试异步向后兼容性""" + """Tests for async backward compatibility""" @pytest.mark.asyncio async def test_works_without_aio_session_parameter(self): - """测试不传 aio_session 参数时保持原有行为""" + """Test that omitting aio_session keeps the original behavior""" http_request = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -534,16 +537,16 @@ async def test_works_without_aio_session_parameter(self): stream=False, ) - # 验证不传 aio_session 时,_external_aio_session 为 None + # Verify _external_aio_session is None when aio_session is not passed assert http_request._external_aio_session is None - # 验证其他参数正常 + # Verify the other parameters are unaffected assert http_request.url == "http://example.com/api" assert http_request.method == HTTPMethod.POST @pytest.mark.asyncio async def test_default_behavior_uses_shared_session(self): - """测试默认行为使用共享 session(不被关闭)""" + """Test that the default behavior uses the shared session (not closed)""" mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 @@ -552,7 +555,7 @@ async def test_default_behavior_uses_shared_session(self): mock_response.__aexit__ = AsyncMock(return_value=None) mock_session.request.return_value = mock_response - # 不传 aio_session 参数 + # Do not pass the aio_session parameter http_request = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -576,7 +579,7 @@ async def mock_handle_response(_response): yield mock_response with patch( - "dashscope.api_entities.http_request.get_shared_aio_session", + "dashscope.legacy_1x.api_entities.http_request.get_shared_aio_session", return_value=mock_session, ): with patch.object( @@ -586,16 +589,17 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 共享 session 不应被关闭(生命周期由 aio_session 模块管理) + # The shared session must not be closed (lifecycle managed + # by the aio_session module) mock_session.close.assert_not_called() class TestAsyncSessionLifecycle: - """测试异步 Session 生命周期""" + """Tests for the async session lifecycle""" @pytest.mark.asyncio async def test_multiple_requests_with_same_custom_session(self): - """测试使用同一个自定义 session 进行多次请求""" + """Test multiple requests with the same custom session""" mock_session = AsyncMock(spec=aiohttp.ClientSession) mock_response = AsyncMock() mock_response.status = 200 @@ -608,7 +612,7 @@ async def mock_request(*_args, **_kwargs): mock_session.request = mock_request - # 第一次请求 + # First request http_request1 = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -639,7 +643,7 @@ async def mock_handle_response(_response): ): _ = await http_request1.aio_call() - # 第二次请求 + # Second request http_request2 = HttpRequest( url="http://example.com/api", api_key="fake-api-key", @@ -667,12 +671,13 @@ async def mock_handle_response(_response): ): _ = await http_request2.aio_call() - # 验证 session 没有被关闭(因为是外部 session) + # Verify the session was not closed (it is an external session) mock_session.close.assert_not_called() class TestAsyncConnectionRetry: - """测试异步池化连接被远端关闭后的重试行为""" + """Tests for retry behavior after a pooled async connection is closed + by the remote peer""" def _build_post_request(self): http_request = HttpRequest( @@ -704,7 +709,7 @@ def _ok_response(): @pytest.mark.asyncio async def test_retry_once_on_connection_error(self): - """连接被远端关闭时重试一次并成功""" + """Retries once and succeeds when the connection is closed by the remote peer""" mock_session = AsyncMock() mock_response = self._ok_response() mock_session.request.side_effect = [ @@ -718,7 +723,7 @@ async def mock_handle_response(_response): yield mock_response with patch( - "dashscope.api_entities.http_request.get_shared_aio_session", + "dashscope.legacy_1x.api_entities.http_request.get_shared_aio_session", return_value=mock_session, ): with patch.object( @@ -732,7 +737,7 @@ async def mock_handle_response(_response): @pytest.mark.asyncio async def test_retry_exhausted_raises(self): - """连续两次 ClientConnectionError 后向上抛出""" + """Raises after two consecutive ClientConnectionErrors""" mock_session = AsyncMock() mock_session.request.side_effect = aiohttp.ClientConnectionError( "Server disconnected", @@ -741,7 +746,7 @@ async def test_retry_exhausted_raises(self): http_request = self._build_post_request() with patch( - "dashscope.api_entities.http_request.get_shared_aio_session", + "dashscope.legacy_1x.api_entities.http_request.get_shared_aio_session", return_value=mock_session, ): with pytest.raises(aiohttp.ClientConnectionError): @@ -751,14 +756,14 @@ async def test_retry_exhausted_raises(self): @pytest.mark.asyncio async def test_no_retry_on_other_exceptions(self): - """非连接类异常不重试""" + """Does not retry non-connection errors""" mock_session = AsyncMock() mock_session.request.side_effect = ValueError("bad request") http_request = self._build_post_request() with patch( - "dashscope.api_entities.http_request.get_shared_aio_session", + "dashscope.legacy_1x.api_entities.http_request.get_shared_aio_session", return_value=mock_session, ): with pytest.raises(ValueError, match="bad request"): diff --git a/tests/unit/test_banner_guide.py b/tests/legacy_1x/unit/test_banner_guide.py similarity index 100% rename from tests/unit/test_banner_guide.py rename to tests/legacy_1x/unit/test_banner_guide.py diff --git a/tests/unit/test_cli_agent_route.py b/tests/legacy_1x/unit/test_cli_agent_route.py similarity index 99% rename from tests/unit/test_cli_agent_route.py rename to tests/legacy_1x/unit/test_cli_agent_route.py index 46375f57..e5200335 100644 --- a/tests/unit/test_cli_agent_route.py +++ b/tests/legacy_1x/unit/test_cli_agent_route.py @@ -23,7 +23,7 @@ import pytest import dashscope -from dashscope import cli +from dashscope.legacy_1x import cli @pytest.fixture() diff --git a/tests/unit/test_cli_application.py b/tests/legacy_1x/unit/test_cli_application.py similarity index 98% rename from tests/unit/test_cli_application.py rename to tests/legacy_1x/unit/test_cli_application.py index 957f8300..71458a55 100644 --- a/tests/unit/test_cli_application.py +++ b/tests/legacy_1x/unit/test_cli_application.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import application +from dashscope.legacy_1x.cli import application class TestCliApplication: diff --git a/tests/unit/test_cli_auth.py b/tests/legacy_1x/unit/test_cli_auth.py similarity index 96% rename from tests/unit/test_cli_auth.py rename to tests/legacy_1x/unit/test_cli_auth.py index f3997b45..84e95725 100644 --- a/tests/unit/test_cli_auth.py +++ b/tests/legacy_1x/unit/test_cli_auth.py @@ -17,9 +17,9 @@ from typer.testing import CliRunner import dashscope -from dashscope.cli import auth as auth_cli -from dashscope.common import api_key as api_key_mod -from dashscope.common.constants import ( +from dashscope.legacy_1x.cli import auth as auth_cli +from dashscope.legacy_1x.common import api_key as api_key_mod +from dashscope.legacy_1x.common.constants import ( DASHSCOPE_API_KEY_ENV, DASHSCOPE_API_KEY_FILE_PATH_ENV, ) @@ -67,7 +67,7 @@ def key_store(monkeypatch, tmp_path): key_file = cache_dir / "api_key" cache_dir.mkdir(parents=True, exist_ok=True) # ``save_api_key``/``get_default_api_key`` read the constant from - # dashscope.common.api_key, while the printed path and the source check in + # dashscope.legacy_1x.common.api_key, while the printed path and the source check in # auth read its own imported copy. Both bindings have to move, or login and # whoami silently disagree about where the key lives. monkeypatch.setattr( diff --git a/tests/unit/test_cli_code_generation.py b/tests/legacy_1x/unit/test_cli_code_generation.py similarity index 97% rename from tests/unit/test_cli_code_generation.py rename to tests/legacy_1x/unit/test_cli_code_generation.py index c421e5ee..8d2435e1 100644 --- a/tests/unit/test_cli_code_generation.py +++ b/tests/legacy_1x/unit/test_cli_code_generation.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import code_generation +from dashscope.legacy_1x.cli import code_generation class TestCliCodeGeneration: diff --git a/tests/unit/test_cli_common.py b/tests/legacy_1x/unit/test_cli_common.py similarity index 96% rename from tests/unit/test_cli_common.py rename to tests/legacy_1x/unit/test_cli_common.py index f28f7375..ca149057 100644 --- a/tests/unit/test_cli_common.py +++ b/tests/legacy_1x/unit/test_cli_common.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -"""Unit tests for CLI exit-code classification in dashscope.cli.common.""" +"""Unit tests for CLI exit-code classification in dashscope.legacy_1x.cli.common.""" from types import SimpleNamespace from typer.testing import CliRunner -from dashscope.cli.common import _exit_code_for -from dashscope.cli import embeddings +from dashscope.legacy_1x.cli.common import _exit_code_for +from dashscope.legacy_1x.cli import embeddings # --------------------------------------------------------------------------- diff --git a/tests/unit/test_cli_embeddings.py b/tests/legacy_1x/unit/test_cli_embeddings.py similarity index 97% rename from tests/unit/test_cli_embeddings.py rename to tests/legacy_1x/unit/test_cli_embeddings.py index 9fd69d39..88bbf085 100644 --- a/tests/unit/test_cli_embeddings.py +++ b/tests/legacy_1x/unit/test_cli_embeddings.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import embeddings +from dashscope.legacy_1x.cli import embeddings class TestCliEmbeddings: diff --git a/tests/unit/test_cli_image_generation.py b/tests/legacy_1x/unit/test_cli_image_generation.py similarity index 99% rename from tests/unit/test_cli_image_generation.py rename to tests/legacy_1x/unit/test_cli_image_generation.py index 6a19f296..66788fc4 100644 --- a/tests/unit/test_cli_image_generation.py +++ b/tests/legacy_1x/unit/test_cli_image_generation.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import image_generation +from dashscope.legacy_1x.cli import image_generation class TestCliImageGeneration: diff --git a/tests/unit/test_cli_image_synthesis.py b/tests/legacy_1x/unit/test_cli_image_synthesis.py similarity index 99% rename from tests/unit/test_cli_image_synthesis.py rename to tests/legacy_1x/unit/test_cli_image_synthesis.py index 142d6cac..b03835f4 100644 --- a/tests/unit/test_cli_image_synthesis.py +++ b/tests/legacy_1x/unit/test_cli_image_synthesis.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import image_synthesis +from dashscope.legacy_1x.cli import image_synthesis class TestCliImageSynthesis: diff --git a/tests/unit/test_cli_main.py b/tests/legacy_1x/unit/test_cli_main.py similarity index 91% rename from tests/unit/test_cli_main.py rename to tests/legacy_1x/unit/test_cli_main.py index aac880ae..203067df 100644 --- a/tests/unit/test_cli_main.py +++ b/tests/legacy_1x/unit/test_cli_main.py @@ -13,12 +13,12 @@ from typer.testing import CliRunner import dashscope -from dashscope.cli import agentic_rl -from dashscope.cli import ( +from dashscope.legacy_1x.cli import agentic_rl +from dashscope.legacy_1x.cli import ( app as cli_app, main as cli_main, ) -from dashscope.common.error import AuthenticationError +from dashscope.legacy_1x.common.error import AuthenticationError def strip_ansi_codes(text): @@ -53,7 +53,7 @@ def mock_list(**kwargs): raise AuthenticationError("No api key provided.") monkeypatch.setattr( - "dashscope.cli.models.dashscope.Models.list", + "dashscope.legacy_1x.cli.models.dashscope.Models.list", mock_list, ) monkeypatch.setattr(sys, "argv", ["dashscope", "models", "list"]) @@ -77,7 +77,7 @@ def test_top_level_help_shows_global_api_key(self): def test_python_module_help_suppresses_urllib3_warning(self): result = subprocess.run( - [sys.executable, "-m", "dashscope.cli", "--help"], + [sys.executable, "-m", "dashscope.legacy_1x.cli", "--help"], check=False, capture_output=True, text=True, @@ -204,7 +204,7 @@ def mock_list(**kwargs): return SimpleNamespace(status_code=200, output={"models": []}) monkeypatch.setattr( - "dashscope.cli.models.dashscope.Models.list", + "dashscope.legacy_1x.cli.models.dashscope.Models.list", mock_list, ) monkeypatch.setattr(dashscope, "api_key", None) @@ -236,7 +236,7 @@ def mock_list(**kwargs): return SimpleNamespace(status_code=200, output={"models": []}) monkeypatch.setattr( - "dashscope.cli.models.dashscope.Models.list", + "dashscope.legacy_1x.cli.models.dashscope.Models.list", mock_list, ) monkeypatch.setattr(dashscope, "api_key", None) @@ -267,22 +267,25 @@ def test_rl_import_failure_is_silent_until_the_command_is_used( monkeypatch, capsys, ): - # The rl import is deferred, so importing dashscope.cli must not tell + # The rl import is deferred, so importing dashscope.legacy_1x.cli must not tell # the user about a dependency they may never ask for. - monkeypatch.setattr(dashscope.cli, "_RL_IMPORT_ERROR", None) + monkeypatch.setattr(dashscope.legacy_1x.cli, "_RL_IMPORT_ERROR", None) monkeypatch.setitem( sys.modules, - "dashscope.cli.agentic_rl", + "dashscope.legacy_1x.cli.agentic_rl", _ImportErrorOnApp( "Agentic RL fine-tuning dependencies are missing from this " "install. Reinstall with: pip install -U dashscope", ), ) - dashscope.cli._register_rl_app() + dashscope.legacy_1x.cli._register_rl_app() assert capsys.readouterr().err == "" - assert isinstance(dashscope.cli._RL_IMPORT_ERROR, ImportError) + assert isinstance( + dashscope.legacy_1x.cli._RL_IMPORT_ERROR, + ImportError, + ) def test_rl_command_prints_the_install_command_verbatim( self, @@ -292,7 +295,7 @@ def test_rl_command_prints_the_install_command_verbatim( # The error already carries its own remedy, so the fallback hint in # _rl_unavailable_detail must not be appended a second time. monkeypatch.setattr( - dashscope.cli, + dashscope.legacy_1x.cli, "_RL_IMPORT_ERROR", ImportError( "Agentic RL fine-tuning dependencies are missing from this " @@ -315,7 +318,7 @@ def test_rl_command_adds_a_hint_when_the_error_has_none( capsys, ): monkeypatch.setattr( - dashscope.cli, + dashscope.legacy_1x.cli, "_RL_IMPORT_ERROR", ImportError("No module named 'typer_x'"), ) @@ -334,13 +337,13 @@ def test_unrelated_command_is_not_told_about_missing_rl_dependencies( capsys, ): monkeypatch.setattr( - dashscope.cli, + dashscope.legacy_1x.cli, "_RL_IMPORT_ERROR", ImportError("Reinstall with: pip install -U dashscope"), ) monkeypatch.setattr(sys, "argv", ["dashscope"]) monkeypatch.setattr( - dashscope.cli, + dashscope.legacy_1x.cli, "_route_to_expert", lambda *args, **kwargs: None, ) @@ -363,10 +366,13 @@ def mock_upload(model, file_path, api_key, base_address=None): return "oss://uploaded", None monkeypatch.setattr( - "dashscope.cli.oss.os.path.exists", + "dashscope.legacy_1x.cli.oss.os.path.exists", lambda path: True, ) - monkeypatch.setattr("dashscope.cli.oss.OssUtils.upload", mock_upload) + monkeypatch.setattr( + "dashscope.legacy_1x.cli.oss.OssUtils.upload", + mock_upload, + ) monkeypatch.setattr(dashscope, "api_key", "global-key") monkeypatch.setattr( sys, @@ -471,7 +477,7 @@ def mock_call(model, prompt, stream=False): ) monkeypatch.setattr( - "dashscope.cli.generation.Generation.call", + "dashscope.legacy_1x.cli.generation.Generation.call", mock_call, ) monkeypatch.setattr( @@ -509,7 +515,7 @@ def mock_files_list(**kwargs): def mock_fine_tunes_list(**kwargs): captured_requests["fine_tunes"] = kwargs - from dashscope.finetune.customize_types import ( + from dashscope.legacy_1x.finetune.customize_types import ( FineTuneList, FineTuneListOutput, ) @@ -520,7 +526,7 @@ def mock_fine_tunes_list(**kwargs): def mock_deployments_list(**kwargs): captured_requests["deployments"] = kwargs - from dashscope.finetune.customize_types import ( + from dashscope.legacy_1x.finetune.customize_types import ( DeploymentList, DeploymentListOutput, ) @@ -530,15 +536,15 @@ def mock_deployments_list(**kwargs): return result monkeypatch.setattr( - "dashscope.cli.files.dashscope.Files.list", + "dashscope.legacy_1x.cli.files.dashscope.Files.list", mock_files_list, ) monkeypatch.setattr( - "dashscope.cli.fine_tunes.dashscope.FineTunes.list", + "dashscope.legacy_1x.cli.fine_tunes.dashscope.FineTunes.list", mock_fine_tunes_list, ) monkeypatch.setattr( - "dashscope.cli.deployments.dashscope.Deployments.list", + "dashscope.legacy_1x.cli.deployments.dashscope.Deployments.list", mock_deployments_list, ) @@ -571,7 +577,7 @@ def mock_files_list(**kwargs): return SimpleNamespace(status_code=200, output={}) monkeypatch.setattr( - "dashscope.cli.files.dashscope.Files.list", + "dashscope.legacy_1x.cli.files.dashscope.Files.list", mock_files_list, ) monkeypatch.setattr( @@ -592,7 +598,7 @@ def test_files_upload_missing_file_exits_without_traceback( capsys, ): monkeypatch.setattr( - "dashscope.cli.files.os.path.exists", + "dashscope.legacy_1x.cli.files.os.path.exists", lambda path: False, ) monkeypatch.setattr( @@ -620,11 +626,11 @@ def mock_upload(**kwargs): raise RuntimeError("upload failed") monkeypatch.setattr( - "dashscope.cli.files.os.path.exists", + "dashscope.legacy_1x.cli.files.os.path.exists", lambda path: True, ) monkeypatch.setattr( - "dashscope.cli.files.dashscope.Files.upload", + "dashscope.legacy_1x.cli.files.dashscope.Files.upload", mock_upload, ) monkeypatch.setattr( @@ -699,7 +705,7 @@ def mock_call(*_args, **_kwargs): return FailingStream() monkeypatch.setattr( - "dashscope.cli.generation.Generation.call", + "dashscope.legacy_1x.cli.generation.Generation.call", mock_call, ) @@ -728,7 +734,7 @@ def mock_list(**kwargs): raise RuntimeError("models failed") monkeypatch.setattr( - "dashscope.cli.models.dashscope.Models.list", + "dashscope.legacy_1x.cli.models.dashscope.Models.list", mock_list, ) @@ -746,7 +752,7 @@ def mock_call(**kwargs): raise RuntimeError("deployment failed") monkeypatch.setattr( - "dashscope.cli.deployments.dashscope.Deployments.call", + "dashscope.legacy_1x.cli.deployments.dashscope.Deployments.call", mock_call, ) @@ -773,11 +779,11 @@ def mock_get(*_args, **_kwargs): raise RuntimeError("network down") monkeypatch.setattr( - "dashscope.cli.deployments.dashscope.Deployments.call", + "dashscope.legacy_1x.cli.deployments.dashscope.Deployments.call", mock_call, ) monkeypatch.setattr( - "dashscope.cli.deployments.dashscope.Deployments.get", + "dashscope.legacy_1x.cli.deployments.dashscope.Deployments.get", mock_get, ) @@ -799,7 +805,7 @@ def mock_call(**_kwargs): return SimpleNamespace(status_code=200, output={"text": "ok"}) monkeypatch.setattr( - "dashscope.cli.application.dashscope.Application.call", + "dashscope.legacy_1x.cli.application.dashscope.Application.call", mock_call, ) @@ -817,7 +823,7 @@ def mock_call(*_args, **_kwargs): return SimpleNamespace(status_code=200, output={"text": "ok"}) monkeypatch.setattr( - "dashscope.cli.generation.Generation.call", + "dashscope.legacy_1x.cli.generation.Generation.call", mock_call, ) @@ -840,7 +846,7 @@ def mock_call(*_args, **_kwargs): ) monkeypatch.setattr( - "dashscope.cli.generation.Generation.call", + "dashscope.legacy_1x.cli.generation.Generation.call", mock_call, ) @@ -869,7 +875,7 @@ def mock_call(**kwargs): raise RuntimeError("image failed") monkeypatch.setattr( - "dashscope.cli.image_generation.ImageGeneration.call", + "dashscope.legacy_1x.cli.image_generation.ImageGeneration.call", mock_call, ) @@ -897,7 +903,7 @@ def mock_call(**_kwargs): return SimpleNamespace(status_code=200, output={"text": "ok"}) monkeypatch.setattr( - "dashscope.cli.multimodal_conversation.dashscope." + "dashscope.legacy_1x.cli.multimodal_conversation.dashscope." "MultiModalConversation.call", mock_call, ) @@ -926,7 +932,7 @@ def mock_call(**_kwargs): return SimpleNamespace(status_code=200, output={"embeddings": []}) monkeypatch.setattr( - "dashscope.cli.multimodal_embedding.dashscope." + "dashscope.legacy_1x.cli.multimodal_embedding.dashscope." "MultiModalEmbedding.call", mock_call, ) @@ -955,7 +961,7 @@ def mock_call(**_kwargs): raise RuntimeError("embedding failed") monkeypatch.setattr( - "dashscope.cli.multimodal_embedding.dashscope." + "dashscope.legacy_1x.cli.multimodal_embedding.dashscope." "MultiModalEmbedding.call", mock_call, ) diff --git a/tests/unit/test_cli_models.py b/tests/legacy_1x/unit/test_cli_models.py similarity index 97% rename from tests/unit/test_cli_models.py rename to tests/legacy_1x/unit/test_cli_models.py index 60917fc0..7f6b0b37 100644 --- a/tests/unit/test_cli_models.py +++ b/tests/legacy_1x/unit/test_cli_models.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import models +from dashscope.legacy_1x.cli import models class TestCliModels: diff --git a/tests/unit/test_cli_multimodal_conversation.py b/tests/legacy_1x/unit/test_cli_multimodal_conversation.py similarity index 97% rename from tests/unit/test_cli_multimodal_conversation.py rename to tests/legacy_1x/unit/test_cli_multimodal_conversation.py index 245efbc6..ebbf7b64 100644 --- a/tests/unit/test_cli_multimodal_conversation.py +++ b/tests/legacy_1x/unit/test_cli_multimodal_conversation.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import multimodal_conversation +from dashscope.legacy_1x.cli import multimodal_conversation class TestCliMultiModalConversation: diff --git a/tests/unit/test_cli_multimodal_embedding.py b/tests/legacy_1x/unit/test_cli_multimodal_embedding.py similarity index 97% rename from tests/unit/test_cli_multimodal_embedding.py rename to tests/legacy_1x/unit/test_cli_multimodal_embedding.py index 0058b0e7..d52621ed 100644 --- a/tests/unit/test_cli_multimodal_embedding.py +++ b/tests/legacy_1x/unit/test_cli_multimodal_embedding.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import multimodal_embedding +from dashscope.legacy_1x.cli import multimodal_embedding class TestCliMultiModalEmbedding: diff --git a/tests/unit/test_cli_rerank.py b/tests/legacy_1x/unit/test_cli_rerank.py similarity index 97% rename from tests/unit/test_cli_rerank.py rename to tests/legacy_1x/unit/test_cli_rerank.py index 6380b39f..ff09ac6a 100644 --- a/tests/unit/test_cli_rerank.py +++ b/tests/legacy_1x/unit/test_cli_rerank.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import rerank +from dashscope.legacy_1x.cli import rerank class TestCliRerank: diff --git a/tests/unit/test_cli_speech_synthesis.py b/tests/legacy_1x/unit/test_cli_speech_synthesis.py similarity index 97% rename from tests/unit/test_cli_speech_synthesis.py rename to tests/legacy_1x/unit/test_cli_speech_synthesis.py index efb7fd1e..5a561706 100644 --- a/tests/unit/test_cli_speech_synthesis.py +++ b/tests/legacy_1x/unit/test_cli_speech_synthesis.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import speech_synthesis +from dashscope.legacy_1x.cli import speech_synthesis class TestCliSpeechSynthesis: diff --git a/tests/unit/test_cli_tokenization.py b/tests/legacy_1x/unit/test_cli_tokenization.py similarity index 96% rename from tests/unit/test_cli_tokenization.py rename to tests/legacy_1x/unit/test_cli_tokenization.py index 9da078b2..69b6ec4b 100644 --- a/tests/unit/test_cli_tokenization.py +++ b/tests/legacy_1x/unit/test_cli_tokenization.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import tokenization +from dashscope.legacy_1x.cli import tokenization class TestCliTokenization: diff --git a/tests/unit/test_cli_transcription.py b/tests/legacy_1x/unit/test_cli_transcription.py similarity index 99% rename from tests/unit/test_cli_transcription.py rename to tests/legacy_1x/unit/test_cli_transcription.py index 20cfc425..28ce93fa 100644 --- a/tests/unit/test_cli_transcription.py +++ b/tests/legacy_1x/unit/test_cli_transcription.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import transcription +from dashscope.legacy_1x.cli import transcription class TestCliTranscription: diff --git a/tests/unit/test_cli_understanding.py b/tests/legacy_1x/unit/test_cli_understanding.py similarity index 96% rename from tests/unit/test_cli_understanding.py rename to tests/legacy_1x/unit/test_cli_understanding.py index c15ea145..b0ca73d8 100644 --- a/tests/unit/test_cli_understanding.py +++ b/tests/legacy_1x/unit/test_cli_understanding.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import understanding +from dashscope.legacy_1x.cli import understanding class TestCliUnderstanding: diff --git a/tests/unit/test_cli_video_synthesis.py b/tests/legacy_1x/unit/test_cli_video_synthesis.py similarity index 99% rename from tests/unit/test_cli_video_synthesis.py rename to tests/legacy_1x/unit/test_cli_video_synthesis.py index c1af60ce..16930ec2 100644 --- a/tests/unit/test_cli_video_synthesis.py +++ b/tests/legacy_1x/unit/test_cli_video_synthesis.py @@ -5,7 +5,7 @@ from typer.testing import CliRunner -from dashscope.cli import video_synthesis +from dashscope.legacy_1x.cli import video_synthesis class TestCliVideoSynthesis: diff --git a/tests/unit/test_code_generation.py b/tests/legacy_1x/unit/test_code_generation.py similarity index 99% rename from tests/unit/test_code_generation.py rename to tests/legacy_1x/unit/test_code_generation.py index 831ce9c4..74e2e764 100644 --- a/tests/unit/test_code_generation.py +++ b/tests/legacy_1x/unit/test_code_generation.py @@ -5,12 +5,12 @@ from http import HTTPStatus from dashscope import CodeGeneration -from dashscope.aigc.code_generation import ( +from dashscope.legacy_1x.aigc.code_generation import ( AttachmentRoleMessageParam, UserRoleMessageParam, ) -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer model = CodeGeneration.Models.tongyi_lingma_v1 diff --git a/tests/unit/test_conversation.py b/tests/legacy_1x/unit/test_conversation.py similarity index 95% rename from tests/unit/test_conversation.py rename to tests/legacy_1x/unit/test_conversation.py index 8dedcc04..e40f60c6 100644 --- a/tests/unit/test_conversation.py +++ b/tests/legacy_1x/unit/test_conversation.py @@ -5,11 +5,19 @@ from http import HTTPStatus from dashscope import Generation -from dashscope.aigc.conversation import Conversation, History, HistoryItem -from dashscope.api_entities.dashscope_response import Choice, Message, Role -from dashscope.common.message_manager import MessageManager -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from dashscope.legacy_1x.aigc.conversation import ( + Conversation, + History, + HistoryItem, +) +from dashscope.legacy_1x.api_entities.dashscope_response import ( + Choice, + Message, + Role, +) +from dashscope.legacy_1x.common.message_manager import MessageManager +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer def request_generator(): diff --git a/tests/unit/test_dashscope_response.py b/tests/legacy_1x/unit/test_dashscope_response.py similarity index 87% rename from tests/unit/test_dashscope_response.py rename to tests/legacy_1x/unit/test_dashscope_response.py index ed7a135c..599d6c5e 100644 --- a/tests/unit/test_dashscope_response.py +++ b/tests/legacy_1x/unit/test_dashscope_response.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.api_entities.dashscope_response import DictMixin +from dashscope.legacy_1x.api_entities.dashscope_response import DictMixin class TestDictMixin: diff --git a/tests/unit/test_handlers_key_docs.py b/tests/legacy_1x/unit/test_handlers_key_docs.py similarity index 100% rename from tests/unit/test_handlers_key_docs.py rename to tests/legacy_1x/unit/test_handlers_key_docs.py diff --git a/tests/unit/test_http_api.py b/tests/legacy_1x/unit/test_http_api.py similarity index 95% rename from tests/unit/test_http_api.py rename to tests/legacy_1x/unit/test_http_api.py index 15ada150..57f2b45c 100644 --- a/tests/unit/test_http_api.py +++ b/tests/legacy_1x/unit/test_http_api.py @@ -4,12 +4,12 @@ from http import HTTPStatus from dashscope import Generation -from tests.unit.constants import ( +from tests.legacy_1x.unit.constants import ( TEST_DISABLE_DATA_INSPECTION_REQUEST_ID, TEST_ENABLE_DATA_INSPECTION_REQUEST_ID, ) -from tests.unit.http_task_request import HttpRequest -from tests.unit.mock_request_base import MockRequestBase +from tests.legacy_1x.unit.http_task_request import HttpRequest +from tests.legacy_1x.unit.mock_request_base import MockRequestBase def request_generator(): diff --git a/tests/unit/test_http_deployments_api.py b/tests/legacy_1x/unit/test_http_deployments_api.py similarity index 93% rename from tests/unit/test_http_deployments_api.py rename to tests/legacy_1x/unit/test_http_deployments_api.py index 3119344e..e22bcb7c 100644 --- a/tests/unit/test_http_deployments_api.py +++ b/tests/legacy_1x/unit/test_http_deployments_api.py @@ -4,8 +4,8 @@ from http import HTTPStatus from dashscope import Deployments -from tests.unit.constants import TEST_JOB_ID -from tests.unit.mock_request_base import MockRequestBase +from tests.legacy_1x.unit.constants import TEST_JOB_ID +from tests.legacy_1x.unit.mock_request_base import MockRequestBase class TestDeploymentRequest(MockRequestBase): diff --git a/tests/unit/test_http_files_api.py b/tests/legacy_1x/unit/test_http_files_api.py similarity index 95% rename from tests/unit/test_http_files_api.py rename to tests/legacy_1x/unit/test_http_files_api.py index 950ff954..5e7938a7 100644 --- a/tests/unit/test_http_files_api.py +++ b/tests/legacy_1x/unit/test_http_files_api.py @@ -4,7 +4,7 @@ from http import HTTPStatus from dashscope import Files -from tests.unit.mock_request_base import MockRequestBase +from tests.legacy_1x.unit.mock_request_base import MockRequestBase class TestFileRequest(MockRequestBase): diff --git a/tests/unit/test_http_fine_tunes_api.py b/tests/legacy_1x/unit/test_http_fine_tunes_api.py similarity index 97% rename from tests/unit/test_http_fine_tunes_api.py rename to tests/legacy_1x/unit/test_http_fine_tunes_api.py index 3ea7e573..35457f52 100644 --- a/tests/unit/test_http_fine_tunes_api.py +++ b/tests/legacy_1x/unit/test_http_fine_tunes_api.py @@ -6,9 +6,9 @@ from http import HTTPStatus from dashscope import FineTunes -from tests.unit.constants import TEST_JOB_ID -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.constants import TEST_JOB_ID +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer # yapf: disable diff --git a/tests/unit/test_http_models_api.py b/tests/legacy_1x/unit/test_http_models_api.py similarity index 82% rename from tests/unit/test_http_models_api.py rename to tests/legacy_1x/unit/test_http_models_api.py index 53a01ff8..3d32672b 100644 --- a/tests/unit/test_http_models_api.py +++ b/tests/legacy_1x/unit/test_http_models_api.py @@ -4,8 +4,8 @@ from http import HTTPStatus from dashscope import Models -from tests.unit.constants import TEST_JOB_ID -from tests.unit.mock_request_base import MockRequestBase +from tests.legacy_1x.unit.constants import TEST_JOB_ID +from tests.legacy_1x.unit.mock_request_base import MockRequestBase class TestModelRequest(MockRequestBase): diff --git a/tests/unit/test_http_speech_synthesizer.py b/tests/legacy_1x/unit/test_http_speech_synthesizer.py similarity index 95% rename from tests/unit/test_http_speech_synthesizer.py rename to tests/legacy_1x/unit/test_http_speech_synthesizer.py index d59410df..bd72d156 100644 --- a/tests/unit/test_http_speech_synthesizer.py +++ b/tests/legacy_1x/unit/test_http_speech_synthesizer.py @@ -5,8 +5,10 @@ import base64 from http import HTTPStatus -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.audio.http_tts.http_speech_synthesizer import ( +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.audio.http_tts.http_speech_synthesizer import ( HttpSpeechSynthesizer, ) diff --git a/tests/unit/test_import_deprecation.py b/tests/legacy_1x/unit/test_import_deprecation.py similarity index 80% rename from tests/unit/test_import_deprecation.py rename to tests/legacy_1x/unit/test_import_deprecation.py index 6ecce74d..c5210ea8 100644 --- a/tests/unit/test_import_deprecation.py +++ b/tests/legacy_1x/unit/test_import_deprecation.py @@ -13,7 +13,7 @@ import textwrap from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] +REPO_ROOT = Path(__file__).resolve().parents[3] def _run(snippet, *, error_on_deprecation): @@ -41,6 +41,17 @@ def test_plain_import_emits_no_deprecation_warning(): assert result.returncode == 0, result.stderr +def test_legacy_errors_keep_their_runtime_behavior_under_warning_errors(): + result = _run( + "from dashscope.common.error import InvalidParameter; " + "raise InvalidParameter('bad')", + error_on_deprecation=True, + ) + assert result.returncode != 0 + assert "InvalidParameter: bad" in result.stderr + assert "PendingDeprecationWarning" not in result.stderr + + def test_non_assistants_usage_emits_no_deprecation_warning(): result = _run( "import dashscope; dashscope.Generation; dashscope.TextEmbedding", @@ -78,19 +89,22 @@ def test_accessing_threads_emits_deprecation_warning(): def test_direct_assistants_import_emits_deprecation_warning(): - result = _run("import dashscope.assistants", error_on_deprecation=True) + result = _run( + "import dashscope.legacy_1x.assistants", + error_on_deprecation=True, + ) assert result.returncode != 0 assert "DeprecationWarning" in result.stderr def test_lazy_names_resolve_to_real_objects(): import dashscope - from dashscope.assistants import Assistant, Assistants - from dashscope.assistants.assistant_types import ( + from dashscope.legacy_1x.assistants import Assistant, Assistants + from dashscope.legacy_1x.assistants.assistant_types import ( AssistantFile, DeleteResponse, ) - from dashscope.threads import RunStepList, Threads + from dashscope.legacy_1x.threads import RunStepList, Threads assert dashscope.Assistants is Assistants assert dashscope.Assistant is Assistant diff --git a/tests/legacy_1x/unit/test_layer_decoupling.py b/tests/legacy_1x/unit/test_layer_decoupling.py new file mode 100644 index 00000000..c884a1f6 --- /dev/null +++ b/tests/legacy_1x/unit/test_layer_decoupling.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Import-graph boundaries for the root implementation and legacy APIs. + +``dashscope/_shared`` is the neutral layer both stacks import from. The +current implementation must not reach into the 1.x business surface, apart +from explicitly pinned compatibility adapters. These are import-graph +properties, so they are surveyed as AST rather than left as prose. +""" + +from __future__ import annotations + +import ast +import importlib +import pathlib + +_REPO = pathlib.Path(__file__).resolve().parents[3] +_DASHSCOPE = _REPO / "dashscope" +_SHARED = _DASHSCOPE / "_shared" +_CURRENT = ( + _DASHSCOPE / "_config.py", + _DASHSCOPE / "_error_registry.py", + _DASHSCOPE / "_logging.py", + _DASHSCOPE / "_quickstart.py", + _DASHSCOPE / "applications" / "finetune" / "agentic_rl.py", + _DASHSCOPE / "client.py", + _DASHSCOPE / "errors.py", + _DASHSCOPE / "applications" / "finetune" / "_submit.py", + _DASHSCOPE / "protocols", + _DASHSCOPE / "resources", + _DASHSCOPE / "transport", + _DASHSCOPE / "types", + _DASHSCOPE / "applications" / "agentstudio", + _DASHSCOPE / "applications" / "finetune" / "reinforcement", +) + +_VERSION_FILE = "dashscope/version.py" + +_NEUTRAL_PACKAGES = frozenset( + { + "_shared", + "acli", + "agentstudio", + "applications", + "finetune", + "protocols", + "resources", + "transport", + "types", + }, +) + +_NEUTRAL_LEAVES = frozenset( + { + "dashscope", + "dashscope.version", + "dashscope._config", + "dashscope._error_registry", + "dashscope._logging", + "dashscope._quickstart", + "dashscope.client", + "dashscope.errors", + "dashscope.finetune._submit", + }, +) + +_SKIP_PARTS = frozenset({"examples"}) + +_COMPATIBILITY_IMPORTS = { + ( + "dashscope/applications/finetune/reinforcement/__init__.py", + "dashscope.legacy_1x.cli.agentic_rl", + ), +} + +# ``__version__`` bindings that are not the distribution version. +_OTHER_VERSION_OWNERS = frozenset( + { + # acli versions itself and is a one-way sync copy of the + # agenticCLI repo, so it must stay independent. + "dashscope/acli/__init__.py", + # The version of the vendored error-registry data. + "dashscope/_error_registry.py", + }, +) + +_DISABLE_ENV = "DASHSCOPE_DISABLE_SDK_HEADERS" + + +def _parse(path: pathlib.Path) -> ast.AST: + """Parse one module. A syntax error fails rather than skips.""" + source = path.read_text(encoding="utf-8") + try: + return ast.parse(source, str(path)) + except SyntaxError as exc: + raise AssertionError(f"{path} does not parse: {exc}") from exc + + +def _modules(root: pathlib.Path): + assert root.exists(), f"scan target does not exist: {root}" + if root.is_file(): + return [root] + paths = [ + path + for path in sorted(root.rglob("*.py")) + if not _SKIP_PARTS.intersection(path.parts) + ] + assert paths, f"scan target contains no Python modules: {root}" + return paths + + +def _dashscope_imports(tree: ast.AST): + """[(module, lineno)] for absolute ``dashscope.*`` imports.""" + found = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.extend((alias.name, node.lineno) for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level == 0 and node.module: + found.append((node.module, node.lineno)) + return [ + (module, lineno) + for module, lineno in found + if module.split(".")[0] == "dashscope" + ] + + +def _is_neutral(module: str) -> bool: + if module in _NEUTRAL_LEAVES: + return True + parts = module.split(".") + return len(parts) > 1 and parts[1] in _NEUTRAL_PACKAGES + + +def _module_level_version_owners(tree: ast.AST): + """Line numbers of module-scope ``__version__ =`` assignments. + + Function-scope bindings are deliberately excluded. Two readers -- + ``transport/http.default_user_agent`` and + ``agentstudio/client._user_agent`` -- degrade with a local + ``except Exception: __version__ = "0.0.0"``. That rebinds a name + inside one function; it does not define the distribution version. + """ + found = [] + for node in tree.body: + if not isinstance(node, ast.Assign): + continue + if any( + isinstance(t, ast.Name) and t.id == "__version__" + for t in node.targets + ): + found.append(node.lineno) + return found + + +def _rel(path: pathlib.Path) -> str: + return path.relative_to(_REPO).as_posix() + + +def test_current_implementation_does_not_import_1x_business_surface(): + offenders = [] + scanned = [] + for root in _CURRENT: + for path in _modules(root): + scanned.append(path) + for module, lineno in _dashscope_imports(_parse(path)): + if _is_neutral(module): + continue + if (_rel(path), module) in _COMPATIBILITY_IMPORTS: + continue + offenders.append(f"{_rel(path)}:{lineno} -> {module}") + assert scanned, "the current implementation scan must not be empty" + assert not offenders, ( + "the current implementation must not import the 1.x business " + "surface outside explicit compatibility adapters:\n " + + "\n ".join(offenders) + ) + + +def test_shared_layer_imports_no_1x_business_surface(): + """``_shared`` stays below both stacks, or it cannot anchor them. + + ``common/utils.py`` reaches ``_shared._sdk_ident`` while + ``dashscope/__init__`` is still executing, so a ``_shared`` module + that imports the 1.x surface closes a cycle through a partially + initialized root. ``dashscope.version`` is the one allowed exception + and is safe only because it imports nothing -- see + ``test_version_file_has_no_imports``. + """ + offenders = [] + for path in _modules(_SHARED): + for module, lineno in _dashscope_imports(_parse(path)): + if _is_neutral(module): + continue + offenders.append(f"{_rel(path)}:{lineno} -> {module}") + assert not offenders, ( + "dashscope/_shared may import only the stdlib, itself, and " + "dashscope.version:\n " + "\n ".join(offenders) + ) + + +def test_version_file_has_no_imports(): + """``dashscope/version.py`` must stay readable without importing. + + ``setup.py`` and ``docs/source/conf.py`` ``exec()`` this file to read + the version rather than importing it, which is what lets packaging + work in an isolated build environment where the runtime dependencies + are not installed. Any import added here -- a re-export of the value + from elsewhere, for instance -- turns reading one string into loading + the whole SDK and its dependencies. + """ + imports = [ + node.lineno + for node in ast.walk(_parse(_DASHSCOPE / "version.py")) + if isinstance(node, (ast.Import, ast.ImportFrom)) + ] + assert not imports, ( + f"{_VERSION_FILE} is exec'd standalone by setup.py and " + f"docs/source/conf.py, so it must not import anything; found " + f"imports at lines {imports}" + ) + + +def test_distribution_version_is_assigned_in_one_place(): + """One distribution version, defined once. + + A second literal would let the wheel's version and the version + reported in request headers drift apart, since ``setup.py`` reads the + file directly while the SDK reads the imported value. Only + module-scope bindings count; ``_OTHER_VERSION_OWNERS`` lists the two + that mean something else. + """ + owners = [] + for path in _modules(_DASHSCOPE): + rel = _rel(path) + if rel in _OTHER_VERSION_OWNERS: + continue + for lineno in _module_level_version_owners(_parse(path)): + owners.append((rel, lineno)) + located = [f"{rel}:{lineno}" for rel, lineno in owners] + assert [rel for rel, _ in owners] == [_VERSION_FILE], ( + "the distribution version must be assigned only in " + f"{_VERSION_FILE}; found: {located}" + ) + + +def test_one_version_reaches_every_identity_string(monkeypatch): + """Every request identity carries the same version segment. + + Each of these four sites reads the version through its own + defensive fallback to ``"0.0.0"``, so a read that failed would + degrade one telemetry string instead of failing loudly -- and the + header and the user-agent would disagree within one process. This + pins that they agree, including the two user-agents whose version + segment no other test looks at. + """ + monkeypatch.delenv(_DISABLE_ENV, raising=False) + version = importlib.import_module("dashscope.version").__version__ + + root = importlib.import_module("dashscope") + assert root.__version__ == version + + ident = importlib.import_module("dashscope._shared._sdk_ident") + headers = ident.get_sdk_headers() + assert headers["x-dashscope-sdk-client"].split("/")[1] == version + assert ident.get_user_agent().startswith(f"dashscope/{version};") + + v2_ident = importlib.import_module("dashscope.transport._ident") + assert v2_ident.SDK_CLIENT_VALUE.split("/")[1] == version + + http = importlib.import_module("dashscope.transport.http") + assert http.default_user_agent() == f"dashscope-v2-python/{version}" + + studio = importlib.import_module("dashscope.agentstudio.client") + agent = studio._user_agent("https://agentstudio.invalid") + assert agent == ( + f"dashscope-agentstudio-python/{version} " + "(+https://agentstudio.invalid)" + ) diff --git a/tests/unit/test_maas_region.py b/tests/legacy_1x/unit/test_maas_region.py similarity index 92% rename from tests/unit/test_maas_region.py rename to tests/legacy_1x/unit/test_maas_region.py index 0e1d340b..081ebc65 100644 --- a/tests/unit/test_maas_region.py +++ b/tests/legacy_1x/unit/test_maas_region.py @@ -4,8 +4,8 @@ import unittest from unittest.mock import patch -from dashscope.common.env import MAAS_REGIONS, resolve_base_url -from dashscope.common.error import InputRequired +from dashscope.legacy_1x.common.env import MAAS_REGIONS, resolve_base_url +from dashscope.legacy_1x.common.error import InputRequired class TestResolveBaseUrl(unittest.TestCase): @@ -24,7 +24,7 @@ def test_resolve_with_workspace(self): ) def test_resolve_without_workspace_raises(self): - import dashscope.common.env as env_mod + import dashscope.legacy_1x.common.env as env_mod url = "https://{workspace_id}.us-east-1.maas.aliyuncs.com/api/v1" with patch.object(env_mod, "_default_workspace_id", None): @@ -32,7 +32,7 @@ def test_resolve_without_workspace_raises(self): resolve_base_url(url, None) def test_resolve_with_env_default_workspace(self): - import dashscope.common.env as env_mod + import dashscope.legacy_1x.common.env as env_mod url = "https://{workspace_id}.us-east-1.maas.aliyuncs.com/api/v1" with patch.object(env_mod, "_default_workspace_id", "ws-env"): @@ -58,7 +58,7 @@ def test_resolve_rejects_invalid_workspace(self): resolve_base_url(url, bad) def test_resolve_rejects_invalid_env_default_workspace(self): - import dashscope.common.env as env_mod + import dashscope.legacy_1x.common.env as env_mod url = "https://{workspace_id}.us-east-1.maas.aliyuncs.com/api/v1" with patch.object(env_mod, "_default_workspace_id", "evil.com/x"): @@ -70,13 +70,13 @@ class TestValidateWorkspaceId(unittest.TestCase): """Test validate_workspace_id character restrictions.""" def test_valid_ids(self): - from dashscope.common.env import validate_workspace_id + from dashscope.legacy_1x.common.env import validate_workspace_id for valid in ["ws-123", "llm-abc_def", "A0", "a" * 64]: self.assertEqual(validate_workspace_id(valid), valid) def test_invalid_ids(self): - from dashscope.common.env import validate_workspace_id + from dashscope.legacy_1x.common.env import validate_workspace_id for invalid in [ "", @@ -101,7 +101,7 @@ class TestValidateRegion(unittest.TestCase): """Test validate_region character restrictions.""" def test_valid_regions(self): - from dashscope.common.env import validate_region + from dashscope.legacy_1x.common.env import validate_region # Upper case is accepted: DNS is case-insensitive and httpx # lowercases the host, so rejecting it broke working callers. @@ -109,7 +109,7 @@ def test_valid_regions(self): self.assertEqual(validate_region(valid), valid) def test_invalid_regions(self): - from dashscope.common.env import validate_region + from dashscope.legacy_1x.common.env import validate_region for invalid in [ "", @@ -150,7 +150,7 @@ class TestMaasEnvLoading(unittest.TestCase): def _reload_env(self, env_vars): """Reload env module with given env vars.""" import importlib - import dashscope.common.env as env_mod + import dashscope.legacy_1x.common.env as env_mod with patch.dict(os.environ, env_vars, clear=False): importlib.reload(env_mod) @@ -159,7 +159,7 @@ def _reload_env(self, env_vars): def _restore_env(self): """Restore env module to default state.""" import importlib - import dashscope.common.env as env_mod + import dashscope.legacy_1x.common.env as env_mod with patch.dict( os.environ, @@ -271,7 +271,7 @@ def tearDown(self): dashscope.base_http_api_url = self._orig_http_url def _capture_request_url(self, func): - from dashscope.client import base_api as base_api_mod + from dashscope.legacy_1x.client import base_api as base_api_mod class ShortCircuit(Exception): pass @@ -326,7 +326,7 @@ def test_async_task_call_resolves_workspace(self): ) def test_call_without_workspace_raises_clear_error(self): - import dashscope.common.env as env_mod + import dashscope.legacy_1x.common.env as env_mod from dashscope import Generation with patch.object(env_mod, "_default_workspace_id", None): diff --git a/tests/unit/test_messages.py b/tests/legacy_1x/unit/test_messages.py similarity index 96% rename from tests/unit/test_messages.py rename to tests/legacy_1x/unit/test_messages.py index 633ccb86..d1a3d0b3 100644 --- a/tests/unit/test_messages.py +++ b/tests/legacy_1x/unit/test_messages.py @@ -5,9 +5,9 @@ import uuid from dashscope import Messages # pylint: disable=no-name-in-module -from dashscope.threads.messages.files import Files -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from dashscope.legacy_1x.threads.messages.files import Files +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestMessages(MockServerBase): diff --git a/tests/unit/test_multimodal_dialog.py b/tests/legacy_1x/unit/test_multimodal_dialog.py similarity index 84% rename from tests/unit/test_multimodal_dialog.py rename to tests/legacy_1x/unit/test_multimodal_dialog.py index ceaedb59..b3d3d1bb 100644 --- a/tests/unit/test_multimodal_dialog.py +++ b/tests/legacy_1x/unit/test_multimodal_dialog.py @@ -5,19 +5,19 @@ import time import pytest -from dashscope.multimodal.dialog_state import DialogState -from dashscope.multimodal.multimodal_dialog import ( +from dashscope.legacy_1x.multimodal.dialog_state import DialogState +from dashscope.legacy_1x.multimodal.multimodal_dialog import ( MultiModalDialog, MultiModalCallback, ) -from dashscope.multimodal.multimodal_request_params import ( +from dashscope.legacy_1x.multimodal.multimodal_request_params import ( Upstream, Downstream, ClientInfo, RequestParameters, Device, ) -from tests.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.base_test import BaseTestEnvironment logger = logging.getLogger("dashscope") logger.setLevel(logging.DEBUG) @@ -29,7 +29,7 @@ conver_instance = None -# 定义voice chat服务回调 +# Define the voice chat service callback class TestCallback(MultiModalCallback): def on_connected(self): logger.debug("connected with server.") @@ -43,13 +43,13 @@ def on_stopped(self): def on_state_changed(self, state: DialogState): if state == DialogState.LISTENING: - # app.update_text("开始收音。。。请提问") + # app.update_text("Listening... please ask") return if state == DialogState.THINKING: - # app.update_text("思考中。。。请耐心等待") + # app.update_text("Thinking... please wait") return if state == DialogState.RESPONDING: - # app.update_text("正在回答。。。") + # app.update_text("Answering...") return def on_speech_audio_data( @@ -64,7 +64,7 @@ def on_error(self, error): sys.exit(0) def on_responding_started(self): - # 开始端侧播放 + # Start device-side playback # pcm_play.start_play() global conver_instance # pylint: disable=global-variable-not-assigned if conver_instance is not None: @@ -86,7 +86,7 @@ def on_responding_content(self, payload): logger.debug(payload) def on_request_accepted(self): - # 服务端接受打断当前播报 + # The server accepts interrupting the current playback # pcm_play.cancel_play() return @@ -107,7 +107,7 @@ def setup_class(cls): @pytest.mark.skip def test_multimodal_dialog_one_turn(self): - # 对话状态 Listening->Thinking->Responding->Listening + # Dialog states: Listening->Thinking->Responding->Listening up_stream = Upstream( type="AudioOnly", mode="push2talk", @@ -136,7 +136,7 @@ def test_multimodal_dialog_one_turn(self): ) self.conversation.start("") - # 首轮进入Listening + # First turn enters Listening while ( DialogState.LISTENING is not self.conversation.get_dialog_state() ): @@ -146,7 +146,7 @@ def test_multimodal_dialog_one_turn(self): "今天天气不错", parameters=None, ) - # 等待第二轮Listening + # Wait for the second Listening while ( DialogState.LISTENING is not self.conversation.get_dialog_state() ): diff --git a/tests/unit/test_opd.py b/tests/legacy_1x/unit/test_opd.py similarity index 88% rename from tests/unit/test_opd.py rename to tests/legacy_1x/unit/test_opd.py index 95748bf7..dc0e916f 100644 --- a/tests/unit/test_opd.py +++ b/tests/legacy_1x/unit/test_opd.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- """Minimal OPD contract tests.""" -# pylint: disable=redefined-outer-name # pytest fixture 标准用法 +# pylint: disable=redefined-outer-name # standard pytest fixture usage from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest -from dashscope.client.base_api import CreateMixin -from dashscope.finetune.agentic_rl import AgenticRL +from dashscope.legacy_1x.finetune.agentic_rl import AgenticRL from dashscope.finetune.reinforcement import ( DataSourceType, RewardFunctionComponent, @@ -21,6 +20,8 @@ @pytest.fixture def rl_client(): + # ``set_api_key`` is looked up in the module that defines __init__, + # which is the 2.0 one; patching the 1.x shim's copy would miss. with patch("dashscope.finetune.agentic_rl.set_api_key"): return AgenticRL(api_key="test-key") @@ -69,9 +70,8 @@ def test_teacher_model_enables_opd( has_rollout, reward_count, ): - with patch.object( - CreateMixin, - "call", + with patch( + "dashscope.finetune._submit.post_submit", return_value=_success_response(), ) as call: rl_client.submit_job( @@ -96,9 +96,8 @@ def test_teacher_model_can_come_from_yaml(rl_client, tmp_path): ) rl_client.init(config_path=str(config)) - with patch.object( - CreateMixin, - "call", + with patch( + "dashscope.finetune._submit.post_submit", return_value=_success_response(), ) as call: rl_client.submit_job(datasets=[_training_dataset()]) @@ -110,16 +109,15 @@ def test_teacher_model_can_come_from_yaml(rl_client, tmp_path): def test_opd_example_reuses_supported_rl_configuration(rl_client): config = ( - Path(__file__).parents[2] - / "dashscope/finetune/reinforcement/examples/workspace" + Path(__file__).parents[3] + / "dashscope/applications/finetune/reinforcement/examples/workspace" / "opd-job.yaml" ) config_text = config.read_text(encoding="utf-8") rl_client.init(config_path=str(config)) - with patch.object( - CreateMixin, - "call", + with patch( + "dashscope.finetune._submit.post_submit", return_value=_success_response(), ) as call: rl_client.submit_job() @@ -159,8 +157,8 @@ def test_opd_example_reuses_supported_rl_configuration(rl_client): @pytest.mark.asyncio async def test_opd_example_uploads_local_datasets(rl_client): config = ( - Path(__file__).parents[2] - / "dashscope/finetune/reinforcement/examples/workspace" + Path(__file__).parents[3] + / "dashscope/applications/finetune/reinforcement/examples/workspace" / "opd-job.yaml" ) rl_client.init(config_path=str(config)) @@ -181,8 +179,8 @@ async def test_opd_example_uploads_local_datasets(rl_client): def test_regular_rl_example_keeps_resources_and_rl_hyperparameters(rl_client): config = ( - Path(__file__).parents[2] - / "dashscope/finetune/reinforcement/examples/workspace" + Path(__file__).parents[3] + / "dashscope/applications/finetune/reinforcement/examples/workspace" / "rl-job.yaml" ) rl_client.init(config_path=str(config)) @@ -212,9 +210,8 @@ def test_pg_opd_requires_teacher_model(rl_client): def test_regular_reinforcement_is_unchanged(rl_client): - with patch.object( - CreateMixin, - "call", + with patch( + "dashscope.finetune._submit.post_submit", return_value=_success_response(), ) as call: rl_client.submit_job(datasets=[_training_dataset()]) diff --git a/tests/unit/test_oss_utils.py b/tests/legacy_1x/unit/test_oss_utils.py similarity index 96% rename from tests/unit/test_oss_utils.py rename to tests/legacy_1x/unit/test_oss_utils.py index 505d384b..2b4eb022 100644 --- a/tests/unit/test_oss_utils.py +++ b/tests/legacy_1x/unit/test_oss_utils.py @@ -5,9 +5,9 @@ import pytest -from dashscope.common.error import InvalidInput -from dashscope.utils import oss_utils -from dashscope.utils.oss_utils import OssUtils +from dashscope.legacy_1x.common.error import InvalidInput +from dashscope.legacy_1x.utils import oss_utils +from dashscope.legacy_1x.utils.oss_utils import OssUtils class FakeUploadResponse: diff --git a/tests/unit/test_packaging_metadata.py b/tests/legacy_1x/unit/test_packaging_metadata.py similarity index 86% rename from tests/unit/test_packaging_metadata.py rename to tests/legacy_1x/unit/test_packaging_metadata.py index 2056482b..37b0dbc5 100644 --- a/tests/unit/test_packaging_metadata.py +++ b/tests/legacy_1x/unit/test_packaging_metadata.py @@ -13,6 +13,7 @@ import ast import json import re +import subprocess import sys import urllib.error import urllib.request @@ -21,7 +22,7 @@ import pytest -ROOT = Path(__file__).resolve().parents[2] +ROOT = Path(__file__).resolve().parents[3] SETUP_PY = ROOT / "setup.py" WORKFLOW = ROOT / ".github" / "workflows" / "unit_test.yml" PYPISTATS_URL = ( @@ -294,3 +295,46 @@ def test_repo_classifiers_cover_every_python_carrying_real_traffic(): if shares is None: pytest.skip("pypistats unreachable") assert not repo_audit(shares) + + +def test_python_39_config_reader_is_a_core_dependency(): + requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8") + assert 'tomli>=1.1.0; python_version < "3.11"' in requirements + + +def test_build_py_removes_stale_deleted_packages(tmp_path): + """Incremental wheel builds must not resurrect pre-migration modules. + + A dirty ``build/lib`` can otherwise contain both ``dashscope/client.py`` + and the deleted ``dashscope/client/__init__.py`` package. Python chooses + the stale package and ``from dashscope.client import Client`` then fails. + """ + build_lib = tmp_path / "build-lib" + stale_files = ( + build_lib / "dashscope" / "client" / "__init__.py", + build_lib / "dashscope" / "aigc" / "__init__.py", + build_lib / "dashscope" / "resources" / "qwen.tiktoken", + ) + for stale in stale_files: + stale.parent.mkdir(parents=True, exist_ok=True) + stale.write_text("# stale pre-2.0 build artifact\n", encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + "setup.py", + "build_py", + "--build-lib", + str(build_lib), + "--force", + ], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + timeout=120, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert (build_lib / "dashscope" / "client.py").is_file() + for stale in stale_files: + assert not stale.exists(), stale diff --git a/tests/unit/test_recognition_explicit_params.py b/tests/legacy_1x/unit/test_recognition_explicit_params.py similarity index 98% rename from tests/unit/test_recognition_explicit_params.py rename to tests/legacy_1x/unit/test_recognition_explicit_params.py index dcb4016b..7a9c6136 100644 --- a/tests/unit/test_recognition_explicit_params.py +++ b/tests/legacy_1x/unit/test_recognition_explicit_params.py @@ -7,7 +7,10 @@ from contextlib import contextmanager from unittest import mock -from dashscope.audio.asr.recognition import Recognition, RecognitionCallback +from dashscope.legacy_1x.audio.asr.recognition import ( + Recognition, + RecognitionCallback, +) class MockRecognitionCallback(RecognitionCallback): diff --git a/tests/unit/test_rerank.py b/tests/legacy_1x/unit/test_rerank.py similarity index 94% rename from tests/unit/test_rerank.py rename to tests/legacy_1x/unit/test_rerank.py index b7427944..357fe88d 100644 --- a/tests/unit/test_rerank.py +++ b/tests/legacy_1x/unit/test_rerank.py @@ -6,8 +6,8 @@ import uuid from dashscope import AioTextReRank, TextReRank -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestReRank(MockServerBase): @@ -18,14 +18,14 @@ def test_call(self, mock_server: MockServer): { "index": 1, "relevance_score": 0.987654, - "document": { # 如果return_documents=true + "document": { # when return_documents=true "text": "哈尔滨是中国黑龙江省的省会,位于中国东北", }, }, { "index": 0, "relevance_score": 0.876543, - "document": { # 如果return_documents=true + "document": { # when return_documents=true "text": "黑龙江离俄罗斯很近", }, }, diff --git a/tests/unit/test_runs.py b/tests/legacy_1x/unit/test_runs.py similarity index 99% rename from tests/unit/test_runs.py rename to tests/legacy_1x/unit/test_runs.py index fe6830dd..86283a4b 100644 --- a/tests/unit/test_runs.py +++ b/tests/legacy_1x/unit/test_runs.py @@ -5,8 +5,8 @@ import uuid from dashscope import Runs, Steps # pylint: disable=no-name-in-module -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestRuns(MockServerBase): diff --git a/tests/unit/test_sdk_headers.py b/tests/legacy_1x/unit/test_sdk_headers.py similarity index 83% rename from tests/unit/test_sdk_headers.py rename to tests/legacy_1x/unit/test_sdk_headers.py index 7a3042f3..21ac656c 100644 --- a/tests/unit/test_sdk_headers.py +++ b/tests/legacy_1x/unit/test_sdk_headers.py @@ -9,14 +9,14 @@ from dashscope.acli import SDK_SESSION_ID as ACLI_SESSION_ID from dashscope.acli import __version__ as acli_version from dashscope.acli.providers.tongyi import TongyiProvider -from dashscope.common.utils import _SDK_SESSION_ID, get_sdk_headers +from dashscope.legacy_1x.common.utils import _SDK_SESSION_ID, get_sdk_headers CLIENT_HEADER = "x-dashscope-sdk-client" SESSION_HEADER = "x-dashscope-sdk-session-id" DISABLE_ENV = "DASHSCOPE_DISABLE_SDK_HEADERS" # client value sources: -# - "python-sdk": SDK default (_SDK_CLIENT in common/utils.py) +# - "python-sdk": SDK default (_SDK_CLIENT in _shared/_sdk_ident.py) # - "python-cli": dashscope/cli/__init__.py calls # set_sdk_client("python-cli") at CLI process startup # - "acli": acli/providers/tongyi.py builds its own header @@ -35,7 +35,10 @@ @pytest.fixture(autouse=True) def _pin_sdk_client(monkeypatch): # CLI tests switch the client to python-cli in-process; pin the default - monkeypatch.setattr("dashscope.common.utils._SDK_CLIENT", "python-sdk") + monkeypatch.setattr( + "dashscope._shared._sdk_ident._SDK_CLIENT", + "python-sdk", + ) def _check_client_header(value, client, version, module=""): @@ -59,6 +62,22 @@ def test_python_sdk_client_header(): assert headers[SESSION_HEADER] == _SDK_SESSION_ID +def test_session_id_has_one_source_across_stacks(): + # A process using the 1.x classes and the 2.0 client at once must + # report one id, or server-side log grouping splits a single logical + # session. The legacy deep path re-exports the neutral leaf rather + # than holding its own copy of the state. + from dashscope._shared._sdk_session import sdk_session_id as leaf + from dashscope.legacy_1x.common._sdk_session import ( + sdk_session_id as via_1x, + ) + from dashscope.transport._ident import sdk_session_id as via_v2 + + assert via_1x is leaf + assert via_v2 is leaf + assert leaf() == _SDK_SESSION_ID + + def test_acli_client_header(): # pylint: disable=protected-access print("\n=== acli: x-dashscope-sdk-client ===") @@ -90,7 +109,7 @@ def test_sdk_headers_disabled(monkeypatch): def test_python_cli_client_header(): - from dashscope.common.utils import set_sdk_client + from dashscope.legacy_1x.common.utils import set_sdk_client set_sdk_client("python-cli") try: @@ -107,14 +126,14 @@ def test_python_cli_client_header(): def test_cli_import_marks_process(): - # subprocess isolation check: after importing dashscope.cli, requests in + # subprocess isolation check: after importing dashscope.legacy_1x.cli, requests in # that process must carry client=python-cli (independent of test order) import subprocess import sys code = ( - "import dashscope.cli; " - "from dashscope.common.utils import get_sdk_headers; " + "import dashscope.legacy_1x.cli; " + "from dashscope.legacy_1x.common.utils import get_sdk_headers; " "h = get_sdk_headers(); " "c = h['x-dashscope-sdk-client']; " "assert c.startswith('python-cli/'), c; " @@ -181,27 +200,28 @@ def test_embedded_module_defaults_to_app(monkeypatch): def test_get_api_module_mapping(): - from dashscope.common.utils import get_api_module + from dashscope.legacy_1x.common.utils import get_api_module cases = { - "dashscope.aigc.generation": "aigc", - "dashscope.aigc.multimodal_conversation": "aigc", - "dashscope.audio.tts.speech_synthesizer": "audio", - "dashscope.audio.asr.recognition": "audio", - "dashscope.embeddings.text_embedding": "embeddings", - "dashscope.nlp.understanding": "nlp", - "dashscope.rerank.text_rerank": "rerank", - "dashscope.app.application": "app", + "dashscope.legacy_1x.aigc.generation": "aigc", + "dashscope.legacy_1x.aigc.multimodal_conversation": "aigc", + "dashscope.legacy_1x.audio.tts.speech_synthesizer": "audio", + "dashscope.legacy_1x.audio.asr.recognition": "audio", + "dashscope.legacy_1x.embeddings.text_embedding": "embeddings", + "dashscope.legacy_1x.nlp.understanding": "nlp", + "dashscope.legacy_1x.rerank.text_rerank": "rerank", + "dashscope.legacy_1x.app.application": "app", "dashscope.tokenizers.tokenization": "tokenizers", - "dashscope.assistants.assistants": "assistants", + "dashscope.legacy_1x.assistants.assistants": "assistants", # threads/messages/runs/steps are grouped into the assistants module - "dashscope.threads.threads": "assistants", - "dashscope.threads.runs.runs": "assistants", - "dashscope.models": "models", - "dashscope.files": "files", - "dashscope.finetune.finetunes": "finetune", - "dashscope.multimodal.tingwu.tingwu": "multimodal", - "dashscope.common.utils": "common", # non-API classes never reach this + "dashscope.legacy_1x.threads.threads": "assistants", + "dashscope.legacy_1x.threads.runs.runs": "assistants", + "dashscope.legacy_1x.models": "models", + "dashscope.legacy_1x.files": "files", + "dashscope.legacy_1x.finetune.finetunes": "finetune", + "dashscope.legacy_1x.multimodal.tingwu.tingwu": "multimodal", + # non-API classes never reach this + "dashscope.legacy_1x.common.utils": "common", "other.lib": "", } for module_name, expected in cases.items(): @@ -223,8 +243,10 @@ def capture_headers(monkeypatch): import aiohttp import requests - from dashscope.api_entities.http_request import HttpRequest - from dashscope.api_entities.websocket_request import WebSocketRequest + from dashscope.legacy_1x.api_entities.http_request import HttpRequest + from dashscope.legacy_1x.api_entities.websocket_request import ( + WebSocketRequest, + ) def _raise(headers): raise _CapturedRequest(headers) @@ -290,29 +312,35 @@ def test_service_api_modules(capture_headers, tmp_path): Understanding, VideoSynthesis, ) - from dashscope.aigc.generation import AioGeneration - from dashscope.aigc.multimodal_conversation import ( + from dashscope.legacy_1x.aigc.generation import AioGeneration + from dashscope.legacy_1x.aigc.multimodal_conversation import ( AioMultiModalConversation, ) - from dashscope.audio.asr.recognition import ( + from dashscope.legacy_1x.audio.asr.recognition import ( Recognition, RecognitionCallback, ) - from dashscope.audio.asr.transcription import Transcription - from dashscope.audio.asr.translation_recognizer import ( + from dashscope.legacy_1x.audio.asr.transcription import Transcription + from dashscope.legacy_1x.audio.asr.translation_recognizer import ( TranslationRecognizerCallback, TranslationRecognizerRealtime, ) - from dashscope.audio.http_tts.http_speech_synthesizer import ( + from dashscope.legacy_1x.audio.http_tts.http_speech_synthesizer import ( HttpSpeechSynthesizer, ) - from dashscope.audio.qwen_asr.qwen_transcription import QwenTranscription - from dashscope.audio.tts.speech_synthesizer import SpeechSynthesizer - from dashscope.embeddings.batch_text_embedding import BatchTextEmbedding - from dashscope.embeddings.multimodal_embedding import ( + from dashscope.legacy_1x.audio.qwen_asr.qwen_transcription import ( + QwenTranscription, + ) + from dashscope.legacy_1x.audio.tts.speech_synthesizer import ( + SpeechSynthesizer, + ) + from dashscope.legacy_1x.embeddings.batch_text_embedding import ( + BatchTextEmbedding, + ) + from dashscope.legacy_1x.embeddings.multimodal_embedding import ( AioMultiModalEmbedding, ) - from dashscope.rerank.text_rerank import AioTextReRank + from dashscope.legacy_1x.rerank.text_rerank import AioTextReRank audio_file = tmp_path / "a.wav" audio_file.write_bytes(b"\x00" * 128) @@ -500,8 +528,8 @@ def test_tasks_module(capture_headers): import asyncio from dashscope import ImageSynthesis - from dashscope.aigc.image_synthesis import AioImageSynthesis - from dashscope.aigc.video_synthesis import VideoSynthesis + from dashscope.legacy_1x.aigc.image_synthesis import AioImageSynthesis + from dashscope.legacy_1x.aigc.video_synthesis import VideoSynthesis cases = [ ( @@ -590,18 +618,20 @@ def test_resource_api_modules(capture_headers): def test_realtime_websocket_modules(): # pylint: disable=import-outside-toplevel,protected-access import dashscope - from dashscope.audio.qwen_omni.omni_realtime import ( + from dashscope.legacy_1x.audio.qwen_omni.omni_realtime import ( OmniRealtimeCallback, OmniRealtimeConversation, ) - from dashscope.audio.qwen_tts_realtime.qwen_tts_realtime import ( + from dashscope.legacy_1x.audio.qwen_tts_realtime.qwen_tts_realtime import ( QwenTtsRealtime, ) - from dashscope.audio.tts_v2.speech_synthesizer import Request as TtsV2Req - from dashscope.multimodal.multimodal_dialog import ( + from dashscope.legacy_1x.audio.tts_v2.speech_synthesizer import ( + Request as TtsV2Req, + ) + from dashscope.legacy_1x.multimodal.multimodal_dialog import ( _Request as DialogRequest, ) - from dashscope.multimodal.tingwu.tingwu_realtime import ( + from dashscope.legacy_1x.multimodal.tingwu.tingwu_realtime import ( _Request as TingwuRequest, ) @@ -652,13 +682,13 @@ class _Cb(OmniRealtimeCallback): def test_encryption_module(monkeypatch): # pylint: disable=import-outside-toplevel - from dashscope.api_entities.encryption import Encryption + from dashscope.legacy_1x.api_entities.encryption import Encryption def fake_get(url, headers=None, timeout=None): raise _CapturedRequest(headers) monkeypatch.setattr( - "dashscope.api_entities.encryption.requests.get", + "dashscope.legacy_1x.api_entities.encryption.requests.get", fake_get, ) monkeypatch.setattr("dashscope.api_key", "sk-test") diff --git a/tests/unit/test_set_region.py b/tests/legacy_1x/unit/test_set_region.py similarity index 100% rename from tests/unit/test_set_region.py rename to tests/legacy_1x/unit/test_set_region.py diff --git a/tests/unit/test_sketch_image_synthesis.py b/tests/legacy_1x/unit/test_sketch_image_synthesis.py similarity index 95% rename from tests/unit/test_sketch_image_synthesis.py rename to tests/legacy_1x/unit/test_sketch_image_synthesis.py index 70462079..4706f457 100644 --- a/tests/unit/test_sketch_image_synthesis.py +++ b/tests/legacy_1x/unit/test_sketch_image_synthesis.py @@ -5,8 +5,8 @@ from http import HTTPStatus from dashscope import ImageSynthesis -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestSketchImageSynthesis(MockServerBase): diff --git a/tests/unit/test_speech_synthesis_v2.py b/tests/legacy_1x/unit/test_speech_synthesis_v2.py similarity index 96% rename from tests/unit/test_speech_synthesis_v2.py rename to tests/legacy_1x/unit/test_speech_synthesis_v2.py index 798db207..50594263 100644 --- a/tests/unit/test_speech_synthesis_v2.py +++ b/tests/legacy_1x/unit/test_speech_synthesis_v2.py @@ -6,10 +6,10 @@ import pytest import dashscope -from dashscope.audio.tts_v2 import ResultCallback, SpeechSynthesizer -from dashscope.common.error import RequestFailure -from dashscope.protocol.websocket import ActionType, EventType -from tests.unit.base_test import BaseTestEnvironment +from dashscope.legacy_1x.audio.tts_v2 import ResultCallback, SpeechSynthesizer +from dashscope.legacy_1x.common.error import RequestFailure +from dashscope.legacy_1x.protocol.websocket import ActionType, EventType +from tests.legacy_1x.unit.base_test import BaseTestEnvironment class TestCallback(ResultCallback): diff --git a/tests/unit/test_sync_custom_session.py b/tests/legacy_1x/unit/test_sync_custom_session.py similarity index 95% rename from tests/unit/test_sync_custom_session.py rename to tests/legacy_1x/unit/test_sync_custom_session.py index 2d45475c..c9e60b26 100644 --- a/tests/unit/test_sync_custom_session.py +++ b/tests/legacy_1x/unit/test_sync_custom_session.py @@ -24,10 +24,12 @@ import requests from requests.adapters import HTTPAdapter -from dashscope.api_entities import http_request as http_request_module -from dashscope.api_entities.http_request import HttpRequest -from dashscope.api_entities.api_request_data import ApiRequestData -from dashscope.common.constants import ApiProtocol, HTTPMethod +from dashscope.legacy_1x.api_entities import ( + http_request as http_request_module, +) +from dashscope.legacy_1x.api_entities.http_request import HttpRequest +from dashscope.legacy_1x.api_entities.api_request_data import ApiRequestData +from dashscope.legacy_1x.common.constants import ApiProtocol, HTTPMethod class TestSyncSessionBasics: @@ -121,7 +123,9 @@ def test_custom_session_is_used_for_request(self, _mock_session_class): # verify the custom session was not closed mock_session.close.assert_not_called() - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_shared_session_is_used_when_no_custom_session( self, mock_get_session, @@ -214,7 +218,9 @@ def test_custom_session_not_closed_by_http_request(self): # verify the custom session was not closed custom_session.close.assert_not_called() - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_shared_session_not_closed_on_success(self, mock_get_session): """Shared session is not closed after success""" mock_session = Mock() @@ -254,7 +260,9 @@ def test_shared_session_not_closed_on_success(self, mock_get_session): # verify the shared session was not closed mock_session.close.assert_not_called() - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_shared_session_not_closed_on_exception(self, mock_get_session): """Shared session is not closed on exception""" mock_session = Mock() @@ -474,7 +482,9 @@ def test_works_without_session_parameter(self): assert http_request.url == "http://example.com/api" assert http_request.method == HTTPMethod.POST - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_default_behavior_unchanged(self, mock_get_session): """Default behavior: use the shared session""" mock_session = Mock() @@ -548,7 +558,9 @@ def _ok_response(): mock_response.text = '{"status": "success"}' return mock_response - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_retry_once_on_connection_error(self, mock_get_session): """Retry once and succeed when the peer drops the connection""" mock_session = Mock() @@ -572,7 +584,9 @@ def test_retry_once_on_connection_error(self, mock_get_session): assert mock_session.post.call_count == 2 - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_retry_exhausted_raises(self, mock_get_session): """Raise after two consecutive ConnectionErrors""" mock_session = Mock() @@ -588,7 +602,9 @@ def test_retry_exhausted_raises(self, mock_get_session): assert mock_session.post.call_count == 2 - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_no_retry_on_other_exceptions(self, mock_get_session): """Non-connection exceptions are not retried""" mock_session = Mock() diff --git a/tests/unit/test_text_embedding.py b/tests/legacy_1x/unit/test_text_embedding.py similarity index 94% rename from tests/unit/test_text_embedding.py rename to tests/legacy_1x/unit/test_text_embedding.py index 9aaf76a7..13f80355 100644 --- a/tests/unit/test_text_embedding.py +++ b/tests/legacy_1x/unit/test_text_embedding.py @@ -4,7 +4,7 @@ from http import HTTPStatus from dashscope import TextEmbedding -from tests.unit.mock_request_base import MockRequestBase +from tests.legacy_1x.unit.mock_request_base import MockRequestBase class TestTextEmbeddingRequest(MockRequestBase): diff --git a/tests/unit/test_threads.py b/tests/legacy_1x/unit/test_threads.py similarity index 96% rename from tests/unit/test_threads.py rename to tests/legacy_1x/unit/test_threads.py index bbd780a1..51fadf1c 100644 --- a/tests/unit/test_threads.py +++ b/tests/legacy_1x/unit/test_threads.py @@ -5,8 +5,8 @@ import uuid from dashscope import Threads # pylint: disable=no-name-in-module -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestThreads(MockServerBase): diff --git a/tests/unit/test_tokenization.py b/tests/legacy_1x/unit/test_tokenization.py similarity index 92% rename from tests/unit/test_tokenization.py rename to tests/legacy_1x/unit/test_tokenization.py index 7728fe78..0c7730b9 100644 --- a/tests/unit/test_tokenization.py +++ b/tests/legacy_1x/unit/test_tokenization.py @@ -5,8 +5,8 @@ from http import HTTPStatus from dashscope import Tokenization -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestTokenization(MockServerBase): diff --git a/tests/unit/test_tokenization_explicit_params.py b/tests/legacy_1x/unit/test_tokenization_explicit_params.py similarity index 100% rename from tests/unit/test_tokenization_explicit_params.py rename to tests/legacy_1x/unit/test_tokenization_explicit_params.py diff --git a/tests/unit/test_tokenizer.py b/tests/legacy_1x/unit/test_tokenizer.py similarity index 54% rename from tests/unit/test_tokenizer.py rename to tests/legacy_1x/unit/test_tokenizer.py index 682c6a15..e9e703f6 100644 --- a/tests/unit/test_tokenizer.py +++ b/tests/legacy_1x/unit/test_tokenizer.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +import importlib import os +import pytest + from dashscope.tokenizers.tokenizer import get_tokenizer from dashscope.tokenizers.qwen_tokenizer import _CHUNK_SIZE @@ -78,3 +81,53 @@ def test_encode_chunk_size_exceed(self): ) decoded_with_special = tokenizer.decode(tokens_with_special) assert decoded_with_special == long_text_with_special + + +class TestTokenizerPaths: + """The root package owns one implementation and its published exports.""" + + @pytest.mark.parametrize( + "module_path,name", + [ + ("dashscope.tokenizers.tokenizer", "get_tokenizer"), + ("dashscope.tokenizers.tokenizer", "list_tokenizers"), + ("dashscope.tokenizers.tokenizer", "QWEN_SERIALS"), + ("dashscope.tokenizers.qwen_tokenizer", "QwenTokenizer"), + ("dashscope.tokenizers.qwen_tokenizer", "SPECIAL_TOKENS_SET"), + ("dashscope.tokenizers.tokenizer_base", "Tokenizer"), + ], + ) + def test_deep_imports_resolve(self, module_path, name): + module = importlib.import_module(module_path) + + assert getattr(module, name) is not None + + def test_package_level_reexports(self): + from dashscope import tokenizers + from dashscope.tokenizers.tokenization import Tokenization + from dashscope.tokenizers.tokenizer import list_tokenizers + from dashscope.tokenizers.tokenizer_base import Tokenizer + + assert tokenizers.__all__ == [ + "Tokenization", + "Tokenizer", + "get_tokenizer", + "list_tokenizers", + ] + assert tokenizers.Tokenization is Tokenization + assert tokenizers.Tokenizer is Tokenizer + assert tokenizers.get_tokenizer is get_tokenizer + assert tokenizers.list_tokenizers is list_tokenizers + + def test_public_path_returns_a_working_tokenizer(self): + tokenizer = get_tokenizer("qwen-turbo") + tokens = tokenizer.encode("hello world") + + assert isinstance(tokens, list) + assert tokenizer.decode(tokens) == "hello world" + + def test_from_import_syntax_resolves(self): + from dashscope.tokenizers.qwen_tokenizer import QwenTokenizer + from dashscope.tokenizers.tokenizer_base import Tokenizer + + assert issubclass(QwenTokenizer, Tokenizer) diff --git a/tests/unit/test_tongyi_native.py b/tests/legacy_1x/unit/test_tongyi_native.py similarity index 100% rename from tests/unit/test_tongyi_native.py rename to tests/legacy_1x/unit/test_tongyi_native.py diff --git a/tests/unit/test_translation_recognizer.py b/tests/legacy_1x/unit/test_translation_recognizer.py similarity index 94% rename from tests/unit/test_translation_recognizer.py rename to tests/legacy_1x/unit/test_translation_recognizer.py index 5f9782fe..6b642d61 100644 --- a/tests/unit/test_translation_recognizer.py +++ b/tests/legacy_1x/unit/test_translation_recognizer.py @@ -6,13 +6,13 @@ import pytest -from dashscope.audio.asr import TranslationRecognizerCallback -from dashscope.audio.asr.translation_recognizer import ( +from dashscope.legacy_1x.audio.asr import TranslationRecognizerCallback +from dashscope.legacy_1x.audio.asr.translation_recognizer import ( TranscriptionResult, TranslationRecognizerRealtime, TranslationResult, ) -from tests.unit.base_test import BaseTestEnvironment +from tests.legacy_1x.unit.base_test import BaseTestEnvironment class Callback(TranslationRecognizerCallback): diff --git a/tests/unit/test_translation_recognizer_explicit_params.py b/tests/legacy_1x/unit/test_translation_recognizer_explicit_params.py similarity index 99% rename from tests/unit/test_translation_recognizer_explicit_params.py rename to tests/legacy_1x/unit/test_translation_recognizer_explicit_params.py index 8be53bbe..63f50a5c 100644 --- a/tests/unit/test_translation_recognizer_explicit_params.py +++ b/tests/legacy_1x/unit/test_translation_recognizer_explicit_params.py @@ -3,7 +3,7 @@ """Test explicit parameters for TranslationRecognizer classes.""" # pylint: disable=protected-access -from dashscope.audio.asr.translation_recognizer import ( +from dashscope.legacy_1x.audio.asr.translation_recognizer import ( TranslationRecognizerRealtime, TranslationRecognizerChat, TranslationRecognizerCallback, diff --git a/tests/unit/test_understanding.py b/tests/legacy_1x/unit/test_understanding.py similarity index 93% rename from tests/unit/test_understanding.py rename to tests/legacy_1x/unit/test_understanding.py index 6e2756c8..63eaf3d4 100644 --- a/tests/unit/test_understanding.py +++ b/tests/legacy_1x/unit/test_understanding.py @@ -5,8 +5,8 @@ from http import HTTPStatus from dashscope import Understanding -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer model = Understanding.Models.opennlu_v1 diff --git a/tests/unit/test_utf8_encoding.py b/tests/legacy_1x/unit/test_utf8_encoding.py similarity index 93% rename from tests/unit/test_utf8_encoding.py rename to tests/legacy_1x/unit/test_utf8_encoding.py index 32dcaf2d..c91f5cf2 100644 --- a/tests/unit/test_utf8_encoding.py +++ b/tests/legacy_1x/unit/test_utf8_encoding.py @@ -15,9 +15,9 @@ import pytest -from dashscope.api_entities.http_request import HttpRequest -from dashscope.api_entities.aiohttp_request import AioHttpRequest -from dashscope.common.constants import HTTPMethod +from dashscope.legacy_1x.api_entities.http_request import HttpRequest +from dashscope.legacy_1x.api_entities.aiohttp_request import AioHttpRequest +from dashscope.legacy_1x.common.constants import HTTPMethod NON_ASCII_PAYLOAD = { @@ -162,7 +162,9 @@ def test_accept_has_charset(self): class TestHttpRequestUtf8Body: # pylint: disable=protected-access - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_body_is_utf8_bytes(self, mock_session_cls): mock_session = _make_mock_sync_session() mock_session_cls.return_value = mock_session @@ -186,7 +188,9 @@ def test_body_is_utf8_bytes(self, mock_session_cls): assert isinstance(sent_body, bytes), "Body must be bytes, not str" assert sent_body == EXPECTED_BODY - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_no_unicode_escapes_in_body(self, mock_session_cls): mock_session = _make_mock_sync_session() mock_session_cls.return_value = mock_session @@ -212,7 +216,9 @@ def test_no_unicode_escapes_in_body(self, mock_session_cls): assert "Оцени идею" in body_str assert "こんにちは" in body_str - @patch("dashscope.api_entities.http_request._get_shared_sync_session") + @patch( + "dashscope.legacy_1x.api_entities.http_request._get_shared_sync_session", + ) def test_body_is_smaller_than_ascii_escaped(self, mock_session_cls): mock_session = _make_mock_sync_session() mock_session_cls.return_value = mock_session @@ -311,7 +317,9 @@ class TestWebSocketRequestUtf8: # pylint: disable=protected-access def test_build_up_message_no_unicode_escapes(self): - from dashscope.api_entities.websocket_request import WebSocketRequest + from dashscope.legacy_1x.api_entities.websocket_request import ( + WebSocketRequest, + ) req = WebSocketRequest( url="wss://example.com/api-ws/v1/test", @@ -330,7 +338,9 @@ def test_build_up_message_no_unicode_escapes(self): assert "你好世界" in message def test_build_up_message_smaller_than_ascii_escaped(self): - from dashscope.api_entities.websocket_request import WebSocketRequest + from dashscope.legacy_1x.api_entities.websocket_request import ( + WebSocketRequest, + ) req = WebSocketRequest( url="wss://example.com/api-ws/v1/test", diff --git a/tests/unit/test_video_synthesis_params.py b/tests/legacy_1x/unit/test_video_synthesis_params.py similarity index 98% rename from tests/unit/test_video_synthesis_params.py rename to tests/legacy_1x/unit/test_video_synthesis_params.py index a23f0a4a..9be7c779 100644 --- a/tests/unit/test_video_synthesis_params.py +++ b/tests/legacy_1x/unit/test_video_synthesis_params.py @@ -5,8 +5,8 @@ from http import HTTPStatus from dashscope import VideoSynthesis -from tests.unit.mock_request_base import MockServerBase -from tests.unit.mock_server import MockServer +from tests.legacy_1x.unit.mock_request_base import MockServerBase +from tests.legacy_1x.unit.mock_server import MockServer class TestVideoSynthesis(MockServerBase): diff --git a/tests/unit/websocket_mock_server_task_handler.py b/tests/legacy_1x/unit/websocket_mock_server_task_handler.py similarity index 98% rename from tests/unit/websocket_mock_server_task_handler.py rename to tests/legacy_1x/unit/websocket_mock_server_task_handler.py index 80743d18..f54f07ff 100644 --- a/tests/unit/websocket_mock_server_task_handler.py +++ b/tests/legacy_1x/unit/websocket_mock_server_task_handler.py @@ -6,8 +6,8 @@ import aiohttp -from dashscope.common.error import UnexpectedMessageReceived -from dashscope.protocol.websocket import ( +from dashscope.legacy_1x.common.error import UnexpectedMessageReceived +from dashscope.legacy_1x.protocol.websocket import ( ACTION_KEY, EVENT_KEY, ActionType, diff --git a/tests/unit/websocket_task_request.py b/tests/legacy_1x/unit/websocket_task_request.py similarity index 84% rename from tests/unit/websocket_task_request.py rename to tests/legacy_1x/unit/websocket_task_request.py index 00d1b1e2..78221c29 100644 --- a/tests/unit/websocket_task_request.py +++ b/tests/legacy_1x/unit/websocket_task_request.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from dashscope.api_entities.dashscope_response import DashScopeAPIResponse -from dashscope.client.base_api import BaseAioApi, BaseApi -from dashscope.common.constants import ApiProtocol -from dashscope.protocol.websocket import WebsocketStreamingMode +from dashscope.legacy_1x.api_entities.dashscope_response import ( + DashScopeAPIResponse, +) +from dashscope.legacy_1x.client.base_api import BaseAioApi, BaseApi +from dashscope.legacy_1x.common.constants import ApiProtocol +from dashscope.legacy_1x.protocol.websocket import WebsocketStreamingMode class WebSocketRequest(BaseApi, BaseAioApi): diff --git a/tests/v2/protocols/test_anthropic_conformance.py b/tests/protocols/test_anthropic_conformance.py similarity index 97% rename from tests/v2/protocols/test_anthropic_conformance.py rename to tests/protocols/test_anthropic_conformance.py index 4d86b073..666c9fa9 100644 --- a/tests/v2/protocols/test_anthropic_conformance.py +++ b/tests/protocols/test_anthropic_conformance.py @@ -28,8 +28,8 @@ import pytest from dashscope.acli.providers.anthropic import AnthropicProvider -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.protocols import AnthropicProtocol +from dashscope import AsyncClient, Client +from dashscope.protocols import AnthropicProtocol MODEL = "qwen3-max" @@ -253,9 +253,9 @@ async def test_public_tool_choice_translation_and_null_compatibility( tool_choice, expected, ): - monkeypatch.setattr("dashscope.v2._config._load_config_file", lambda _: {}) + monkeypatch.setattr("dashscope._config._load_config_file", lambda _: {}) monkeypatch.setattr( - "dashscope.v2._config._api_key_from_file", + "dashscope._config._api_key_from_file", lambda: None, ) via_parameters = entry_point != "named" diff --git a/tests/v2/protocols/test_integration_endpoints.py b/tests/protocols/test_integration_endpoints.py similarity index 95% rename from tests/v2/protocols/test_integration_endpoints.py rename to tests/protocols/test_integration_endpoints.py index c5aaa3b4..84405257 100644 --- a/tests/v2/protocols/test_integration_endpoints.py +++ b/tests/protocols/test_integration_endpoints.py @@ -11,9 +11,9 @@ gateway case targets any OpenAI-compatible endpoint via the explicit ``base_url`` escape hatch:: - DASHSCOPE_API_KEY=... DASHSCOPE_WORKSPACE=... pytest tests/v2/protocols/ + DASHSCOPE_API_KEY=... DASHSCOPE_WORKSPACE=... pytest tests/protocols/ GATEWAY_API_KEY=... GATEWAY_BASE_URL=... GATEWAY_MODEL=... \\ - pytest tests/v2/protocols/test_integration_endpoints.py + pytest tests/protocols/test_integration_endpoints.py """ from __future__ import annotations @@ -22,7 +22,7 @@ import pytest -from dashscope.v2 import Client +from dashscope import Client def _make_client(api_key, base_url): diff --git a/tests/v2/protocols/test_openai_conformance.py b/tests/protocols/test_openai_conformance.py similarity index 98% rename from tests/v2/protocols/test_openai_conformance.py rename to tests/protocols/test_openai_conformance.py index be0ea359..d6ccd6ca 100644 --- a/tests/v2/protocols/test_openai_conformance.py +++ b/tests/protocols/test_openai_conformance.py @@ -16,7 +16,7 @@ # pylint: disable=protected-access from dashscope.acli.providers.openai import OpenAIProvider -from dashscope.v2.protocols import OpenAIProtocol +from dashscope.protocols import OpenAIProtocol MODEL = "qwen-plus" diff --git a/tests/v2/smoke/test_examples.py b/tests/smoke/test_examples.py similarity index 97% rename from tests/v2/smoke/test_examples.py rename to tests/smoke/test_examples.py index af10f89f..af280e61 100644 --- a/tests/v2/smoke/test_examples.py +++ b/tests/smoke/test_examples.py @@ -18,7 +18,7 @@ import pytest -ROOT = Path(__file__).resolve().parents[3] +ROOT = Path(__file__).resolve().parents[2] EXAMPLES = ROOT / "examples" # Examples that run fully offline (mock transport / local subprocess), diff --git a/tests/v2/smoke/test_full_surface.py b/tests/smoke/test_full_surface.py similarity index 99% rename from tests/v2/smoke/test_full_surface.py rename to tests/smoke/test_full_surface.py index 7b69cbbe..b7ab6b24 100644 --- a/tests/v2/smoke/test_full_surface.py +++ b/tests/smoke/test_full_surface.py @@ -16,7 +16,7 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client +from dashscope import AsyncClient, Client BASE_URL = "https://api.test.local/api/v1" @@ -600,7 +600,7 @@ def handler(request): 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 + from dashscope.applications.agentstudio import Client as AgentStudioClient mounted = client.agentstudio assert isinstance(mounted, AgentStudioClient) diff --git a/tests/v2/test_agentstudio_errors.py b/tests/test_agentstudio_errors.py similarity index 54% rename from tests/v2/test_agentstudio_errors.py rename to tests/test_agentstudio_errors.py index 01840c0a..ee9d09f0 100644 --- a/tests/v2/test_agentstudio_errors.py +++ b/tests/test_agentstudio_errors.py @@ -1,15 +1,9 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""Tests for the AgentStudio -> v2 error-alignment proxy (M3 3.12). +"""AgentStudio raises v2 errors natively through its public API. -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 - ``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``. +Covers direct clients, lazy pagination, and SSE streams without a mount- +boundary translation layer. """ # Mock handlers intentionally ignore the request argument. @@ -23,17 +17,17 @@ 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.agentstudio.types import ServerEvent, Session -from dashscope.v2 import errors as v2_errors -from dashscope.v2.resources._agentstudio import ( - _wrap_result, - translate_agentstudio_error, - wrap_agentstudio, +from dashscope.applications.agentstudio import ( + AsyncClient as AsAgentStudioClient, +) +from dashscope.applications.agentstudio import Client as AgentStudioClient +from dashscope.applications.agentstudio import exceptions as as_exc +from dashscope.applications.agentstudio.pagination import ( + AsyncCursorPage, + CursorPage, ) +from dashscope.applications.agentstudio.types import ServerEvent, Session +from dashscope import errors as v2_errors BASE_URL = "https://as.test.local" @@ -50,79 +44,41 @@ def _clear_credentials(monkeypatch): # --------------------------------------------------------------------------- -# Two-branch translation, exercised directly (no network) +# Native v2 classes and catalog-backed local errors # --------------------------------------------------------------------------- -@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" +def test_legacy_names_alias_v2_classes(): + assert as_exc.AgentStudioError is v2_errors.DashScopeError + assert as_exc.InvalidRequestError is v2_errors.BadRequestError + assert as_exc.ConflictError is v2_errors.APIStatusError + assert as_exc.OverloadedError is v2_errors.ServiceUnavailableError @pytest.mark.parametrize( "as_cls,code", [ - (as_exc.APIConnectionError, "sdk.agentstudio.APIConnectionError"), - (as_exc.APITimeoutError, "sdk.agentstudio.APITimeoutError"), + (as_exc.APIConnectionError, "sdk.ConnectionError"), + (as_exc.APITimeoutError, "sdk.Timeout"), (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")) +def test_local_and_stream_errors_use_catalog_entries(as_cls, code): + err = 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 +# Transparent client over a real (mock-transport) client # --------------------------------------------------------------------------- -def _make_proxy(handler, **kwargs): +def _make_client(handler, **kwargs): kwargs.setdefault("max_retries", 0) - real = AgentStudioClient( + return AgentStudioClient( api_key="sk-test", base_url=BASE_URL, http_client=httpx.Client( @@ -131,19 +87,18 @@ def _make_proxy(handler, **kwargs): ), **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_client_exposes_native_resources(): + client = _make_client(lambda request: httpx.Response(200, json={})) + assert isinstance(client, AgentStudioClient) + assert hasattr(client, "agents") + assert hasattr(client, "sessions") + assert client.transport.base_url == BASE_URL + client.close() -def test_proxy_translates_http_error_and_is_catchable_as_v2(): +def test_client_translates_http_error_and_is_catchable_as_v2(): def handler(request): return httpx.Response( 404, @@ -157,43 +112,41 @@ def handler(request): }, ) - proxy = _make_proxy(handler) + client = _make_client(handler) with pytest.raises(v2_errors.DashScopeError) as ei: - proxy.sessions.retrieve("s-1") + client.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() + client.close() -def test_proxy_translates_rate_limit_error(): - proxy = _make_proxy(lambda request: httpx.Response(429, json={})) +def test_client_translates_rate_limit_error(): + client = _make_client(lambda request: httpx.Response(429, json={})) with pytest.raises(v2_errors.RateLimitError): - proxy.sessions.retrieve("s-1") - proxy.close() + client.sessions.retrieve("s-1") + client.close() -def test_proxy_translates_connection_error_to_sdk_error(): +def test_client_translates_connection_error_to_sdk_error(): def handler(request): raise httpx.ConnectError("boom") - proxy = _make_proxy(handler) + client = _make_client(handler) with pytest.raises(v2_errors.SdkError) as ei: - proxy.sessions.retrieve("s-1") - assert ei.value.code == "sdk.agentstudio.APIConnectionError" - proxy.close() + client.sessions.retrieve("s-1") + assert ei.value.code == "sdk.ConnectionError" + client.close() # --------------------------------------------------------------------------- -# Async proxy +# Async client # --------------------------------------------------------------------------- -def _make_async_proxy(handler, **kwargs): +def _make_async_client(handler, **kwargs): kwargs.setdefault("max_retries", 0) - real = AsAgentStudioClient( + return AsAgentStudioClient( api_key="sk-test", base_url=BASE_URL, http_client=httpx.AsyncClient( @@ -202,11 +155,10 @@ def _make_async_proxy(handler, **kwargs): ), **kwargs, ) - return wrap_agentstudio(real) @pytest.mark.asyncio -async def test_async_proxy_translates_http_error(): +async def test_async_client_translates_http_error(): def handler(request): return httpx.Response( 429, @@ -219,44 +171,16 @@ def handler(request): }, ) - proxy = _make_async_proxy(handler) - assert isinstance(proxy, AsAgentStudioClient) + client = _make_async_client(handler) + assert isinstance(client, 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" + await client.sessions.retrieve("s-1") + await client.aclose() @pytest.mark.parametrize("compat", [False, True]) @pytest.mark.parametrize("header_id", [None, "header-id"]) -def test_translation_preserves_v1_metadata(compat, header_id): +def test_from_response_preserves_metadata(compat, header_id): body = { "type": "error", "message": "top-level message must not replace the parsed message", @@ -267,105 +191,27 @@ def test_translation_preserves_v1_metadata(compat, header_id): }, } headers = {"x-request-id": header_id} if header_id else {} - original = as_exc.from_response( + err = 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 isinstance(err, v2_errors.RateLimitError) + assert err.request_id == (header_id or "body-id") + assert err.code == "rate_limit_error" + assert err.message == "canonical message" + assert err.body is body assert body["request_id"] == "body-id" -def test_agentstudio_code_does_not_override_http_status(): - original = as_exc.from_response( +def test_unknown_agentstudio_code_falls_back_to_http_status(): + err = 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() + assert isinstance(err, v2_errors.RateLimitError) + assert err.code == "InvalidApiKey" + assert err.body == {"error": {"code": "InvalidApiKey", "message": "quota"}} def _page_handler(requests, fail_at=None): @@ -406,10 +252,10 @@ async def test_pagination_lazily_translates_later_pages( fail_at, ): requests = [] - factory = _make_async_proxy if asynchronous else _make_proxy - proxy = factory(_page_handler(requests, fail_at)) + factory = _make_async_client if asynchronous else _make_client + client = factory(_page_handler(requests, fail_at)) try: - page = proxy.sessions.list(limit=1) + page = client.sessions.list(limit=1) if asynchronous: page = await page assert isinstance( @@ -444,12 +290,11 @@ async def test_pagination_lazily_translates_later_pages( 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() + await client.transport._client.aclose() else: - proxy.transport._client.close() + client.transport._client.close() @pytest.mark.parametrize("asynchronous", [False, True]) @@ -458,10 +303,10 @@ 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)) + factory = _make_async_client if asynchronous else _make_client + client = factory(_page_handler(requests)) try: - page = proxy.sessions.list(limit=1) + page = client.sessions.list(limit=1) if asynchronous: page = await page items = [item async for item in page] if asynchronous else list(page) @@ -481,9 +326,9 @@ async def test_pagination_success_preserves_models_and_mutability( assert not page finally: if asynchronous: - await proxy.transport._client.aclose() + await client.transport._client.aclose() else: - proxy.transport._client.close() + client.transport._client.close() _SSE_EVENTS = [ @@ -546,7 +391,7 @@ def _sse_client(asynchronous, error=None): headers={"content-type": "text/event-stream"}, stream=body, ) - factory = _make_async_proxy if asynchronous else _make_proxy + factory = _make_async_client if asynchronous else _make_client return factory(lambda request: response), response, body @@ -560,17 +405,17 @@ async def _consume_stream(stream, view, asynchronous): @pytest.mark.parametrize( "error_cls,code", [ - (httpx.ReadTimeout, "sdk.agentstudio.APITimeoutError"), - (httpx.ReadError, "sdk.agentstudio.APIConnectionError"), - (httpx.RemoteProtocolError, "sdk.agentstudio.APIConnectionError"), + (httpx.ReadTimeout, "sdk.Timeout"), + (httpx.ReadError, "sdk.ConnectionError"), + (httpx.RemoteProtocolError, "sdk.ConnectionError"), ], ) @pytest.mark.asyncio 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) + client, response, body = _sse_client(asynchronous, original) try: - stream = proxy.sessions.events.stream("s-1") + stream = client.sessions.events.stream("s-1") if asynchronous: stream = await stream assert not response.is_closed @@ -583,18 +428,18 @@ async def test_real_sse_transport_errors(asynchronous, view, error_cls, code): assert body.closed == 1 finally: if asynchronous: - await proxy.transport._client.aclose() + await client.transport._client.aclose() else: - proxy.transport._client.close() + client.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) + client, response, body = _sse_client(asynchronous) try: - stream = proxy.sessions.events.stream("s-1") + stream = client.sessions.events.stream("s-1") if asynchronous: stream = await stream await stream.aclose() @@ -607,12 +452,11 @@ async def test_real_sse_closed_views(asynchronous, view): 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() + await client.transport._client.aclose() else: - proxy.transport._client.close() + client.transport._client.close() @pytest.mark.parametrize("asynchronous", [False, True]) @@ -624,9 +468,9 @@ async def test_real_sse_normal_results_and_context_close( view, early_exit, ): - proxy, response, body = _sse_client(asynchronous) + client, response, body = _sse_client(asynchronous) try: - stream = proxy.sessions.events.stream("s-1") + stream = client.sessions.events.stream("s-1") if asynchronous: stream = await stream async with stream as entered: @@ -667,9 +511,9 @@ async def test_real_sse_normal_results_and_context_close( assert body.closed == 1 finally: if asynchronous: - await proxy.transport._client.aclose() + await client.transport._client.aclose() else: - proxy.transport._client.close() + client.transport._client.close() @pytest.mark.parametrize("asynchronous", [False, True]) @@ -683,9 +527,9 @@ async def test_real_sse_programming_errors_are_not_translated( error_cls, ): original = error_cls("programming error") - proxy, response, body = _sse_client(asynchronous, original) + client, response, body = _sse_client(asynchronous, original) try: - stream = proxy.sessions.events.stream("s-1") + stream = client.sessions.events.stream("s-1") if asynchronous: stream = await stream with pytest.raises(error_cls) as caught: @@ -695,9 +539,9 @@ async def test_real_sse_programming_errors_are_not_translated( assert body.closed == 1 finally: if asynchronous: - await proxy.transport._client.aclose() + await client.transport._client.aclose() else: - proxy.transport._client.close() + client.transport._client.close() @pytest.mark.asyncio @@ -716,9 +560,9 @@ async def __aiter__(self): headers={"content-type": "text/event-stream"}, stream=body, ) - proxy = _make_async_proxy(lambda request: response) + client = _make_async_client(lambda request: response) try: - stream = await proxy.sessions.events.stream("s-1") + stream = await client.sessions.events.stream("s-1") async with stream: task = asyncio.create_task( _consume_stream(stream, "text_stream", True), @@ -730,28 +574,4 @@ async def __aiter__(self): assert response.is_closed assert body.closed == 1 finally: - await proxy.transport._client.aclose() - - -@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() + await client.transport._client.aclose() diff --git a/tests/unit/test_agentstudio_models.py b/tests/test_agentstudio_models.py similarity index 100% rename from tests/unit/test_agentstudio_models.py rename to tests/test_agentstudio_models.py diff --git a/tests/unit/test_agentstudio_protocol.py b/tests/test_agentstudio_protocol.py similarity index 100% rename from tests/unit/test_agentstudio_protocol.py rename to tests/test_agentstudio_protocol.py diff --git a/tests/test_agentstudio_shim.py b/tests/test_agentstudio_shim.py new file mode 100644 index 00000000..9a2897a3 --- /dev/null +++ b/tests/test_agentstudio_shim.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for AgentStudio's shared core and two public facades.""" + +from __future__ import annotations + +import importlib +import sys + +import pytest + +import dashscope.agentstudio as impl +from dashscope.applications import agentstudio as v2_impl +from dashscope import errors as sdk_errors +from dashscope._shared import _sdk_ident, _validation +from dashscope.agentstudio.transport import build_headers +from dashscope.applications.agentstudio.transport import ( + build_headers as build_v2_headers, +) +from dashscope.legacy_1x.common import error as legacy_error +from dashscope.legacy_1x.common import env as legacy_env +from dashscope.legacy_1x.common import utils as legacy_utils +from dashscope.agentstudio import client as legacy_client +from dashscope.agentstudio import exceptions as impl_exc +from dashscope.applications.agentstudio import client as as_client +from dashscope.applications.agentstudio import exceptions as v2_exc + +BASE_URL = "https://as.test.local" + +# Deep paths the ecosystem relies on: the unit tests, the examples and the +# acli skill docs all import these directly. +SUBMODULES = [ + "client", + "constants", + "exceptions", + "pagination", + "streaming", + "transport", + "resources", + "resources._helpers", + "resources.agents", + "resources.deployments", + "resources.environments", + "resources.files", + "resources.memory_stores", + "resources.security", + "resources.session_events", + "resources.session_resources", + "resources.session_threads", + "resources.sessions", + "resources.skills", + "resources.vaults", + "resources.webhook_endpoints", + "types", + "types.models", + "types.params", +] + + +def _make_client(**kwargs): + kwargs.setdefault("api_key", "sk-test") + kwargs.setdefault("base_url", BASE_URL) + return impl.Client(**kwargs) + + +@pytest.fixture +def _clean_env(monkeypatch): + """Keep ambient env vars from changing base-url resolution.""" + + for var in ( + "DASHSCOPE_WORKSPACE", + "DASHSCOPE_AGENTSTUDIO_URL", + "AGENTSTUDIO_URL", + ): + monkeypatch.delenv(var, raising=False) + + +# --------------------------------------------------------------------------- +# The public package owns the only implementation +# --------------------------------------------------------------------------- + + +def test_public_names_are_exported(): + assert impl.__all__ + for name in impl.__all__: + assert getattr(impl, name) is not None, name + + +def test_client_classes_are_versioned_facades(): + assert impl.Client.__module__ == "dashscope.legacy_1x.agentstudio.client" + assert ( + impl.AsyncClient.__module__ == "dashscope.legacy_1x.agentstudio.client" + ) + assert ( + v2_impl.Client.__module__ + == "dashscope.applications.agentstudio.client" + ) + assert ( + v2_impl.AsyncClient.__module__ + == "dashscope.applications.agentstudio.client" + ) + + +def test_exception_families_are_versioned(): + assert impl_exc.AgentStudioError is not sdk_errors.DashScopeError + assert issubclass( + impl_exc.AgentStudioError, + legacy_error.DashScopeException, + ) + assert v2_exc.AgentStudioError is sdk_errors.DashScopeError + assert v2_exc.AuthenticationError is sdk_errors.AuthenticationError + + +@pytest.mark.parametrize("name", SUBMODULES) +def test_deep_imports_resolve_to_facade_or_shared_core(name): + module_name = f"dashscope.agentstudio.{name}" + facade_modules = {"client", "exceptions", "streaming", "transport"} + root = ( + "dashscope.legacy_1x.agentstudio" + if name in facade_modules + else "dashscope.applications.agentstudio" + ) + assert importlib.import_module(module_name) is importlib.import_module( + f"{root}.{name}", + ) + + +@pytest.mark.parametrize("name", SUBMODULES) +def test_submodules_are_reachable_as_attributes(name): + """sys.modules alone is not enough -- attribute access must work too.""" + + __import__("dashscope.agentstudio") + obj = impl + for part in name.split("."): + obj = getattr(obj, part) + assert obj is sys.modules[f"dashscope.agentstudio.{name}"] + + +def test_deep_import_from_syntax_resolves(): + from dashscope.agentstudio.resources.agents import Agents + from dashscope.agentstudio.types.models import Agent + + assert Agents.__module__ == ( + "dashscope.applications.agentstudio.resources.agents" + ) + assert ( + Agent.__module__ == "dashscope.applications.agentstudio.types.models" + ) + + +# --------------------------------------------------------------------------- +# The neutral layer holds the singletons AgentStudio depends on +# --------------------------------------------------------------------------- + + +def test_both_agentstudio_error_bases_remain_catchable(): + assert issubclass( + impl_exc.AgentStudioError, + legacy_error.DashScopeException, + ) + assert issubclass(v2_exc.AgentStudioError, sdk_errors.DashScopeError) + + +def test_validators_are_shared_and_still_reject_host_breakouts(): + assert legacy_env.validate_region is _validation.validate_region + assert ( + legacy_env.validate_workspace_id is _validation.validate_workspace_id + ) + # These values would move the effective host off *.maas.aliyuncs.com + # while the transport still sends Authorization: Bearer . + breakout = ("a@evil.com/#", "ws/evil", "ws evil", "", "-lead") + for bad in breakout + ("x" * 65,): + with pytest.raises(ValueError): + _validation.validate_workspace_id(bad) + for bad in breakout + ("x" * 64, "trail-"): + with pytest.raises(ValueError): + _validation.validate_region(bad) + + +def test_workspace_and_region_still_build_a_maas_host(_clean_env): + client = impl.Client( + api_key="sk-test", + workspace="ws-demo", + region="cn-hangzhou", + ) + assert client.transport.base_url == ( + "https://ws-demo.cn-hangzhou.maas.aliyuncs.com/api/v1/agentstudio" + ) + + +def test_untrusted_workspace_is_rejected_before_the_host_is_built(_clean_env): + with pytest.raises(ValueError): + impl.Client(api_key="sk-test", workspace="a@evil.com/#") + + +# --------------------------------------------------------------------------- +# The unmarked module tag, and the CLI's process-wide override +# --------------------------------------------------------------------------- + + +def test_agentstudio_tag_carries_no_v2_marker(monkeypatch): + monkeypatch.setattr(_sdk_ident, "_SDK_CLIENT", "python-sdk") + value = build_headers( + api_key="sk-test", + uid=None, + user_agent="ua", + )["x-dashscope-sdk-client"] + assert value.endswith("/agentstudio") + assert not value.endswith("agentstudio2") + + +def test_cli_sdk_client_override_reaches_agentstudio(monkeypatch): + """``_SDK_CLIENT`` is process-wide mutable state: one copy only.""" + + monkeypatch.setattr(_sdk_ident, "_SDK_CLIENT", "python-cli") + value = build_headers( + api_key="sk-test", + uid=None, + user_agent="ua", + )["x-dashscope-sdk-client"] + assert value.split("/")[0] == "python-cli" + # the 1.x re-export reads the very same state + assert ( + legacy_utils.get_sdk_headers(module="agentstudio")[ + "x-dashscope-sdk-client" + ] + == value + ) + + +def test_session_id_is_shared_with_the_v2_transport(): + from dashscope.transport._ident import sdk_headers + + assert ( + build_headers( + api_key="sk-test", + uid=None, + user_agent="ua", + )["x-dashscope-sdk-session-id"] + == sdk_headers("aigc")["x-dashscope-sdk-session-id"] + ) + + +# --------------------------------------------------------------------------- +# 2.0 API-key resolution is independent of the historical facade +# --------------------------------------------------------------------------- + + +def test_legacy_client_uses_the_historical_key_resolver(monkeypatch): + monkeypatch.setattr(legacy_client, "get_default_api_key", lambda: "sk-old") + client = impl.Client(base_url=BASE_URL) + assert client.transport.api_key == "sk-old" + client.close() + + +@pytest.fixture +def _no_key_anywhere(monkeypatch): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.setattr(as_client, "_api_key_from_file", lambda: None) + monkeypatch.setattr(as_client.settings, "api_key", None) + + +def test_api_key_prefers_settings_over_the_key_file(monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-env") + monkeypatch.setattr(as_client, "_api_key_from_file", lambda: "sk-file") + assert as_client._default_api_key() == "sk-env" + + +def test_api_key_falls_back_to_the_key_file(monkeypatch): + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.setattr(as_client, "_api_key_from_file", lambda: "sk-file") + monkeypatch.setattr(as_client.settings, "api_key", None) + assert as_client._default_api_key() == "sk-file" + + +def test_missing_api_key_resolves_to_none(_no_key_anywhere): + assert as_client._default_api_key() is None + + +def test_missing_api_key_surfaces_at_request_time(_no_key_anywhere): + """Construction succeeds; the transport raises the native error.""" + + client = v2_impl.Client(base_url=BASE_URL) + assert client.transport.api_key is None + with pytest.raises(v2_exc.AuthenticationError): + build_v2_headers( + api_key=client.transport.api_key, + uid=None, + user_agent="ua", + ) diff --git a/tests/v2/test_apps.py b/tests/test_apps.py similarity index 90% rename from tests/v2/test_apps.py rename to tests/test_apps.py index f5c08a87..3cc6455b 100644 --- a/tests/v2/test_apps.py +++ b/tests/test_apps.py @@ -12,8 +12,8 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import RateLimitError +from dashscope import AsyncClient, Client +from dashscope.errors import RateLimitError BASE_URL = "https://api.test.local/api/v1" @@ -183,43 +183,39 @@ def handler(request): def test_agentstudio_mounted_lazily(): - from dashscope.agentstudio import Client as AgentStudioClient + from dashscope.applications.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 type(mounted) is 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" - ) + # An explicit main base URL is propagated to the AgentStudio service root. + assert mounted.transport.base_url == f"{BASE_URL}/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. + # Explicit base_url wins over workspace/region, as on every v2 resource. 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" - ) + assert mounted.transport.base_url == f"{BASE_URL}/agentstudio" mounted.close() @pytest.mark.asyncio async def test_async_agentstudio_mount_follows_region(): - from dashscope.agentstudio import AsyncClient as AsAgentStudioClient + from dashscope.applications.agentstudio import ( + AsyncClient as AsAgentStudioClient, + ) client = _make_async_client( lambda request: httpx.Response(200, json={}), @@ -227,13 +223,26 @@ async def test_async_agentstudio_mount_follows_region(): 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" - ) + assert type(mounted) is AsAgentStudioClient + assert mounted.transport.base_url == f"{BASE_URL}/agentstudio" await mounted.aclose() +def test_agentstudio_mount_shares_owned_pool_and_client_options(): + client = Client( + api_key="sk-test", + base_url=BASE_URL, + timeout=9.0, + max_retries=4, + ) + mounted = client.agentstudio + assert mounted.transport._client is client.transport.http_client + assert mounted.transport.timeout == 9.0 + assert mounted.transport.max_retries == 4 + client.close() + assert mounted.transport.is_closed is True + + @pytest.mark.asyncio async def test_async_create_non_streaming(): def handler(request): @@ -306,7 +315,7 @@ def handler(request): assert chunks[-1].code == "Throttling" -def test_close_closes_mounted_agentstudio(): +def test_close_preserves_caller_owned_mounted_pool(): client = _make_client( lambda request: httpx.Response(200, json={}), workspace="ws-1", @@ -314,12 +323,13 @@ def test_close_closes_mounted_agentstudio(): mounted = client.agentstudio assert mounted.transport.is_closed is False client.close() - assert mounted.transport.is_closed is True + assert mounted.transport.is_closed is False assert client._agentstudio is None # pylint: disable=protected-access + client.transport.http_client.close() @pytest.mark.asyncio -async def test_aclose_closes_mounted_agentstudio(): +async def test_aclose_preserves_caller_owned_mounted_pool(): client = _make_async_client( lambda request: httpx.Response(200, json={}), workspace="ws-1", @@ -327,8 +337,9 @@ async def test_aclose_closes_mounted_agentstudio(): mounted = client.agentstudio assert mounted.transport.is_closed is False await client.aclose() - assert mounted.transport.is_closed is True + assert mounted.transport.is_closed is False assert client._agentstudio is None # pylint: disable=protected-access + await client.transport.http_client.aclose() @pytest.mark.asyncio diff --git a/tests/v2/test_audio.py b/tests/test_audio.py similarity index 98% rename from tests/v2/test_audio.py rename to tests/test_audio.py index e918f513..fecfa1a9 100644 --- a/tests/v2/test_audio.py +++ b/tests/test_audio.py @@ -12,12 +12,17 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -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 +from dashscope import AsyncClient, Client +from dashscope.errors import ( + APITimeoutError, + APIConnectionError, + DashScopeError, + WsStateError, +) +from dashscope.resources import audio as audio_resources +from dashscope.resources.audio._callbacks import run_with_callback +from dashscope.resources.audio.asr import RecognitionResult +from dashscope.transport.ws import WsEventError BASE_URL = "https://api.test.local/api/v1" TRANSCRIPTION_PATH = "/api/v1/services/audio/asr/transcription" @@ -463,9 +468,9 @@ def connector(url, headers): rec.send_audio_frame(b"second") rec.stop() rec.stop() - with pytest.raises(RuntimeError, match="input is finished"): + with pytest.raises(WsStateError, match="input is finished"): rec.send_audio_frame(b"late") - with pytest.raises(RuntimeError, match="input is finished"): + with pytest.raises(WsStateError, match="input is finished"): rec.update_context(context) assert next(events)["header"]["event"] == "task-finished" assert list(events) == [] @@ -522,7 +527,7 @@ def recv(self): model="fun-asr-realtime", connector=lambda url, headers: ws, ) - with pytest.raises(WebSocketTimeoutException): + with pytest.raises(APITimeoutError): with rec: next(rec.events()) assert ws.closed @@ -543,7 +548,7 @@ def send(self, payload, opcode=1): with pytest.raises(OSError, match="send failed"): rec.send_audio_frame(b"audio") assert ws.closed - with pytest.raises(RuntimeError): + with pytest.raises(WsStateError): rec.send_audio_frame(b"more") diff --git a/tests/v2/test_audio_connection.py b/tests/test_audio_connection.py similarity index 97% rename from tests/v2/test_audio_connection.py rename to tests/test_audio_connection.py index d8d962d4..d7749ec4 100644 --- a/tests/v2/test_audio_connection.py +++ b/tests/test_audio_connection.py @@ -9,8 +9,12 @@ import pytest -from dashscope.v2.client import Client -from dashscope.v2.errors import DashScopeError +from dashscope.client import Client +from dashscope.errors import ( + APITimeoutError, + DashScopeError, + WsBinaryOwnerConflictError, +) BASE_URL = "https://api.test.local/api/v1" WS_URL = "wss://ws.test.local/api-ws/v1/inference" @@ -269,7 +273,7 @@ def test_two_syntheses_on_one_connection_fail_fast(): 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"): + with pytest.raises(WsBinaryOwnerConflictError, match="binary downlink"): tts2.start() # The first session is unaffected. tts1.send_text("继续") @@ -432,7 +436,7 @@ def send(self, payload, opcode=1): timeout=0.05, ) try: - expected = TimeoutError if reply == "timeout" else DashScopeError + expected = APITimeoutError if reply == "timeout" else DashScopeError with pytest.raises(expected): rec.start() assert not conn._tasks @@ -502,7 +506,7 @@ def test_shared_recognition_close_after_uplink_conflict_does_not_break_owner( first.start() second.start() first.send_audio_frame(b"first") - with pytest.raises(RuntimeError, match="binary uplink"): + with pytest.raises(WsBinaryOwnerConflictError, match="binary uplink"): second.send_audio_frame(b"blocked") second.close() first.send_audio_frame(b"still first") diff --git a/tests/v2/test_audio_management.py b/tests/test_audio_management.py similarity index 88% rename from tests/v2/test_audio_management.py rename to tests/test_audio_management.py index f51386b3..844ac8c2 100644 --- a/tests/v2/test_audio_management.py +++ b/tests/test_audio_management.py @@ -4,7 +4,8 @@ ``client.audio.vocabularies`` / ``phrases`` / ``voices`` / ``voice_design`` -- 1.x ``VocabularyService`` / ``AsrPhraseManager`` / -``VoiceEnrollmentService`` plus the qwen voice-design REST family. +``VoiceEnrollmentService`` plus prompt-created voice design, which shares +the voice-enrollment registry with cloning. """ # Mock handlers intentionally ignore the request argument. @@ -17,8 +18,8 @@ import httpx import pytest -from dashscope.v2 import Client -from dashscope.v2.errors import ( +from dashscope import Client +from dashscope.errors import ( APIConnectionError, APIStatusError, APITimeoutError, @@ -227,7 +228,19 @@ def handler(request): assert all(call["model"] == "voice-enrollment" for call in calls) -# ── voice design (声音设计, qwen-voice-design) ───────────────────────── +# ── voice design (voice-enrollment registry, prompt-created) ────────── + + +# Mirrors the "查询音色详情" response in the voice-design API reference. +_DESIGNED_VOICE = { + "voice_id": "cosyvoice-v3.5-plus-vd-announcer-xxxxxx", + "gmt_create": "2025-12-10 14:54:09", + "gmt_modified": "2025-12-10 17:47:48", + "status": "OK", + "target_model": "cosyvoice-v3.5-plus", + "voice_prompt": "沉稳的中年男性播音员,音色低沉浑厚", + "preview_text": "各位听众朋友们大家好,欢迎收听本期节目", +} def test_voice_design_crud(): @@ -237,12 +250,14 @@ def handler(request): body = json.loads(request.content.decode()) calls.append(body) action = body["input"]["action"] - if action == "create": + if action == "create_voice": return httpx.Response( 200, json={ + "request_id": "req-design", "output": { - "voice": "announcer", + "voice_id": "cosyvoice-v3.5-plus-vd-announcer-xxxxxx", + "target_model": "cosyvoice-v3.5-plus", "preview_audio": { "data": "QUJD", "sample_rate": 24000, @@ -252,72 +267,77 @@ def handler(request): "usage": {"count": 1}, }, ) - if action == "list": - return httpx.Response( - 200, - json={ - "output": { - "voice_list": [ - {"voice": "announcer", "language": "zh"}, - ], - "total_count": 1, - }, - }, - ) - if action == "query": + if action == "list_voice": return httpx.Response( 200, - json={ - "output": {"voice": "announcer", "language": "zh"}, - }, + json={"output": {"voice_list": [_DESIGNED_VOICE]}}, ) + if action == "query_voice": + return httpx.Response(200, json={"output": _DESIGNED_VOICE}) return httpx.Response( 200, - json={"output": {"voice": "announcer"}}, + json={"output": {}, "usage": {"count": 1}}, ) 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", + target_model="cosyvoice-v3.5-plus", + prefix="announcer", + voice_prompt="沉稳的中年男性播音员,音色低沉浑厚", + preview_text="各位听众朋友们大家好,欢迎收听本期节目", + language_hints=["zh"], parameters={"sample_rate": 24000, "response_format": "wav"}, + extra_input={"prefix": "evil", "extra": 1}, ) - assert created.voice == "announcer" + assert created.voice_id == "cosyvoice-v3.5-plus-vd-announcer-xxxxxx" + assert created.request_id == "req-design" # The mock always returns a dict; pylint only sees Optional[Dict]. # pylint: disable-next=unsubscriptable-object assert created.preview_audio["response_format"] == "wav" + + # Named arguments win over the extra_input escape hatch. assert calls[0] == { - "model": "qwen-voice-design", + "model": "voice-enrollment", "input": { - "action": "create", - "target_model": "qwen3-tts-vd-realtime-2026-01-15", - "voice_prompt": "沉稳的中年男性,音色低沉浑厚", - "preview_text": "各位听众朋友,大家好", - "preferred_name": "announcer", - "language": "zh", + "action": "create_voice", + "extra": 1, + "target_model": "cosyvoice-v3.5-plus", + "prefix": "announcer", + "voice_prompt": "沉稳的中年男性播音员,音色低沉浑厚", + "preview_text": "各位听众朋友们大家好,欢迎收听本期节目", + "language_hints": ["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 + listed = client.audio.voice_design.list(prefix="announcer") + assert listed.voices == [_DESIGNED_VOICE] assert calls[1]["input"] == { - "action": "list", + "action": "list_voice", "page_index": 0, "page_size": 10, + "prefix": "announcer", } - fetched = client.audio.voice_design.get("announcer") - assert fetched.language == "zh" - assert calls[2]["input"] == {"action": "query", "voice": "announcer"} + fetched = client.audio.voice_design.get( + "cosyvoice-v3.5-plus-vd-announcer-xxxxxx", + ) + assert fetched.status == "OK" + assert fetched.voice_prompt == "沉稳的中年男性播音员,音色低沉浑厚" + assert fetched.gmt_create == "2025-12-10 14:54:09" + assert calls[2]["input"] == { + "action": "query_voice", + "voice_id": "cosyvoice-v3.5-plus-vd-announcer-xxxxxx", + } - 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) + client.audio.voice_design.delete( + "cosyvoice-v3.5-plus-vd-announcer-xxxxxx", + ) + assert calls[3]["input"] == { + "action": "delete_voice", + "voice_id": "cosyvoice-v3.5-plus-vd-announcer-xxxxxx", + } + assert all(call["model"] == "voice-enrollment" for call in calls) # ── phrases (1.x AsrPhraseManager, fine-tunes rider) ────────────────── @@ -514,7 +534,8 @@ def test_management_all_actions_keep_real_response_headers(resource, method): "url": "https://cdn.test/audio.wav", }, "voice_design": { - "target_model": "qwen-tts-vd", + "target_model": "cosyvoice-v3.5-plus", + "prefix": "test", "voice_prompt": "warm", "preview_text": "hello", }, @@ -550,7 +571,7 @@ def test_management_all_actions_keep_real_response_headers(resource, method): for key in ("vocabularies", "vocabulary_list") ] + [ - (resource, key, "voices", {"voice": "voice-1", "enabled": False}) + (resource, key, "voices", {"voice_id": "voice-1", "status": "OK"}) for resource in ("voices", "voice_design") for key in ("voices", "voice_list") ] @@ -685,9 +706,13 @@ def test_management_malformed_json_is_not_suppressed(resource, raise_on_error): 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): + # Compatibility mode suppresses service errors, not response-contract + # failures; malformed JSON uses the catalog internal_error. + with pytest.raises(InternalServerError) as caught: getattr(client.audio, resource).list() + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert isinstance(caught.value.__cause__, ValueError) @pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) @@ -810,7 +835,7 @@ def handler(request): client = _make_client(handler) voice_extra = {"language_hints": ["en"], "max_prompt_audio_length": 10} - design_extra = {"language": "en", "preferred_name": "original"} + design_extra = {"language_hints": ["en"], "prefix": "original"} original = json.loads(json.dumps([voice_extra, design_extra])) parameters = {} if explicit else None client.audio.voices.create( @@ -823,11 +848,11 @@ def handler(request): parameters=parameters, ) client.audio.voice_design.create( - target_model="qwen3-tts-vd-realtime-2026-01-15", + target_model="cosyvoice-v3.5-plus", + prefix="demo", voice_prompt="低沉", preview_text="你好", - language="" if explicit else None, - preferred_name="" if explicit else None, + language_hints=[] if explicit else None, extra_input=design_extra, parameters=parameters, ) @@ -835,10 +860,8 @@ def handler(request): 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 bodies[1]["input"]["language_hints"] == ([] if explicit else ["en"]) + assert bodies[1]["input"]["prefix"] == "demo" assert [voice_extra, design_extra] == original for body in bodies: if explicit: @@ -863,16 +886,17 @@ def handler(request): 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", + target_model="cosyvoice-v3.5-plus", + prefix="demo", voice_prompt="低沉", preview_text="你好", - extra_input={"action": "delete"}, + extra_input={"action": "delete_voice"}, ) - expected_action = "create" - assert bodies[0]["input"]["action"] == expected_action + # Both families post the same action word, so neither may let the + # caller's escape hatch rewrite it into a delete. + assert bodies[0]["input"]["action"] == "create_voice" @pytest.mark.parametrize("resource", _MANAGEMENT_RESOURCES) @@ -908,11 +932,9 @@ def handler(request): 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["authorization"] == "Bearer sk-test" assert requests[1].headers["x-dashscope-workspace"] == "pool-ws" - assert ( - requests[1].extensions["timeout"]["read"] == http_client.timeout.read - ) + assert requests[1].extensions["timeout"]["read"] == client.config.timeout assert all(request.url.host == "api.test.local" for request in requests) assert headers == original diff --git a/tests/v2/test_audio_omni_session.py b/tests/test_audio_omni_session.py similarity index 99% rename from tests/v2/test_audio_omni_session.py rename to tests/test_audio_omni_session.py index 380c46b2..5979be0f 100644 --- a/tests/v2/test_audio_omni_session.py +++ b/tests/test_audio_omni_session.py @@ -15,14 +15,15 @@ WebSocketTimeoutException, ) -from dashscope.v2 import Client -from dashscope.v2.errors import ( +from dashscope import Client +from dashscope.errors import ( + APITimeoutError, APIConnectionError, BadRequestError, RateLimitError, ) -from dashscope.v2.resources.audio import realtime -from dashscope.v2.transport._ident import sdk_headers +from dashscope.resources.audio import realtime +from dashscope.transport._ident import sdk_headers class FakeWebSocket: @@ -555,9 +556,9 @@ def create_connection(url, *, header, timeout): monkeypatch.setattr("websocket.create_connection", create_connection) session = client.audio.realtime.connect(model="omni") - with pytest.raises(WebSocketTimeoutException) as caught: + with pytest.raises(APITimeoutError) as caught: session.__enter__() - assert caught.value is failure + assert caught.value.__cause__ is failure with pytest.raises(RuntimeError, match="not open"): session.recv_event() with session: @@ -757,9 +758,15 @@ def test_recv_transport_failure_closes_and_propagates( session.end_session() ws.inbox.append(failure) ws.close_error = OSError("cleanup failed") - with pytest.raises(type(failure)) as caught: + expected = ( + APITimeoutError + if isinstance(failure, WebSocketTimeoutException) + else type(failure) + ) + with pytest.raises(expected) as caught: session.recv_event() - assert caught.value is failure + if expected is not APITimeoutError: + assert caught.value is failure assert ws.close_calls == 1 diff --git a/tests/v2/test_audio_samples.py b/tests/test_audio_samples.py similarity index 99% rename from tests/v2/test_audio_samples.py rename to tests/test_audio_samples.py index d4d30f21..d4f7f419 100644 --- a/tests/v2/test_audio_samples.py +++ b/tests/test_audio_samples.py @@ -16,7 +16,7 @@ import pytest from dashscope import Client -from dashscope.v2.errors import DashScopeError +from dashscope.errors import DashScopeError def _sample(name): diff --git a/tests/v2/test_audio_samples_direct_call.py b/tests/test_audio_samples_direct_call.py similarity index 99% rename from tests/v2/test_audio_samples_direct_call.py rename to tests/test_audio_samples_direct_call.py index 88485eb9..f813d4fd 100644 --- a/tests/v2/test_audio_samples_direct_call.py +++ b/tests/test_audio_samples_direct_call.py @@ -35,7 +35,7 @@ def _direct(name): SAMPLES = ( - Path(__file__).resolve().parents[2] + Path(__file__).resolve().parents[1] / "examples" / "01_atomic_capabilities" / "audio" diff --git a/tests/v2/test_audio_tts_http.py b/tests/test_audio_tts_http.py similarity index 95% rename from tests/v2/test_audio_tts_http.py rename to tests/test_audio_tts_http.py index 8737a3ea..670a7e2d 100644 --- a/tests/v2/test_audio_tts_http.py +++ b/tests/test_audio_tts_http.py @@ -13,8 +13,8 @@ import httpx import pytest -from dashscope.v2 import Client -from dashscope.v2.errors import ( +from dashscope import AsyncClient, Client +from dashscope.errors import ( APIConnectionError, APIStatusError, APITimeoutError, @@ -34,6 +34,13 @@ def _make_client(handler, **kwargs): 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 _b64(payload: bytes) -> str: return base64.b64encode(payload).decode() @@ -281,6 +288,29 @@ def handler(request): next(stream) +@pytest.mark.asyncio +async def test_async_http_stream_json_envelope_raises(): + # A 200 application/json body is a service failure, not audio SSE. + # The async stream must surface it (parity with the sync path) rather + # than silently yielding zero chunks. + def handler(request): + return httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + json={"code": "InvalidParameter", "message": "bad voice"}, + ) + + client = _make_async_client(handler) + stream = await client.audio.tts.http.stream( + "你好", + model="qwen-audio-3.1-tts-flash", + voice="nobody", + ) + with pytest.raises(BadRequestError, match="bad voice"): + async for _ in stream: + pass + + def test_http_synthesize_error_status_raises(): def handler(request): return httpx.Response( @@ -466,8 +496,11 @@ def test_http_malformed_json_is_not_suppressed(raise_on_error): lambda request: httpx.Response(200, content=b'{"output":'), raise_on_error=raise_on_error, ) - with pytest.raises(ValueError): + with pytest.raises(InternalServerError) as caught: client.audio.tts.http.synthesize("你好", **_TTS_ARGS) + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert isinstance(caught.value.__cause__, ValueError) @pytest.mark.parametrize("method", ["synthesize", "stream"]) @@ -628,8 +661,10 @@ def test_http_stream_malformed_json_response_raises_and_closes( headers={"content-type": "application/json"}, raise_on_error=raise_on_error, ) - with pytest.raises(ValueError): + with pytest.raises(InternalServerError) as caught: list(client.audio.tts.http.stream("你好", **_TTS_ARGS)) + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 assert wire.closed and response.is_closed @@ -731,7 +766,7 @@ def test_http_stream_close_before_first_chunk_releases_response( def test_http_stream_close_failure_still_releases_response(): - from dashscope.v2.resources.audio.http_tts import ( + from dashscope.resources.audio.http_tts import ( HttpSynthesisChunk, HttpSynthesisStream, ) @@ -847,11 +882,9 @@ def handler(request): 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["authorization"] == "Bearer sk-test" assert requests[1].headers["x-dashscope-workspace"] == "pool-ws" - assert ( - requests[1].extensions["timeout"]["read"] == http_client.timeout.read - ) + assert requests[1].extensions["timeout"]["read"] == client.config.timeout 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 diff --git a/tests/v2/test_audio_tts_realtime.py b/tests/test_audio_tts_realtime.py similarity index 98% rename from tests/v2/test_audio_tts_realtime.py rename to tests/test_audio_tts_realtime.py index fa29637a..f1a5fe76 100644 --- a/tests/v2/test_audio_tts_realtime.py +++ b/tests/test_audio_tts_realtime.py @@ -15,13 +15,14 @@ WebSocketTimeoutException, ) -from dashscope.v2 import Client -from dashscope.v2.errors import ( +from dashscope import Client +from dashscope.errors import ( + APITimeoutError, BadRequestError, DashScopeError, RateLimitError, ) -from dashscope.v2.resources.audio._callbacks import run_with_callback +from dashscope.resources.audio._callbacks import run_with_callback def _make_client(**kwargs): @@ -504,5 +505,5 @@ def test_realtime_recv_raises_on_transport_error(): model="qwen-omni-turbo-realtime", connector=lambda url, headers: ws, ) as rt: - with pytest.raises(WebSocketTimeoutException): + with pytest.raises(APITimeoutError): rt.recv_event() diff --git a/tests/v2/test_audio_tts_session.py b/tests/test_audio_tts_session.py similarity index 98% rename from tests/v2/test_audio_tts_session.py rename to tests/test_audio_tts_session.py index cafdebd3..5ae253a7 100644 --- a/tests/v2/test_audio_tts_session.py +++ b/tests/test_audio_tts_session.py @@ -14,9 +14,13 @@ import pytest -from dashscope.v2 import Client -from dashscope.v2.errors import APIConnectionError, BadRequestError -from dashscope.v2.resources.audio.tts import SynthesisSession +from dashscope import Client +from dashscope.errors import ( + APIConnectionError, + BadRequestError, + WsStateError, +) +from dashscope.resources.audio.tts import SynthesisSession def _event(name, payload=None, task_id="t"): @@ -99,7 +103,7 @@ def test_multiple_text_flush_stop_and_raw_events(client, monkeypatch): ws = FakeWebSocket([started, result, b"first", b"second", finished]) clock = iter([10.0, 10.25]) monkeypatch.setattr( - "dashscope.v2.resources.audio.tts.time.monotonic", + "dashscope.resources.audio.tts.time.monotonic", lambda: next(clock), ) session = _session(client, ws) @@ -278,7 +282,7 @@ def test_no_more_text_or_flush_after_input_ends(client, ending, operation): with _session(client, ws) as session: getattr(session, ending)() sent_count = len(ws.sent) - with pytest.raises(RuntimeError): + with pytest.raises(WsStateError): if operation == "send_text": session.send_text("too late") else: @@ -468,7 +472,7 @@ def interrupted_run(*args, **kwargs): raise error monkeypatch.setattr( - "dashscope.v2.resources.audio.tts.WsSession.run_task", + "dashscope.resources.audio.tts.WsSession.run_task", interrupted_run, ) try: diff --git a/tests/v2/test_audio_ws_integration.py b/tests/test_audio_ws_integration.py similarity index 99% rename from tests/v2/test_audio_ws_integration.py rename to tests/test_audio_ws_integration.py index fcdf9fda..42f9bf6a 100644 --- a/tests/v2/test_audio_ws_integration.py +++ b/tests/test_audio_ws_integration.py @@ -17,13 +17,14 @@ from aiohttp import WSMsgType, web from websocket import WebSocketConnectionClosedException -from dashscope.v2 import Client -from dashscope.v2.errors import ( +from dashscope import Client +from dashscope.errors import ( + APITimeoutError, APIConnectionError, BadRequestError, RateLimitError, ) -from dashscope.v2.transport.ws import WsEventError +from dashscope.transport.ws import WsEventError TIMEOUT = 2 @@ -1277,7 +1278,7 @@ async def scenario(ws, request): "failure, expected_error", [ ("invalid-json", json.JSONDecodeError), - ("receive-timeout", websocket.WebSocketTimeoutException), + ("receive-timeout", APITimeoutError), ("tcp-abort", APIConnectionError), ("close-frame", APIConnectionError), ], @@ -1357,7 +1358,7 @@ async def scenario(ws, request): "failure, expected_error", [ ("invalid-json", json.JSONDecodeError), - ("receive-timeout", websocket.WebSocketTimeoutException), + ("receive-timeout", APITimeoutError), ("tcp-abort", APIConnectionError), ], ) diff --git a/tests/v2/test_base_url_routing.py b/tests/test_base_url_routing.py similarity index 98% rename from tests/v2/test_base_url_routing.py rename to tests/test_base_url_routing.py index c656c043..bd730e67 100644 --- a/tests/v2/test_base_url_routing.py +++ b/tests/test_base_url_routing.py @@ -18,7 +18,7 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client +from dashscope 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 @@ -71,7 +71,7 @@ @pytest.fixture def clean_env(monkeypatch): - from dashscope.v2._config import settings + from dashscope._config import settings for name in ( "DASHSCOPE_API_KEY", diff --git a/tests/v2/test_chat.py b/tests/test_chat.py similarity index 93% rename from tests/v2/test_chat.py rename to tests/test_chat.py index f9ce1c93..359d641a 100644 --- a/tests/v2/test_chat.py +++ b/tests/test_chat.py @@ -12,9 +12,13 @@ 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 +from dashscope import AsyncClient, Client +from dashscope.errors import ( + ContentFilterError, + InternalServerError, + RateLimitError, +) +from dashscope.types.completions import parse_completion BASE_URL = "https://api.test.local/api/v1" CHAT_PATH = "/api/v1/services/aigc/text-generation/generation" @@ -22,6 +26,39 @@ OPENAI_PATH = "/api/v1/chat/completions" +@pytest.mark.asyncio +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_chat_maps_invalid_success_json_to_internal_error(asynchronous): + response = httpx.Response( + 200, + text="not-json", + headers={"x-request-id": "header-id"}, + ) + client = ( + _make_async_client(lambda request: response, max_retries=0) + if asynchronous + else _make_client(lambda request: response, max_retries=0) + ) + try: + with pytest.raises(InternalServerError) as caught: + call = client.chat.completions.create( + model="qwen-plus", + messages="hi", + ) + if asynchronous: + await call + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert caught.value.request_id == "header-id" + assert caught.value.body == "not-json" + assert isinstance(caught.value.__cause__, ValueError) + finally: + if asynchronous: + await client.aclose() + else: + client.close() + + def _sse(events): out = [] for i, data in enumerate(events, 1): @@ -1532,18 +1569,18 @@ def handler(request): 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. + # The OSS upload must run under the per-call key; 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") + def fake_upload(client, model, content, **kwargs): + seen["client"] = client + seen["api_key"] = kwargs.get("api_key") return True, "oss://bucket/cat.png", {} monkeypatch.setattr( - "dashscope.v2.resources._oss.check_and_upload_local", + "dashscope.resources._oss.check_and_upload_local", fake_upload, ) captured = {} @@ -1563,11 +1600,57 @@ def handler(request): api_key="sk-override", ) assert seen["api_key"] == "sk-override" - # The certificate is fetched from the client's own base_url so a + # The certificate is fetched through the client's own transport, so a # region/gateway-scoped client uploads to the matching regional OSS. - assert seen["base_address"] == client.config.base_url + assert seen["client"] is client assert captured["auth"] == "Bearer sk-override" assert captured["oss"] == "enable" assert captured["body"]["input"]["messages"][0]["content"] == [ {"image": "oss://bucket/cat.png"}, ] + + +@pytest.mark.asyncio +async def test_async_media_upload_reaches_the_gateway(tmp_path, monkeypatch): + # The async call offloads the blocking upload to a worker thread, which + # cannot await the AsyncTransport unless the client is bridged; without + # the bridge the policy fetch returns a coroutine and the upload dies. + monkeypatch.setattr( + "dashscope.resources._oss_upload._post_to_oss", + lambda *args, **kwargs: httpx.Response(200, text=""), + ) + policy = { + "oss_access_key_id": "STS.access-key-id", + "signature": "sig==", + "policy": "pol==", + "upload_dir": "upload/2026/abc", + "x_oss_object_acl": "private", + "x_oss_forbid_overwrite": "true", + "upload_host": "https://dashscope-oss.oss-cn-beijing.aliyuncs.com", + } + captured = {} + + def handler(request): + if request.url.path.endswith("/uploads"): + return httpx.Response( + 200, + json={"request_id": "r", "data": policy}, + ) + captured["oss"] = request.headers.get("X-DashScope-OssResourceResolve") + captured["body"] = json.loads(request.content) + return _native_response() + + image = tmp_path / "cat.png" + image.write_bytes(b"\x89PNG\r\n\x1a\n") + client = _make_async_client(handler) + resp = await client.chat.completions.create( + model="qwen-vl-max", + messages=[{"role": "user", "content": [{"image": str(image)}]}], + ) + + assert captured["oss"] == "enable" + assert captured["body"]["input"]["messages"][0]["content"] == [ + {"image": f"oss://{policy['upload_dir']}/cat.png"}, + ] + assert resp.output_text == "ok" + await client.aclose() diff --git a/tests/test_chat_params.py b/tests/test_chat_params.py new file mode 100644 index 00000000..687a9a10 --- /dev/null +++ b/tests/test_chat_params.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Unit tests for the model-name rule behind incremental_output. + +The streaming behaviour this feeds is covered end to end in test_chat.py +(``test_incremental_output_false_merges_client_side`` and the exempt-model +cases); this pins the rule itself. +""" + +from __future__ import annotations + +import pytest + +from dashscope.resources.chat._params import ( + should_modify_incremental_output, +) + + +@pytest.mark.parametrize( + "model", + [ + "qwen-tts", + "CosyVoice-TTS-v1", + "qwen-omni-turbo", + "Qwen2.5-Omni-7B", + "qwen-deep-research", + "qwen-DEEP-Research", + ], +) +def test_server_side_models_are_exempt(model): + assert should_modify_incremental_output(model) is False + + +@pytest.mark.parametrize( + "model", + ["qwen-plus", "qwen-max", "qwen-vl-max", "qwen3-coder-plus", ""], +) +def test_incremental_only_models_merge_client_side(model): + assert should_modify_incremental_output(model) is True + + +@pytest.mark.parametrize("model", [None, 123, ["qwen-tts"]]) +def test_a_missing_model_name_merges_client_side(model): + # Anything that is not a model name falls back to the common case + # rather than guessing an exemption. + assert should_modify_incremental_output(model) is True diff --git a/tests/v2/test_config.py b/tests/test_config.py similarity index 92% rename from tests/v2/test_config.py rename to tests/test_config.py index da5a64c6..f6177a76 100644 --- a/tests/v2/test_config.py +++ b/tests/test_config.py @@ -9,8 +9,8 @@ import pytest -from dashscope.v2 import AsyncClient, Client, Config -from dashscope.v2._config import ( +from dashscope import AsyncClient, Client, Config +from dashscope._config import ( DEFAULT_MAX_RETRIES, DEFAULT_PROTOCOL, DEFAULT_REGION, @@ -36,7 +36,7 @@ @pytest.fixture def clean_env(monkeypatch, tmp_path): - from dashscope.v2._config import settings + from dashscope._config import settings for name in ( "DASHSCOPE_API_KEY", @@ -452,7 +452,7 @@ def test_api_key_env_and_config_beat_key_file( def test_save_api_key_roundtrip(clean_env, tmp_path, monkeypatch): - from dashscope.v2 import save_api_key + from dashscope import save_api_key key_file = tmp_path / "nested" / "api_key" save_api_key("sk-saved", str(key_file)) @@ -462,6 +462,40 @@ def test_save_api_key_roundtrip(clean_env, tmp_path, monkeypatch): assert cfg.api_key == "sk-saved" +def test_save_api_key_writes_the_default_path( + clean_env, + tmp_path, + monkeypatch, +): + from dashscope import _config + + default = tmp_path / "home" / ".dashscope" / "api_key" + monkeypatch.setattr(_config, "_DEFAULT_API_KEY_FILE_PATH", str(default)) + # The env var outranks the default path when reading; drop it so this + # exercises the write-default / read-default pair. + monkeypatch.delenv("DASHSCOPE_API_KEY_FILE_PATH", raising=False) + + _config.save_api_key("sk-default") + + assert default.read_text(encoding="utf-8") == "sk-default" + cfg = resolve_config(config_path="/nonexistent/config.toml") + assert cfg.api_key == "sk-default" + + +def test_save_api_key_accepts_a_bare_filename( + clean_env, + tmp_path, + monkeypatch, +): + from dashscope import save_api_key + + # 1.x does os.makedirs(os.path.dirname(path)), which raises when the + # path has no directory component; the v2 writer resolves against cwd. + monkeypatch.chdir(tmp_path) + save_api_key("sk-bare", "api_key") + assert (tmp_path / "api_key").read_text(encoding="utf-8") == "sk-bare" + + def test_invalid_protocol(clean_env): with pytest.raises(ValueError, match="invalid protocol"): resolve_config(protocol="grpc", config_path="/nonexistent/config.toml") @@ -474,6 +508,17 @@ def test_invalid_timeout_and_retries(clean_env): resolve_config(max_retries=-1, config_path="/nonexistent/config.toml") +def test_non_string_base_url(clean_env): + # Left unvalidated, a non-string base_url only dies in the transport on + # ``base_url.rstrip("/")`` -- an AttributeError far from the bad argument. + with pytest.raises(TypeError, match="base_url must be a string"): + resolve_config(base_url=123, config_path="/nonexistent/config.toml") + with pytest.raises(TypeError, match="base_url must be a string"): + Client(base_url=123) + with pytest.raises(TypeError, match="base_url must be a string"): + AsyncClient(base_url=123) + + def test_invalid_toml(clean_env, tmp_path): path = _write_config(tmp_path, "not valid [toml") with pytest.raises(ValueError, match="invalid TOML"): @@ -551,7 +596,7 @@ def test_invalid_numeric_env(clean_env, monkeypatch): def test_settings_read_env_lazily(clean_env, monkeypatch): - from dashscope.v2._config import settings + from dashscope._config import settings monkeypatch.setenv("DASHSCOPE_PROTOCOL", "anthropic") monkeypatch.setenv("DASHSCOPE_TIMEOUT", "9") diff --git a/tests/v2/test_decision.py b/tests/test_decision.py similarity index 98% rename from tests/v2/test_decision.py rename to tests/test_decision.py index e5f87ab1..1a59de5a 100644 --- a/tests/v2/test_decision.py +++ b/tests/test_decision.py @@ -19,14 +19,14 @@ 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 ( +from dashscope import AsyncClient, Client +from dashscope.errors import BadRequestError, RateLimitError +from dashscope.resources.decision import ( ChoiceQuestion, NoulQuestion, ScoreQuestion, ) -from dashscope.v2.transport._ident import sdk_client_value +from dashscope.transport._ident import sdk_client_value BASE_URL = "https://api.test.local/api/v1" _WS_HOST = "https://ws-123.cn-beijing.maas.aliyuncs.com" @@ -83,7 +83,7 @@ def _make_async_client(handler, **kwargs): @pytest.fixture def clean_env(monkeypatch): - from dashscope.v2._config import settings + from dashscope._config import settings for name in ( "DASHSCOPE_API_KEY", diff --git a/tests/v2/test_error_registry.py b/tests/test_error_registry.py similarity index 71% rename from tests/v2/test_error_registry.py rename to tests/test_error_registry.py index 3ba08e95..fe858214 100644 --- a/tests/v2/test_error_registry.py +++ b/tests/test_error_registry.py @@ -2,9 +2,9 @@ # 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 +``dashscope/_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 +``data/*.textproto``), vendored at ``__version__ == "1.0.11"``. Only the ``PublicError`` and ``SdkErrorDef`` tables are carried; see ``test_vendored_copy_is_pruned_to_the_tables_v2_consumes``. @@ -12,8 +12,8 @@ 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 +Nothing here asserts how ``dashscope/errors.py`` maps the catalog; +that coupling lives in ``tests/test_errors.py``, so this file keeps depending on the generated artifact alone. """ @@ -26,17 +26,15 @@ 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 +from dashscope 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. +# Operation outcomes raised through ``AgenticRLError.from_catalog`` rather +# than dedicated exception classes. _CONDITION_ONLY = { "sdk.agentic_rl.FunctionRegistrationFailed", "sdk.agentic_rl.DatasetsUploadFailed", @@ -83,11 +81,11 @@ def _by_key(key): def test_catalog_size_and_provenance(): - assert registry.__version__ == "1.0.6" - assert registry.__commit__ == "56b7208" + assert registry.__version__ == "1.0.11" + assert registry.__commit__ == "84e5f33" assert registry.__snapshot__ is False assert len(PUBLIC) == 16 - assert len(SDK) == 32 + assert len(SDK) == 41 def test_generated_header_survives_vendoring(): @@ -97,7 +95,7 @@ def test_generated_header_survives_vendoring(): def test_vendored_copy_is_pruned_to_the_tables_v2_consumes(): - """Upstream 1.0.6 also ships a 277-entry ``INTERNAL_ERRORS`` table. + """Upstream 1.0.11 also ships a 299-entry ``INTERNAL_ERRORS`` table. It describes server-side components (``gateway.*`` / ``mint.*`` / ``api.*`` / ``turbo.*`` / ``model.*``) through ``InternalErrorDef``, @@ -130,7 +128,7 @@ def test_public_entries_are_immutable(): PUBLIC[0].key = "mutated" -def test_catalog_requires_exactly_nine_exception_classes(): +def test_catalog_requires_exactly_ten_exception_classes(): assert {entry.error_code for entry in PUBLIC} == _REQUIRED_CLASSES @@ -218,7 +216,7 @@ def test_format_message_substitution(): def test_agentic_rl_reachability_counts(): - """18 exact class-name hits, plus a family fallback for the rest.""" + """18 dedicated classes, plus condition-only and base-class entries.""" rl = _classes(rl_errors) entries = [e for e in SDK if e.name.startswith("sdk.agentic_rl.")] assert len(entries) == 27 @@ -227,13 +225,12 @@ def test_agentic_rl_reachability_counts(): 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 + without_dedicated_classes = suffixes - rl expected = {name.rsplit(".", 1)[1] for name in _CONDITION_ONLY} expected.add("Error") - assert unreachable == expected + assert without_dedicated_classes == expected def test_every_1x_rl_exception_has_a_catalog_landing_point(): @@ -243,14 +240,19 @@ def test_every_1x_rl_exception_has_a_catalog_landing_point(): ``sdk.agentic_rl.Error``; everything else matches by class name. """ names = _by_name(SDK) - for cls_name in _classes(rl_errors): + local_classes = { + name + for name in _classes(rl_errors) + if getattr(rl_errors, name).__module__ == rl_errors.__name__ + } + for cls_name in local_classes: 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.""" +def test_agentstudio_sdk_error_entries_have_native_classes(): + """The catalog's four AgentStudio entries all have native classes.""" expected = { "APIConnectionError", "APITimeoutError", @@ -263,12 +265,7 @@ def test_agentstudio_transport_errors_fully_covered(): 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``. - """ + """AgentStudio HTTP errors use statuses, not a generic SDK entry.""" assert "sdk.agentstudio.Error" not in _by_name(SDK) status_classes = { name @@ -287,10 +284,65 @@ def test_agentstudio_has_no_family_fallback(): assert status_classes, "expected 1.x agentstudio HTTP error classes" -def test_invalid_api_key_is_the_only_unfamily_entry(): +def test_unfamily_entries_are_the_generic_sdk_ones(): + """Entries outside a product family are the cross-SDK generics. + + ``sdk.InvalidApiKey`` plus the transport/file quartet shared with + the Java SDK (Python wires up ConnectionError / Timeout / IoError; + ConnectionInterrupted is a Java threading artifact carried for + parity). Anything else landing here means a product family grew + without its ``sdk..`` prefix -- that is a catalog bug. + """ others = [ e.name for e in SDK - if not e.name.startswith(("sdk.agentic_rl.", "sdk.agentstudio.")) + if not e.name.startswith( + ("sdk.agentic_rl.", "sdk.agentstudio.", "sdk.ws."), + ) ] - assert others == ["sdk.InvalidApiKey"] + assert others == [ + "sdk.InvalidApiKey", + "sdk.ConnectionError", + "sdk.ConnectionInterrupted", + "sdk.Timeout", + "sdk.IoError", + ] + + +def test_ws_family_entries(): + """The five websocket entries; four wired, one carried for parity. + + ProtocolError / IllegalState / QueueCapacityExceeded / + BinaryOwnerConflict back the v2 websocket transport's exception classes. + UpstreamQueueFull remains parity-only because sends are synchronous and + the Python transport has no upstream queue. + """ + entries = _by_name(SDK) + expected = { + "sdk.ws.ProtocolError", + "sdk.ws.UpstreamQueueFull", + "sdk.ws.QueueCapacityExceeded", + "sdk.ws.BinaryOwnerConflict", + "sdk.ws.IllegalState", + } + assert {e.name for e in SDK if e.name.startswith("sdk.ws.")} == expected + + from dashscope import errors as v2_errors + + wired = { + "sdk.ws.ProtocolError": "WsEventError", + "sdk.ws.IllegalState": "WsStateError", + "sdk.ws.QueueCapacityExceeded": ("WsQueueCapacityExceededError"), + "sdk.ws.BinaryOwnerConflict": "WsBinaryOwnerConflictError", + } + for name, cls_name in wired.items(): + cls = getattr(v2_errors, cls_name) + assert cls._SDK_ERROR_NAME == name + expected_vars = ( + [] + if name.endswith("QueueCapacityExceeded") + else [ + "detail", + ] + ) + assert entries[name].vars == expected_vars diff --git a/tests/v2/test_errors.py b/tests/test_errors.py similarity index 83% rename from tests/v2/test_errors.py rename to tests/test_errors.py index f22b3ca5..48bd0d37 100644 --- a/tests/v2/test_errors.py +++ b/tests/test_errors.py @@ -5,7 +5,7 @@ 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 +``dashscope/_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. @@ -18,11 +18,13 @@ 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 ( +from dashscope import AsyncClient, Client +from dashscope import _error_registry as registry +from dashscope import errors +from dashscope.errors import ( + APIConnectionError, APIStatusError, + APITimeoutError, AuthenticationError, BadRequestError, ContentFilterError, @@ -36,18 +38,23 @@ RequestTooLargeError, SdkError, ServiceUnavailableError, + WsBinaryOwnerConflictError, + WsEventError, + WsQueueCapacityExceededError, + WsStateError, check_response, error_from_business_code, error_from_sdk_exception, + sdk_error, ) -from dashscope.v2.errors import ( +from dashscope.errors import ( _CODE_MAP, _REFINED_CLASS, _SDK_ERRORS, _STATUS_MAP, _WIRE_TO_PUBLIC, ) -from dashscope.v2.types.common import JobStatus, ResponseBase, response_body +from dashscope.types.common import JobStatus, ResponseBase, response_body def _resp(status, body=None, headers=None): @@ -536,9 +543,9 @@ async def test_public_http_error_propagates_and_closes( text, payload, ): - monkeypatch.setattr("dashscope.v2._config._load_config_file", lambda _: {}) + monkeypatch.setattr("dashscope._config._load_config_file", lambda _: {}) monkeypatch.setattr( - "dashscope.v2._config._api_key_from_file", + "dashscope._config._api_key_from_file", lambda: None, ) wire = _ErrorBodyStream(text) @@ -622,8 +629,36 @@ def test_strict_http_envelope_retains_non_object_failures(raw, raise_on_error): @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) + response = httpx.Response( + 200, + content=json.dumps(raw), + headers={ + "content-type": "application/json", + "x-request-id": "header-id", + }, + ) + with pytest.raises(InternalServerError) as caught: + response_body(response, raise_on_error=raise_on_error) + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert caught.value.request_id == "header-id" + assert caught.value.body == raw + assert caught.value.headers["x-request-id"] == "header-id" + + +def test_strict_http_envelope_rejects_invalid_json_as_internal_error(): + response = httpx.Response( + 200, + text="not-json", + headers={"x-request-id": "header-id"}, + ) + with pytest.raises(InternalServerError) as caught: + response_body(response) + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert caught.value.request_id == "header-id" + assert caught.value.body == "not-json" + assert isinstance(caught.value.__cause__, ValueError) def test_error_code_enum(): @@ -667,11 +702,66 @@ def test_catalog_classes_are_flat_siblings(): def test_status_map_is_derived_from_the_catalog(): catalogued = {e.status_code for e in registry.PUBLIC_ERRORS} assert set(_STATUS_MAP) == catalogued + # First claimant of a status wins the status-only mapping. Entries + # sharing a status also share the same exception class. + seen = set() for entry in registry.PUBLIC_ERRORS: + if entry.status_code in seen: + continue + seen.add(entry.status_code) expected = getattr(errors, entry.error_code) assert _STATUS_MAP[entry.status_code] is expected, entry.key +def test_bare_500_maps_to_internal_server_error(): + assert _STATUS_MAP[500] is InternalServerError + with pytest.raises(InternalServerError) as ei: + check_response(_resp(500, {"message": "boom"})) + assert type(ei.value) is InternalServerError + + +def test_unparseable_500_response_maps_to_internal_server_error(): + response = httpx.Response(500, text="not-json") + error = errors.response_error(response) + assert type(error) is InternalServerError + assert error.status_code == 500 + assert error.message == "not-json" + + +def test_response_error_is_not_a_public_catalog_entry_or_class(): + assert "response_error" not in { + entry.key for entry in registry.PUBLIC_ERRORS + } + assert not hasattr(errors, "ResponseError") + + +@pytest.mark.parametrize( + "cls,name", + [ + (WsEventError, "sdk.ws.ProtocolError"), + (WsStateError, "sdk.ws.IllegalState"), + ( + WsQueueCapacityExceededError, + "sdk.ws.QueueCapacityExceeded", + ), + (WsBinaryOwnerConflictError, "sdk.ws.BinaryOwnerConflict"), + ], +) +def test_ws_errors_default_from_their_catalog_entries(cls, name): + entry = _SDK_ERRORS[name] + err = cls(detail="some detail") + assert isinstance(err, SdkError) + assert err.message == entry.format_message({"detail": "some detail"}) + assert err.code == entry.name + assert err.solution == entry.solution + assert err.allow_retry is entry.allow_retry + # An explicit message bypasses the template but keeps the wiring. + err = cls("custom", request_id="req-1") + assert err.message == "custom" + assert err.code == entry.name + assert err.request_id == "req-1" + + 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. @@ -783,6 +873,14 @@ def test_ambiguous_status_gets_no_catalog_text(): assert ei.value.status_code == status +def test_bodyless_500_gets_internal_error_catalog_text(): + with pytest.raises(InternalServerError) as ei: + check_response(_bare(500)) + assert ei.value.message == ( + "The server encountered an internal error. Please try again later." + ) + + def test_wire_code_identifies_an_entry_on_an_ambiguous_status(): err = errors.error_from_status( 400, @@ -828,6 +926,47 @@ def test_sdk_error_has_no_http_semantics(): assert SdkError("boom").status_code is None +def test_transport_errors_are_catalog_backed_sdk_errors(): + assert issubclass(APIConnectionError, SdkError) + assert issubclass(APITimeoutError, APIConnectionError) + + conn = APIConnectionError("boom") + conn_entry = _SDK_ERRORS["sdk.ConnectionError"] + assert conn.code == conn_entry.name + assert conn.solution == conn_entry.solution + assert conn.allow_retry == conn_entry.allow_retry + assert conn.status_code is None + + timeout = APITimeoutError("boom") + timeout_entry = _SDK_ERRORS["sdk.Timeout"] + assert timeout.code == timeout_entry.name + assert timeout.solution == timeout_entry.solution + assert timeout.allow_retry == timeout_entry.allow_retry + + +def test_transport_error_detail_fills_the_catalog_template(): + err = APIConnectionError(detail="dns blew up") + assert err.message == "Connection failed: dns blew up." + err = APITimeoutError(detail="slow") + assert err.message == "Operation timed out: slow." + + +def test_transport_error_explicit_fields_win_over_catalog_defaults(): + err = APIConnectionError("boom", code="custom", allow_retry=False) + assert err.code == "custom" + assert err.allow_retry is False + + +def test_sdk_error_factory_builds_classless_entries_from_the_catalog(): + entry = _SDK_ERRORS["sdk.IoError"] + err = sdk_error("sdk.IoError", {"detail": "disk full"}) + assert type(err) is SdkError + assert err.message == "Local I/O failed: disk full." + assert err.code == entry.name + assert err.solution == entry.solution + assert err.allow_retry is entry.allow_retry + + def test_sdk_error_translation_resolves_the_catalog_by_class_name(): err = error_from_sdk_exception( OSSUploadError("bucket denied"), diff --git a/tests/v2/test_files_models.py b/tests/test_files_models.py similarity index 94% rename from tests/v2/test_files_models.py rename to tests/test_files_models.py index ff76efbe..a1e14bbf 100644 --- a/tests/v2/test_files_models.py +++ b/tests/test_files_models.py @@ -7,8 +7,8 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import BadRequestError, NotFoundError +from dashscope import AsyncClient, Client +from dashscope.errors import BadRequestError, NotFoundError, SdkError BASE_URL = "https://api.test.local/api/v1" @@ -64,8 +64,11 @@ def handler(request): def test_files_upload_validation(tmp_path): client = _make_client(lambda r: httpx.Response(200, json={"data": {}})) - with pytest.raises(FileNotFoundError): + with pytest.raises(SdkError) as missing: client.files.upload(file_path=str(tmp_path / "nope.jsonl")) + assert missing.value.code == "sdk.IoError" + assert "nope.jsonl" in missing.value.message + assert missing.value.allow_retry is False bad = tmp_path / "bad.jsonl" bad.write_text("not json\n", encoding="utf-8") @@ -76,6 +79,24 @@ def test_files_upload_validation(tmp_path): assert resp is not None +def test_files_upload_unreadable_file_is_catalog_io_error( + tmp_path, + monkeypatch, +): + client = _make_client(lambda r: httpx.Response(200, json={"data": {}})) + path = tmp_path / "x.jsonl" + path.write_text("{}\n", encoding="utf-8") + + def _deny(*args, **kwargs): + raise PermissionError("denied") + + monkeypatch.setattr("builtins.open", _deny) + with pytest.raises(SdkError) as ei: + client.files.upload(file_path=str(path)) + assert ei.value.code == "sdk.IoError" + assert ei.value.message == "Local I/O failed: denied." + + def test_files_upload_retry_resends_full_body(tmp_path, monkeypatch): bodies = [] @@ -91,7 +112,7 @@ def handler(request): }, ) - from dashscope.v2.transport import http as transport_http + from dashscope.transport import http as transport_http monkeypatch.setattr(transport_http, "_sleep", lambda seconds: None) file_path = tmp_path / "t.jsonl" diff --git a/tests/v2/test_finetune_deployments.py b/tests/test_finetune_deployments.py similarity index 99% rename from tests/v2/test_finetune_deployments.py rename to tests/test_finetune_deployments.py index afc3238f..bb08d7dd 100644 --- a/tests/v2/test_finetune_deployments.py +++ b/tests/test_finetune_deployments.py @@ -12,13 +12,13 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import ( +from dashscope import AsyncClient, Client +from dashscope.errors import ( BadRequestError, DashScopeError, RateLimitError, ) -from dashscope.v2.resources.fine_tunes import FineTune +from dashscope.resources.fine_tunes import FineTune BASE_URL = "https://api.test.local/api/v1" diff --git a/tests/test_finetune_rl_shim.py b/tests/test_finetune_rl_shim.py new file mode 100644 index 00000000..82328c3f --- /dev/null +++ b/tests/test_finetune_rl_shim.py @@ -0,0 +1,460 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The finetune RL implementation and its 1.x compatibility surface. + +``dashscope.applications.finetune`` owns the single workflow core. +The historical ``dashscope.finetune`` paths expose the 1.x facade, while +``client.rl`` exposes the 2.0 facade. The tests also pin Function Compute, +``uvicorn`` / ``python -m``, lazy optional dependencies and the submit wire +format. +""" + +from __future__ import annotations + +import importlib +import importlib.util +import json +import pathlib +import platform +import runpy +import sys +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +import dashscope +from dashscope._shared import _sdk_ident +from dashscope._shared._sdk_ident import set_sdk_client +from dashscope.legacy_1x.finetune.agentic_rl import ( + AgenticRL as LegacyAgenticRL, +) +from dashscope.version import __version__ +from dashscope.errors import SdkError +from dashscope.applications.finetune import _submit +from dashscope.applications.finetune.agentic_rl import AgenticRL as V2AgenticRL +from dashscope.applications.finetune.agentic_rl import ( + SubmitOutput, + SubmitResult, +) +from dashscope.applications.finetune.reinforcement import ( + DASHSCOPE_HTTP_BASE_URL, +) +from dashscope.applications.finetune.reinforcement import TrainingType +from dashscope.applications.finetune.reinforcement.common.errors import ( + _LEGACY_ERROR_CODES, + AgenticRLError, + RuntimeErrorWithCode, + ValueErrorWithCode, +) +from dashscope.resources import reinforcement_learning as _rl_mount + +PUBLIC = "dashscope.finetune.reinforcement" +V2 = "dashscope.applications.finetune.reinforcement" +PATHS = [PUBLIC, V2] + +ENVELOPE = { + "status_code": 200, + "request_id": "req-1", + "code": None, + "message": "", + "output": { + "job_id": "ft-1", + "status": "RUNNING", + "create_time": "2026-01-02 03:04:05", + "finetuned_output": "qwen-ft", + }, + "usage": None, + "headers": {"x-request-id": "h-1"}, +} + + +class _StubTuning: + """The smallest ``tuning`` that ``_build_submit_request`` accepts.""" + + def __init__(self): + self.teacher_model = None + self.name = "stub-job" + self.model = SimpleNamespace(name="qwen-test") + self.functions = [] + self.datasets = [] + self.training = SimpleNamespace( + hyper_parameters=None, + resources=None, + type=TrainingType.TRAINING_TYPE, + ) + + def combine_ids_runtimes(self, functype, id_str=None): + return [] + + def check_function_names(self): + return True + + +def _stub(cls, api_key="sk-submit"): + # ``set_api_key`` writes DASHSCOPE_API_KEY for real; these tests are + # about the submit call, not about credential discovery. + with patch("dashscope.applications.finetune.agentic_rl.set_api_key"): + instance = cls(api_key=api_key) + instance.tuning = _StubTuning() + return instance + + +class _FakeResponse: + status_code = 200 + headers = {"content-type": "application/json"} + content = b"" + + @staticmethod + def json(): + return {"request_id": "req-1", "output": {"job_id": "ft-1"}} + + +@pytest.fixture(name="post_calls") +def fixture_post_calls(monkeypatch): + """Record every ``post_submit`` call instead of hitting the network.""" + calls = [] + + # ``_SDK_CLIENT`` is process-wide mutable state, and importing the CLI + # (which the compatibility-path ``app`` test does) marks the whole + # process ``python-cli``. Pinning it here keeps these + # assertions independent of test order; monkeypatch restores whatever + # the ambient value was. + monkeypatch.setattr(_sdk_ident, "_SDK_CLIENT", "python-sdk") + + class _Session: + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + def post(self, url, data=None, headers=None, timeout=None): + calls.append( + { + "url": url, + "data": data, + "headers": headers, + "timeout": timeout, + }, + ) + return _FakeResponse() + + monkeypatch.setattr(_submit.requests, "Session", _Session) + return calls + + +# -- the two AgenticRL surfaces ------------------------------------------- + + +def test_legacy_agentic_rl_subclasses_the_v2_one(): + assert LegacyAgenticRL is not V2AgenticRL + assert issubclass(LegacyAgenticRL, V2AgenticRL) + + +def test_only_the_1x_surface_keeps_the_job_verbs(): + assert "CreateMixin" not in [c.__name__ for c in V2AgenticRL.__mro__] + for verb in ("cancel", "list", "get", "delete", "logs"): + assert verb not in V2AgenticRL.__dict__, verb + assert verb in LegacyAgenticRL.__dict__, verb + + +# -- laziness ------------------------------------------------------------- + + +@pytest.mark.parametrize("alias", PATHS) +def test_server_module_resolves_without_importing_fastapi(alias): + submit_path = f"{alias}.component.server.server" + spec = importlib.util.find_spec(submit_path) + assert spec is not None + assert ( + "dashscope/applications/finetune/reinforcement" + in spec.origin.replace( + "\\", + "/", + ) + ) + assert spec.loader.get_code(submit_path) is not None + assert "fastapi" not in sys.modules + + +@pytest.mark.parametrize("alias", PATHS) +def test_python_dash_m_resolves_the_compatibility_server_path(alias): + # ``runpy`` refuses a loader that yields no code object, which is how + # examples/workspace/scripts/start_local_*.sh launches the server. + submit_path = f"{alias}.component.server.server" + name, _, code = runpy._get_module_details(submit_path) + assert name == submit_path + assert code is not None + assert "fastapi" not in sys.modules + + +@pytest.mark.skipif( + importlib.util.find_spec("opentelemetry") is not None, + reason="opentelemetry is installed, so the guard cannot be observed", +) +def test_genai_stays_behind_its_optional_dependency_on_all_paths(): + # ``genai/__init__`` imports ``llm_dashscope`` eagerly, which needs + # opentelemetry. All spellings must fail identically rather than one + # quietly succeeding through the alias. + for alias in [V2]: + name = f"{alias}.component.observability.genai.llm_dashscope" + with pytest.raises(ModuleNotFoundError) as ei: + importlib.import_module(name) + assert "opentelemetry" in str(ei.value) + + +def test_app_is_reachable_lazily_from_the_public_path(): + from dashscope.legacy_1x.cli.agentic_rl import app as cli_app + + for alias in PATHS: + assert importlib.import_module(alias).app is cli_app + + +# -- the submit POST ------------------------------------------------------ + + +def test_submit_post_keeps_the_published_wire_format(post_calls): + _stub(V2AgenticRL).submit_job(job_name="计算任务") + + assert len(post_calls) == 1 + call = post_calls[0] + assert call["url"] == f"{DASHSCOPE_HTTP_BASE_URL}/fine-tunes" + assert call["timeout"] == _submit.SUBMIT_TIMEOUT + + headers = call["headers"] + # Insertion order is part of the contract: it is the order 1.x's + # CreateMixin produced, and the backend sees the same bytes. + assert list(headers) == [ + "Content-Type", + "user-agent", + "x-dashscope-sdk-client", + "x-dashscope-sdk-session-id", + "Authorization", + "Accept", + ] + assert headers["Content-Type"] == "application/json; charset=utf-8" + assert headers["Accept"] == headers["Content-Type"] + assert headers["Authorization"] == "Bearer sk-submit" + + # Unmarked module tag: the v2 transport's ``finetune2`` belongs to + # client.rl.jobs, not to the submit path. + assert headers["x-dashscope-sdk-client"] == ( + f"python-sdk/{__version__}/finetune" + ) + + user_agent = headers["user-agent"] + assert user_agent.startswith("dashscope/") + assert f"python/{platform.python_version()}" in user_agent + + body = call["data"] + assert isinstance(body, bytes) + payload = json.loads(body.decode("utf-8")) + # ensure_ascii=False: a non-ASCII job name must not be escaped. + assert "计算任务".encode("utf-8") in body + assert payload["job_name"].startswith("计算任务-") + assert payload["model"] == "qwen-test" + assert payload["training_type"] == str(TrainingType.TRAINING_TYPE) + assert "teacher_model" not in payload + + +def test_cli_sdk_client_override_reaches_the_submit_tag(post_calls): + # ``set_sdk_client`` is the CLI's own entry point into the mutable + # global; the ``post_calls`` fixture pins and restores it. + set_sdk_client("python-cli") + + _stub(V2AgenticRL).submit_job() + + assert post_calls[0]["headers"]["x-dashscope-sdk-client"] == ( + f"python-cli/{__version__}/finetune" + ) + + +def test_sdk_header_kill_switch_still_applies(monkeypatch, post_calls): + monkeypatch.setenv("DASHSCOPE_DISABLE_SDK_HEADERS", "1") + + _stub(V2AgenticRL).submit_job() + + headers = post_calls[0]["headers"] + assert "x-dashscope-sdk-client" not in headers + assert "x-dashscope-sdk-session-id" not in headers + assert headers["Authorization"] == "Bearer sk-submit" + + +def test_session_id_is_one_value_across_both_stacks(): + from dashscope.legacy_1x.common.utils import get_sdk_headers + from dashscope.transport._ident import sdk_headers + + legacy = get_sdk_headers("finetune")["x-dashscope-sdk-session-id"] + v2 = sdk_headers("finetune")["x-dashscope-sdk-session-id"] + assert legacy == v2 + + +# -- api key resolution --------------------------------------------------- + + +def test_submit_ignores_the_1x_root_api_key_global(monkeypatch, post_calls): + monkeypatch.setattr(dashscope, "api_key", "sk-root-global", raising=False) + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-env") + + rl = _stub(V2AgenticRL, api_key="sk-submit") + assert rl._api_key == "sk-submit" + rl.submit_job() + + assert post_calls[0]["headers"]["Authorization"] == "Bearer sk-submit" + + +def test_submit_falls_back_to_the_environment(monkeypatch, post_calls): + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-env") + + rl = _stub(V2AgenticRL, api_key=None) + assert rl._api_key is None + rl.submit_job() + + assert post_calls[0]["headers"]["Authorization"] == "Bearer sk-env" + + +# -- the two return types ------------------------------------------------- + + +def test_current_submit_returns_submit_result_and_feeds_to_job(): + with patch( + "dashscope.applications.finetune._submit.post_submit", + return_value=dict(ENVELOPE), + ): + result = _stub(V2AgenticRL).submit_job() + + assert isinstance(result, SubmitResult) + assert isinstance(result.output, SubmitOutput) + assert result.output.job_id == "ft-1" + assert result.request_id == "req-1" + assert result.headers == {"x-request-id": "h-1"} + + job = _rl_mount._to_job(result) + assert job.job_id == "ft-1" + assert job.status == "RUNNING" + # The v2 model's aliases absorb the submit's 1.x-era field names. + assert job.created_at == "2026-01-02 03:04:05" + assert job.finetuned_model == "qwen-ft" + assert job.request_id == "req-1" + + +def test_legacy_submit_returns_the_finetune_dataclass(): + from dashscope.legacy_1x.finetune.customize_types import FineTune + + with patch( + "dashscope.applications.finetune._submit.post_submit", + return_value=dict(ENVELOPE), + ): + result = _stub(LegacyAgenticRL).submit_job() + + assert isinstance(result, FineTune) + assert result.status_code == 200 + assert result.output.job_id == "ft-1" + assert result.output.finetuned_output == "qwen-ft" + + +def test_a_failed_submit_is_an_envelope_not_an_exception(): + failure = { + "status_code": 400, + "request_id": "req-2", + "code": "InvalidParameter", + "message": "bad job", + "output": None, + "usage": None, + "headers": {}, + } + with patch( + "dashscope.applications.finetune._submit.post_submit", + return_value=dict(failure), + ): + result = _stub(V2AgenticRL).submit_job() + + assert result.status_code == 400 + assert result.code == "InvalidParameter" + assert result.output is None + + +# -- single implementation ------------------------------------------------ + + +def test_application_package_contains_the_unique_implementation(): + public = importlib.import_module(V2) + package_dir = pathlib.Path(public.__file__).parent + assert package_dir.name == "reinforcement" + assert package_dir.parent.name == "finetune" + assert len(list(package_dir.rglob("*.py"))) > 1 + assert importlib.util.find_spec("dashscope.reinforcement") is None + assert not (package_dir.parent.parent / "agentic_rl.py").exists() + + +def test_the_v2_mount_points_at_the_v2_workflow_modules(): + assert _rl_mount._agentic_rl is importlib.import_module( + "dashscope.applications.finetune.agentic_rl", + ) + assert _rl_mount._reinforcement is importlib.import_module(V2) + + +def test_the_two_rl_error_modules_are_versioned(): + legacy_errors = importlib.import_module(f"{PUBLIC}.common.errors") + canonical_errors = importlib.import_module(f"{V2}.common.errors") + assert legacy_errors is not canonical_errors + assert legacy_errors.ValueErrorWithCode is not ValueErrorWithCode + assert canonical_errors.ValueErrorWithCode is ValueErrorWithCode + + +@pytest.mark.parametrize( + ("catalog_name", "legacy_code"), + _LEGACY_ERROR_CODES.items(), +) +def test_rl_errors_have_native_catalog_and_legacy_code_defaults( + catalog_name, + legacy_code, +): + errors = importlib.import_module(f"{V2}.common.errors") + cls_name = catalog_name.rsplit(".", 1)[1] + cls = AgenticRLError if cls_name == "Error" else getattr(errors, cls_name) + error = cls("failure") + + assert isinstance(error, SdkError) + assert error.code == catalog_name + assert error.error_code == legacy_code + assert error.status_code is None + + +def test_explicit_legacy_code_overrides_the_class_default(): + error = ValueErrorWithCode("duplicate names", error_code=3004) + + assert error.code == "sdk.agentic_rl.ValueErrorWithCode" + assert error.error_code == 3004 + assert error.message == "duplicate names" + + +def test_condition_only_catalog_entries_do_not_need_new_classes(): + error = AgenticRLError.from_catalog( + "sdk.agentic_rl.DuplicateFunctionNames", + error_code=3004, + variables={"names": "rollout"}, + ) + + assert type(error) is AgenticRLError + assert error.code == "sdk.agentic_rl.DuplicateFunctionNames" + assert error.message == "Duplicate function names detected: rollout." + assert error.error_code == 3004 + assert error.allow_retry is False + assert "unique name" in error.solution + + +def test_legacy_builtin_exception_inheritance_is_retained(): + # 1.x contract: these stayed catchable as the builtins they wrap, so + # ``except RuntimeError`` / ``except ValueError`` keep working, while the + # v2 unification means they are still ``AgenticRLError`` too. + assert issubclass(RuntimeErrorWithCode, RuntimeError) + assert issubclass(ValueErrorWithCode, ValueError) + assert issubclass(RuntimeErrorWithCode, AgenticRLError) + assert issubclass(ValueErrorWithCode, AgenticRLError) + + with pytest.raises(ValueError): + raise ValueErrorWithCode("bad value") + with pytest.raises(RuntimeError): + raise RuntimeErrorWithCode("bad state") diff --git a/tests/v2/test_http_pool.py b/tests/test_http_pool.py similarity index 97% rename from tests/v2/test_http_pool.py rename to tests/test_http_pool.py index f666031b..9b3dc8ad 100644 --- a/tests/v2/test_http_pool.py +++ b/tests/test_http_pool.py @@ -7,8 +7,8 @@ import pytest -from dashscope.v2._config import resolve_config, settings -from dashscope.v2.client import Client +from dashscope._config import resolve_config, settings +from dashscope.client import Client @pytest.fixture diff --git a/tests/v2/test_images_videos.py b/tests/test_images_videos.py similarity index 98% rename from tests/v2/test_images_videos.py rename to tests/test_images_videos.py index c8fdb497..00bcba7e 100644 --- a/tests/v2/test_images_videos.py +++ b/tests/test_images_videos.py @@ -12,7 +12,7 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client +from dashscope import AsyncClient, Client BASE_URL = "https://api.test.local/api/v1" IMAGE_PATH = "/api/v1/services/aigc/text2image/image-synthesis" @@ -665,13 +665,13 @@ def handler(request): 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") + def fake_upload(client, model, content, **kwargs): + seen["client"] = client + seen["api_key"] = kwargs.get("api_key") return True, "oss://bucket/ref.png", {} monkeypatch.setattr( - "dashscope.v2.resources._oss.check_and_upload_local", + "dashscope.resources._oss.check_and_upload_local", fake_upload, ) captured = {} @@ -690,7 +690,7 @@ def handler(request): api_key="sk-override", ) assert seen["api_key"] == "sk-override" - assert seen["base_address"] == client.config.base_url + assert seen["client"] is client assert captured["auth"] == "Bearer sk-override" assert captured["oss"] == "enable" content = captured["body"]["input"]["messages"][0]["content"] diff --git a/tests/v2/test_jobs.py b/tests/test_jobs.py similarity index 98% rename from tests/v2/test_jobs.py rename to tests/test_jobs.py index bdf2c536..a720070d 100644 --- a/tests/v2/test_jobs.py +++ b/tests/test_jobs.py @@ -13,9 +13,9 @@ 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 +from dashscope import AsyncClient, Client +from dashscope.errors import DashScopeError +from dashscope.resources._jobs import AsyncJobs, Job, JobList, Jobs BASE_URL = "https://api.test.local/api/v1" @@ -236,7 +236,7 @@ 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) + monkeypatch.setattr("dashscope.resources._jobs.time", wait_clock) client = _make_client(handler) jobs = Jobs(client, "aigc") with pytest.raises(DashScopeError, match="timed out"): @@ -666,7 +666,7 @@ def handler(request): assert wait_clock.monotonic() == 10.5 return httpx.Response(200, json=_job_body("SUCCEEDED")) - monkeypatch.setattr("dashscope.v2.resources._jobs.time", wait_clock) + monkeypatch.setattr("dashscope.resources._jobs.time", wait_clock) client = ( _make_async_client(handler, raise_on_error=raise_on_error) if asynchronous @@ -722,7 +722,7 @@ def handler(request): clock_module = "audio.asr" if modality == "asr" else "_jobs" monkeypatch.setattr( - f"dashscope.v2.resources.{clock_module}.time", + f"dashscope.resources.{clock_module}.time", wait_clock, ) client = ( @@ -777,7 +777,7 @@ def handler(request): requests.append(request) return httpx.Response(200, json=_job_body("SUCCEEDED")) - monkeypatch.setattr("dashscope.v2.resources.audio.asr.time", wait_clock) + monkeypatch.setattr("dashscope.resources.audio.asr.time", wait_clock) client = ( _make_async_client(handler, raise_on_error=raise_on_error) if asynchronous diff --git a/tests/test_legacy_import_aliases.py b/tests/test_legacy_import_aliases.py new file mode 100644 index 00000000..9b69e605 --- /dev/null +++ b/tests/test_legacy_import_aliases.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from __future__ import annotations + +import importlib +import os +import pathlib +import subprocess +import sys +from unittest.mock import patch + +import pytest + +import dashscope +from dashscope.aigc.multimodal_conversation import MultiModalConversation +from dashscope.audio.qwen_asr import QwenTranscription +from dashscope.finetune.finetunes import FineTunes + + +_REPO = pathlib.Path(__file__).resolve().parents[1] + + +def _run_python(*args): + env = os.environ.copy() + env["PYTHONDONTWRITEBYTECODE"] = "1" + result = subprocess.run( + [sys.executable, *args], + cwd=_REPO, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_all_legacy_module_aliases_in_clean_process(): + _run_python( + "-c", + """ +import importlib +import dashscope + +packages = ( + 'aigc', 'api_entities', 'app', 'assistants', 'audio', 'cli', + 'common', 'embeddings', 'io', 'multimodal', 'nlp', 'protocol', + 'rerank', 'threads', 'tokenizers', 'utils', +) +modules = ('files', 'model', 'models') +for name in packages + modules: + old = importlib.import_module(f'dashscope.{name}') + canonical = importlib.import_module(f'dashscope.legacy_1x.{name}') + assert old is canonical, name + assert getattr(dashscope, name) is old, name + +for name in ('customize_types', 'deployments', 'finetunes'): + old = importlib.import_module(f'dashscope.finetune.{name}') + canonical = importlib.import_module( + f'dashscope.legacy_1x.finetune.{name}', + ) + assert old is canonical, name + +# ``from dashscope.finetune import `` resolves the child against the +# shared canonical ``__name__``; guard the 1.x-only submodules stay reachable. +from dashscope.finetune import customize_types, deployments, finetunes +for name, mod in ( + ('customize_types', customize_types), + ('deployments', deployments), + ('finetunes', finetunes), +): + legacy = importlib.import_module(f'dashscope.legacy_1x.finetune.{name}') + assert mod is legacy, name +""", + ) + + +def test_target_legacy_classes_keep_root_identity(): + assert dashscope.FineTunes is FineTunes + assert dashscope.MultiModalConversation is MultiModalConversation + + +@pytest.mark.parametrize( + ("patch_target", "api"), + [ + ("dashscope.finetune.finetunes.FineTunes.call", FineTunes), + ( + "dashscope.aigc.multimodal_conversation.MultiModalConversation.call", + MultiModalConversation, + ), + ( + "dashscope.audio.qwen_asr.qwen_transcription.QwenTranscription.call", + QwenTranscription, + ), + ], +) +def test_target_legacy_calls_accept_old_patch_paths(patch_target, api): + sentinel = object() + with patch(patch_target, return_value=sentinel) as call: + result = api.call(model="test-model") + assert result is sentinel + call.assert_called_once_with(model="test-model") + + +def test_client_and_legacy_base_api_coexist(): + from dashscope.client import AsyncClient, Client + from dashscope.client.base_api import BaseApi + + canonical = importlib.import_module("dashscope.legacy_1x.client.base_api") + assert importlib.import_module("dashscope.client.base_api") is canonical + assert BaseApi is canonical.BaseApi + assert Client is dashscope.Client + assert AsyncClient is dashscope.AsyncClient + assert Client.__module__ == "dashscope.client" + + +def test_leftover_1x_client_dir_does_not_shadow_client_module(tmp_path): + """pip leaves 1.x files behind on an in-place upgrade, and a directory + outranks a same-named module, so the empty 1.x ``client/`` would win.""" + site = tmp_path / "site-packages" + installed = site / "dashscope" + installed.mkdir(parents=True) + for entry in (_REPO / "dashscope").iterdir(): + if entry.name == "__pycache__": + continue + (installed / entry.name).symlink_to(entry) + + stale = installed / "client" + stale.mkdir() + (stale / "__init__.py").write_text("", encoding="utf-8") + (stale / "base_api.py").write_text("", encoding="utf-8") + + env = os.environ.copy() + env["PYTHONDONTWRITEBYTECODE"] = "1" + env["PYTHONPATH"] = str(site) + result = subprocess.run( + [ + sys.executable, + "-c", + """ +import dashscope +import dashscope.client as client +from dashscope.client import AsyncClient, Client +from dashscope.client.base_api import BaseApi + +assert client.__file__.endswith('client.py'), client.__file__ +assert Client is dashscope.Client +assert AsyncClient is dashscope.AsyncClient +assert Client.__module__ == 'dashscope.client' +assert BaseApi.__module__ == 'dashscope.legacy_1x.client.base_api' +""", + ], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize( + ("old_name", "canonical_name"), + [ + ("dashscope.agentstudio", "dashscope.legacy_1x.agentstudio"), + ( + "dashscope.agentstudio.types.models", + "dashscope.applications.agentstudio.types.models", + ), + ( + "dashscope.finetune.agentic_rl", + "dashscope.legacy_1x.finetune.agentic_rl", + ), + ( + "dashscope.finetune.reinforcement.common.errors", + "dashscope.legacy_1x.finetune.reinforcement.common.errors", + ), + ], +) +def test_historical_paths_select_the_1x_facades(old_name, canonical_name): + assert importlib.import_module(old_name) is importlib.import_module( + canonical_name, + ) + + +def test_agentic_rl_historical_contract_is_unchanged(): + module = importlib.import_module("dashscope.finetune.agentic_rl") + for verb in ("cancel", "list", "get", "delete", "logs"): + assert hasattr(module.AgenticRL, verb) + assert module.AgenticRL.__module__.startswith("dashscope.legacy_1x") + + +def test_plain_import_preserves_cli_and_assistants_laziness(): + _run_python( + "-c", + """ +import sys +import dashscope + +names = ( + 'dashscope.cli', + 'dashscope.legacy_1x.cli', + 'dashscope.assistants', + 'dashscope.legacy_1x.assistants', +) +assert not any(name in sys.modules for name in names) +""", + ) + + +def test_historical_cli_module_entrypoint(): + _run_python("-m", "dashscope.cli", "--help") diff --git a/tests/v2/test_logging.py b/tests/test_logging.py similarity index 79% rename from tests/v2/test_logging.py rename to tests/test_logging.py index 38b67040..f3b88ae8 100644 --- a/tests/v2/test_logging.py +++ b/tests/test_logging.py @@ -13,20 +13,20 @@ import httpx import pytest -from dashscope.v2 import _logging as logging_mod -from dashscope.v2._logging import ( +from dashscope import _logging as logging_mod +from dashscope._logging import ( REDACTED, SensitiveHeadersFilter, get_logger, redact_headers, redact_text, ) -from dashscope.v2.transport import SyncTransport +from dashscope.transport import SyncTransport @pytest.fixture def reset_logging(monkeypatch): - root = logging.getLogger("dashscope.v2") + root = logging.getLogger(logging_mod.ROOT_NAME) monkeypatch.setattr(logging_mod, "_configured", False) yield root.filters.clear() @@ -81,7 +81,7 @@ def test_filter_redacts_record(reset_logging): def test_env_enables_debug_logging(reset_logging, monkeypatch): monkeypatch.setenv("DASHSCOPE_LOG", "debug") logger = get_logger("transport") - root = logging.getLogger("dashscope.v2") + root = logging.getLogger(logging_mod.ROOT_NAME) assert root.level == logging.DEBUG assert len(root.handlers) == 1 assert isinstance( @@ -89,13 +89,13 @@ def test_env_enables_debug_logging(reset_logging, monkeypatch): SensitiveHeadersFilter, ) assert isinstance(logger.filters[0], SensitiveHeadersFilter) - assert logger.name == "dashscope.v2.transport" + assert logger.name == f"{logging_mod.ROOT_NAME}.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") + root = logging.getLogger(logging_mod.ROOT_NAME) assert root.level == logging.NOTSET assert root.handlers == [] assert root.filters == [] @@ -107,20 +107,38 @@ def test_configured_once(reset_logging, monkeypatch): get_logger("a") get_logger("a") get_logger("b") - root = logging.getLogger("dashscope.v2") + root = logging.getLogger(logging_mod.ROOT_NAME) assert len(root.handlers) == 1 - for name in ("dashscope.v2.a", "dashscope.v2.b"): - child = logging.getLogger(name) + for name in ("a", "b"): + child = logging.getLogger(f"{logging_mod.ROOT_NAME}.{name}") assert len(child.filters) == 1 +def test_configuration_does_not_mutate_legacy_root_logger( + reset_logging, + monkeypatch, +): + legacy_root = logging.getLogger("dashscope") + level = legacy_root.level + handlers = list(legacy_root.handlers) + filters = list(legacy_root.filters) + + monkeypatch.setenv("DASHSCOPE_LOG", "debug") + get_logger("transport") + + assert logging_mod.ROOT_NAME != "dashscope" + assert legacy_root.level == level + assert legacy_root.handlers == handlers + assert legacy_root.filters == filters + + def test_transport_logs_retry_and_request_id( reset_logging, monkeypatch, caplog, ): monkeypatch.setattr( - "dashscope.v2.transport.http._sleep", + "dashscope.transport.http._sleep", lambda s: None, ) calls = {"n": 0} @@ -143,7 +161,7 @@ def handler(request): base_url="https://api.test.local", http_client=http_client, ) - with caplog.at_level(logging.DEBUG, logger="dashscope.v2"): + with caplog.at_level(logging.DEBUG, logger=logging_mod.ROOT_NAME): resp = t.request("POST", "/chat") assert resp.status_code == 200 messages = [r.getMessage() for r in caplog.records] @@ -158,7 +176,7 @@ def test_child_logger_output_redacted_via_sdk_handler( ): monkeypatch.setenv("DASHSCOPE_LOG", "debug") logger = get_logger("transport") - root = logging.getLogger("dashscope.v2") + root = logging.getLogger(logging_mod.ROOT_NAME) stream = io.StringIO() root.handlers[0].stream = stream logger.info("token: Bearer sk-abcdef1234567890") @@ -174,7 +192,7 @@ def test_child_logger_output_redacted_via_user_handler( # 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"): + with caplog.at_level(logging.DEBUG, logger=logging_mod.ROOT_NAME): logger.info("token: Bearer sk-abcdef1234567890") messages = [r.getMessage() for r in caplog.records] assert any("Bearer " + REDACTED in m for m in messages) diff --git a/tests/v2/test_multimodal.py b/tests/test_multimodal.py similarity index 99% rename from tests/v2/test_multimodal.py rename to tests/test_multimodal.py index 54cb41e4..80c8c404 100644 --- a/tests/v2/test_multimodal.py +++ b/tests/test_multimodal.py @@ -12,7 +12,7 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client +from dashscope import AsyncClient, Client BASE_URL = "https://api.test.local/api/v1" MULTIMODAL_PATH = "/api/v1/services/aigc/multimodal-generation/generation" diff --git a/tests/test_oss_upload.py b/tests/test_oss_upload.py new file mode 100644 index 00000000..49eed1ec --- /dev/null +++ b/tests/test_oss_upload.py @@ -0,0 +1,422 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Tests for the v2-native local-file → OSS uploader.""" + +# Mock handlers intentionally ignore the request argument. +# pylint: disable=unused-argument + +from __future__ import annotations + +import asyncio +import os + +import httpx +import pytest + +from dashscope import AsyncClient, Client +from dashscope.errors import ( + AuthenticationError, + InternalServerError, + PermissionDeniedError, +) +from dashscope.resources import _oss_upload +from dashscope.resources._blocking import run_blocking + +BASE_URL = "https://api.test.local/api/v1" +OSS_HOST = "https://dashscope-oss.oss-cn-beijing.aliyuncs.com" + +_CERT = { + "oss_access_key_id": "STS.access-key-id", + "signature": "sig==", + "policy": "pol==", + "upload_dir": "upload/2026/abc", + "x_oss_object_acl": "private", + "x_oss_forbid_overwrite": "true", + "upload_host": OSS_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) + + +def _policy_handler(calls, status=200): + def handler(request): + calls.append(request) + if status != 200: + return httpx.Response(status, json={"message": "denied"}) + return httpx.Response( + 200, + json={"request_id": "r-1", "data": _CERT}, + ) + + return handler + + +def _policy_client(status=200): + """Client whose transport answers ``GET /uploads`` with a policy.""" + calls = [] + return _make_client(_policy_handler(calls, status=status)), calls + + +def _capture_oss(monkeypatch, status=200): + """Send the OSS POST through a MockTransport and record what it carried.""" + seen = {} + real_post = _oss_upload._post_to_oss + + def handler(request): + seen["url"] = str(request.url) + seen["headers"] = request.headers + seen["body"] = request.content.decode("utf-8", "replace") + if status == 200: + return httpx.Response(200, text="") + return httpx.Response( + status, + text="AccessDenied", + ) + + def patched(upload_host, form, file_name, file_path, transport=None): + seen["form"] = form + seen["file_name"] = file_name + return real_post( + upload_host, + form, + file_name, + file_path, + transport=httpx.MockTransport(handler), + ) + + monkeypatch.setattr(_oss_upload, "_post_to_oss", patched) + return seen + + +def _png(tmp_path, name="cat.png"): + path = tmp_path / name + path.write_bytes(b"\x89PNG\r\n\x1a\n") + return str(path) + + +def test_certificate_fetch_rides_the_client_transport(): + client, calls = _policy_client() + + cert = _oss_upload.get_upload_certificate( + client, + "qwen-vl-max", + api_key="sk-override", + workspace="ws-1", + ) + + assert cert == _CERT + request = calls[0] + assert request.url.path == "/api/v1/uploads" + assert request.url.params["action"] == "getPolicy" + assert request.url.params["model"] == "qwen-vl-max" + # This hop talks to DashScope, so it does carry the per-call credential. + assert request.headers.get("Authorization") == "Bearer sk-override" + assert request.headers.get("X-DashScope-WorkSpace") == "ws-1" + + +def test_certificate_fetch_failure_raises(): + client, _ = _policy_client(status=401) + with pytest.raises(AuthenticationError): + _oss_upload.get_upload_certificate(client, "qwen-vl-max") + + +@pytest.mark.parametrize( + "body,request_id", + [ + ({"request_id": "r-1"}, "r-1"), + # "output" is not this endpoint's envelope; accepting it would let + # an unrelated body through as a policy and fail later on a missing + # policy field instead of here. + ({"request_id": "r-1", "output": _CERT}, "r-1"), + ([], "header-id"), + ("not-an-object", "header-id"), + ], +) +def test_certificate_fetch_rejects_an_invalid_policy_body(body, request_id): + client = _make_client( + lambda request: httpx.Response( + 200, + json=body, + headers={"x-request-id": "header-id"}, + ), + ) + + with pytest.raises(InternalServerError) as caught: + _oss_upload.get_upload_certificate(client, "m") + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert caught.value.request_id == request_id + assert caught.value.body == body + assert caught.value.headers["x-request-id"] == "header-id" + + +def test_certificate_fetch_rejects_invalid_json_as_internal_error(): + client = _make_client( + lambda request: httpx.Response( + 200, + text="not-json", + headers={"x-request-id": "header-id"}, + ), + ) + + with pytest.raises(InternalServerError) as caught: + _oss_upload.get_upload_certificate(client, "m") + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert caught.value.request_id == "header-id" + assert caught.value.body == "not-json" + assert isinstance(caught.value.__cause__, ValueError) + + +@pytest.mark.asyncio +async def test_run_blocking_leaves_the_default_executor_for_async_transport( + monkeypatch, +): + real_to_thread = asyncio.to_thread + default_executor_calls = [] + + async def tracked_to_thread(fn, *args, **kwargs): + default_executor_calls.append(fn) + return await real_to_thread(fn, *args, **kwargs) + + class ExecutorUsingTransport: + async def request(self): + return await asyncio.to_thread(lambda: "ok") + + monkeypatch.setattr(asyncio, "to_thread", tracked_to_thread) + client = type("Client", (), {"transport": ExecutorUsingTransport()})() + + result = await run_blocking( + client, + lambda bridged: bridged.transport.request(), + ) + + assert result == "ok" + # Only the async transport used the loop default executor. The outer + # blocking call ran on the isolated pool, so the two cannot starve each + # other when concurrent uploads fill the bridge pool. + assert len(default_executor_calls) == 1 + + +@pytest.mark.asyncio +async def test_async_client_uploads_over_the_bridged_transport( + tmp_path, + monkeypatch, +): + seen = _capture_oss(monkeypatch) + calls = [] + client = _make_async_client(_policy_handler(calls)) + + url, cert = await run_blocking( + client, + _oss_upload.upload_local_file, + "qwen-vl-max", + _png(tmp_path), + ) + + # The policy fetch must come back a response, not the un-awaited + # coroutine an AsyncTransport hands a caller that forgets to await. + assert url == f"oss://{_CERT['upload_dir']}/cat.png" + assert cert == _CERT + assert seen["url"] == OSS_HOST + assert len(calls) == 1 + assert calls[0].headers.get("Authorization") == "Bearer sk-test" + await client.aclose() + + +def test_upload_local_file_builds_the_oss_form(tmp_path, monkeypatch): + seen = _capture_oss(monkeypatch) + client, calls = _policy_client() + file_path = _png(tmp_path) + + url, cert = _oss_upload.upload_local_file( + client, + "qwen-vl-max", + file_path, + ) + + assert url == f"oss://{_CERT['upload_dir']}/cat.png" + assert cert == _CERT + assert seen["url"] == OSS_HOST + assert seen["form"] == { + "OSSAccessKeyId": _CERT["oss_access_key_id"], + "Signature": _CERT["signature"], + "policy": _CERT["policy"], + "key": _CERT["upload_dir"] + "/cat.png", + "x-oss-object-acl": "private", + "x-oss-forbid-overwrite": "true", + "success_action_status": "200", + "x-oss-content-type": "image/png", + } + assert seen["file_name"] == "cat.png" + # OSS rejects a post-object request whose "file" part is not last. + assert seen["body"].rindex('name="key"') < seen["body"].rindex( + 'name="file"', + ) + assert len(calls) == 1 + + +def test_unknown_extension_falls_back_to_octet_stream(tmp_path, monkeypatch): + seen = _capture_oss(monkeypatch) + client, _ = _policy_client() + file_path = str(tmp_path / "blob") + with open(file_path, "wb") as handle: + handle.write(b"x") + + _oss_upload.upload_local_file(client, "qwen-vl-max", file_path) + + assert seen["form"]["x-oss-content-type"] == "application/octet-stream" + + +def test_oss_post_carries_no_dashscope_credential(tmp_path, monkeypatch): + seen = _capture_oss(monkeypatch) + client, _ = _policy_client() + + _oss_upload.upload_local_file(client, "qwen-vl-max", _png(tmp_path)) + + # The credential boundary: the storage host must never see the API key, + # so this hop uses its own auth-free client rather than the transport. + assert seen["headers"].get("Authorization") is None + assert "sk-test" not in seen["body"] + assert seen["headers"].get("Accept") == "application/json" + user_agent = seen["headers"].get("User-Agent", "") + assert user_agent.startswith("dashscope-v2-python/") + + +def test_oss_post_failure_maps_to_a_dashscope_error(tmp_path, monkeypatch): + _capture_oss(monkeypatch, status=403) + client, _ = _policy_client() + + with pytest.raises(PermissionDeniedError): + _oss_upload.upload_local_file(client, "qwen-vl-max", _png(tmp_path)) + + +def test_certificate_is_reused_across_uploads(tmp_path, monkeypatch): + _capture_oss(monkeypatch) + client, calls = _policy_client() + first = _png(tmp_path, "a.png") + second = _png(tmp_path, "b.png") + + url_a, cert = _oss_upload.upload_local_file(client, "m", first) + url_b, _ = _oss_upload.upload_local_file( + client, + "m", + second, + certificate=cert, + ) + + assert url_a == f"oss://{_CERT['upload_dir']}/a.png" + assert url_b == f"oss://{_CERT['upload_dir']}/b.png" + # Only the first upload needed a policy. + assert len(calls) == 1 + + +def test_check_and_upload_local_keeps_remote_urls_untouched(): + client, calls = _policy_client() + + for content in ( + "https://example.com/cat.png", + "http://example.com/cat.png", + ): + assert _oss_upload.check_and_upload_local(client, "m", content) == ( + False, + content, + None, + ) + assert calls == [] + + +def test_check_and_upload_local_flags_an_existing_oss_url(): + client, calls = _policy_client() + + result = _oss_upload.check_and_upload_local( + client, + "m", + "oss://bucket/cat.png", + ) + + # Nothing was uploaded, but the caller still has to opt the request into + # OSS resolution. + assert result == (True, "oss://bucket/cat.png", None) + assert calls == [] + + +def test_check_and_upload_local_uploads_a_file_uri(tmp_path, monkeypatch): + _capture_oss(monkeypatch) + client, calls = _policy_client() + file_path = _png(tmp_path) + + is_up, url, cert = _oss_upload.check_and_upload_local( + client, + "qwen-vl-max", + f"file://{file_path}", + api_key="sk-override", + ) + + assert is_up is True + assert url == f"oss://{_CERT['upload_dir']}/cat.png" + assert cert == _CERT + assert len(calls) == 1 + assert calls[0].headers.get("Authorization") == "Bearer sk-override" + + +def test_check_and_upload_local_uploads_a_bare_path(tmp_path, monkeypatch): + _capture_oss(monkeypatch) + client, _ = _policy_client() + + is_up, url, _ = _oss_upload.check_and_upload_local( + client, + "m", + _png(tmp_path), + ) + + assert is_up is True + assert url == f"oss://{_CERT['upload_dir']}/cat.png" + + +def test_check_and_upload_local_expands_a_path_that_is_not_a_file(): + client, calls = _policy_client() + + is_up, value, cert = _oss_upload.check_and_upload_local( + client, + "m", + "~/nope.png", + ) + + # Passed through, but with ~ expanded as in 1.x. + assert (is_up, cert) == (False, None) + assert value == os.path.expanduser("~/nope.png") + assert calls == [] + + +def test_missing_file_uri_raises_value_error(tmp_path): + client, calls = _policy_client() + missing = (tmp_path / "gone.png").as_uri() + + with pytest.raises(ValueError, match="is not exists"): + _oss_upload.check_and_upload_local(client, "m", missing) + assert calls == [] + + +@pytest.mark.parametrize( + "uri,expected", + [ + ("file:///tmp/a%20b.png", "/tmp/a b.png"), + ("file://localhost/tmp/a.png", "localhost/tmp/a.png"), + ("file:///C:/Users/me/a.png", "C:/Users/me/a.png"), + ], +) +def test_resolve_file_uri_path(uri, expected): + assert _oss_upload._resolve_file_uri_path(uri) == expected diff --git a/tests/test_package_root.py b/tests/test_package_root.py new file mode 100644 index 00000000..5fdc85c8 --- /dev/null +++ b/tests/test_package_root.py @@ -0,0 +1,207 @@ +# -*- 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 +from dataclasses import replace + +import httpx +import pytest + +import dashscope +from dashscope import _config, _quickstart +from dashscope.client import AsyncClient as RootAsyncClient +from dashscope.client import Client as RootClient + +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_current_implementation(): + assert dashscope.Client is RootClient + assert dashscope.AsyncClient is RootAsyncClient + assert dashscope.Client.__module__ == "dashscope.client" + assert callable(dashscope.chat) + for name in ( + "AsyncClient", + "Client", + "Config", + "DashScopeError", + "ErrorCode", + "Settings", + "WsConnection", + "WsTask", + "chat", + "errors", + "save_api_key", + "settings", + ): + assert hasattr(dashscope, name), name + assert name in dashscope.__all__, name + + +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_plain_import_keeps_the_historical_tokenizers_module(): + # Regression guard: the package root exposed this child immediately in + # 1.x. Accessing it must not require an explicit import of the child first. + assert dashscope.tokenizers is __import__( + "dashscope.tokenizers", + fromlist=["tokenizers"], + ) + assert "tokenizers" in dir(dashscope) + + +def test_tokenizers_keep_the_historical_legacy_implementation(): + from dashscope.tokenizers import get_tokenizer, list_tokenizers + + assert get_tokenizer.__module__ == ( + "dashscope.legacy_1x.tokenizers.tokenizer" + ) + assert dashscope.get_tokenizer is get_tokenizer + assert dashscope.list_tokenizers is list_tokenizers + for name in ("get_tokenizer", "list_tokenizers"): + assert name in dashscope.__all__ + + +def test_root_uses_current_save_api_key(): + from dashscope.legacy_1x.common.api_key import save_api_key as save_1x + + assert dashscope.save_api_key is _config.save_api_key + assert dashscope.save_api_key is not save_1x + assert dashscope.save_api_key.__module__ == "dashscope._config" + + +def test_quickstart_rebuilds_after_configuration_changes( + monkeypatch, + tmp_path, +): + first_config = _config.resolve_config( + api_key="sk-first", + base_url=BASE_URL, + config_path=tmp_path / "absent.toml", + ) + second_config = replace(first_config, api_key="sk-second") + current = {"config": first_config} + + class FakeClient: + def __init__(self, config): + self.config = config + self.closed = False + + def close(self): + self.closed = True + + monkeypatch.setattr(_quickstart, "_client", None) + monkeypatch.setattr(_quickstart, "_active_clients", {}) + monkeypatch.setattr(_quickstart, "_retired_clients", set()) + monkeypatch.setattr( + _quickstart, + "resolve_config", + lambda: current["config"], + ) + monkeypatch.setattr( + _quickstart, + "_new_client", + lambda config: FakeClient(config), + ) + + first = _quickstart._select_client(acquire=True) + current["config"] = second_config + second = _quickstart._get_client() + + assert second is not first + assert second.config.api_key == "sk-second" + # The old pool stays alive while an earlier call is still using it. + assert not first.closed + _quickstart._release_client(first) + assert first.closed + + +def test_root_save_api_key_roundtrip(tmp_path): + target = tmp_path / "nested" / "api_key" + dashscope.save_api_key("sk-roundtrip", str(target)) + assert target.read_text(encoding="utf-8") == "sk-roundtrip" + + +def test_root_get_tokenizer_encodes_locally(): + tokenizer = dashscope.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 dashscope.list_tokenizers() + with pytest.raises(dashscope.common.error.UnsupportedModel): + dashscope.get_tokenizer("not-a-qwen-model") + + +def test_v2_namespace_is_removed(): + assert not hasattr(dashscope, "v2") + with pytest.raises(ModuleNotFoundError): + __import__("dashscope.v2") + + +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) + monkeypatch.setattr(_quickstart, "_active_clients", {}) + monkeypatch.setattr(_quickstart, "_retired_clients", set()) + monkeypatch.setattr( + _quickstart, + "resolve_config", + lambda: client.config, + ) + + 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/test_protocols.py similarity index 89% rename from tests/v2/test_protocols.py rename to tests/test_protocols.py index 469af0cf..1554e4d4 100644 --- a/tests/v2/test_protocols.py +++ b/tests/test_protocols.py @@ -7,15 +7,15 @@ import httpx import pytest -from dashscope.v2 import Client -from dashscope.v2.errors import BadRequestError, DashScopeError -from dashscope.v2.protocols import ( +from dashscope import Client +from dashscope.errors import BadRequestError, DashScopeError +from dashscope.protocols import ( AnthropicProtocol, NativeProtocol, OpenAIProtocol, get_protocol, ) -from dashscope.v2.protocols.base import ChatRequest +from dashscope.protocols.base import ChatRequest BASE_URL = "https://api.test.local/api/v1" CHAT_PATH = "/api/v1/services/aigc/text-generation/generation" @@ -1227,6 +1227,175 @@ def test_anthropic_stream_usage_keeps_cache_counters(): assert ended.usage.cache_read_input_tokens == 64 +def test_anthropic_rejects_non_text_system_parts(): + protocol = AnthropicProtocol() + with pytest.raises(ValueError, match="system messages support text"): + protocol.build_chat_request( + model="qwen3-max", + messages=[ + { + "role": "system", + "content": [{"type": "image", "url": "https://x"}], + }, + {"role": "user", "content": "hi"}, + ], + ) + + +def test_anthropic_preserves_interleaved_thinking_blocks_for_replay(): + protocol = AnthropicProtocol() + payload = { + "id": "msg-1", + "content": [ + {"type": "thinking", "thinking": "a", "signature": "sig-a"}, + {"type": "text", "text": "middle"}, + {"type": "thinking", "thinking": "b", "signature": "sig-b"}, + ], + } + message = protocol.parse_completion(payload).choices[0].message + assert message.reasoning_signature is None + assert [b["signature"] for b in message.reasoning_blocks] == [ + "sig-a", + "sig-b", + ] + request = protocol.build_chat_request( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}, message.model_dump()], + ) + assert ( + request.body["messages"][1]["content"][:2] == message.reasoning_blocks + ) + + +def test_anthropic_streaming_preserves_interleaved_thinking_blocks(): + # Streaming parity with parse_completion: an interleaved-thinking + # stream must yield verbatim reasoning_blocks and must NOT merge the + # per-block signatures into one invalid reasoning_signature. + from dashscope.resources.chat.completions import _aggregate + + protocol = AnthropicProtocol() + parse = protocol.make_chunk_parser() + events = [ + {"type": "message_start", "message": {"id": "msg-1"}}, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking"}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "thinking_delta", "thinking": "a"}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "sig-a"}, + }, + { + "type": "content_block_start", + "index": 1, + "content_block": {"type": "text"}, + }, + { + "type": "content_block_delta", + "index": 1, + "delta": {"type": "text_delta", "text": "middle"}, + }, + { + "type": "content_block_start", + "index": 2, + "content_block": {"type": "thinking"}, + }, + { + "type": "content_block_delta", + "index": 2, + "delta": {"type": "thinking_delta", "thinking": "b"}, + }, + { + "type": "content_block_delta", + "index": 2, + "delta": {"type": "signature_delta", "signature": "sig-b"}, + }, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + }, + ] + chunks = [c for c in (parse(e) for e in events) if c is not None] + message = _aggregate(chunks).choices[0].message + assert message.content == "middle" + assert message.reasoning_content == "ab" + # Two signed blocks -> no single replayable signature. + assert message.reasoning_signature is None + assert [b["signature"] for b in message.reasoning_blocks] == [ + "sig-a", + "sig-b", + ] + # The streamed turn replays through the same path as the parsed one. + request = protocol.build_chat_request( + model="qwen3-max", + messages=[{"role": "user", "content": "hi"}, message.model_dump()], + ) + assert ( + request.body["messages"][1]["content"][:2] == message.reasoning_blocks + ) + + +def test_anthropic_streaming_single_signed_block_keeps_signature(): + from dashscope.resources.chat.completions import _aggregate + + protocol = AnthropicProtocol() + parse = protocol.make_chunk_parser() + events = [ + {"type": "message_start", "message": {"id": "msg-2"}}, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "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": "message_delta", "delta": {"stop_reason": "end_turn"}}, + ] + chunks = [c for c in (parse(e) for e in events) if c is not None] + message = _aggregate(chunks).choices[0].message + assert message.reasoning_signature == "sig" + assert message.reasoning_blocks == [ + {"type": "thinking", "thinking": "hmm", "signature": "sig"}, + ] + + +def test_anthropic_streaming_surfaces_redacted_thinking(): + from dashscope.resources.chat.completions import _accumulate_chunks + + protocol = AnthropicProtocol() + parse = protocol.make_chunk_parser() + events = [ + {"type": "message_start", "message": {"id": "msg-3"}}, + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "redacted_thinking", "data": "enc"}, + }, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}}, + ] + chunks = [c for c in (parse(e) for e in events) if c is not None] + cumulative = list(_accumulate_chunks(iter(chunks))) + final = cumulative[-1].choices[0].delta + assert (final.model_extra or {}).get("reasoning_blocks") == [ + {"type": "redacted_thinking", "data": "enc"}, + ] + + def test_anthropic_parse_completion_thinking(): # Parity with the streaming thinking_delta path: thinking blocks # surface as reasoning_content instead of being silently dropped, diff --git a/tests/v2/test_rl.py b/tests/test_rl.py similarity index 94% rename from tests/v2/test_rl.py rename to tests/test_rl.py index 29e189b8..1943a626 100644 --- a/tests/v2/test_rl.py +++ b/tests/test_rl.py @@ -10,16 +10,16 @@ import httpx import pytest -from dashscope.finetune import agentic_rl as agentic_rl_module -from dashscope.finetune.reinforcement.common.errors import ( +from dashscope.applications.finetune import agentic_rl as agentic_rl_module +from dashscope.applications.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 +from dashscope import AsyncClient, Client +from dashscope.errors import DashScopeError, SdkError +from dashscope.types.common import JobStatus BASE_URL = "https://api.test.local/api/v1" @@ -644,12 +644,12 @@ def test_rl_long_form_alias(): assert async_client.reinforcement_learning is async_client.rl -# -- 1.x exceptions translated into the v2 error family ----------------- +# -- RL exceptions are native members of 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``.""" + """Make one named step of the fake raise ``exc``.""" # Steps the mount layer invokes as ``AgenticRL`` classmethods. classmethod_steps = { @@ -677,24 +677,28 @@ def _sfail(first, **kwargs): return _set -def test_jobs_create_translates_1x_failure(fail_step): - fail_step("run", RuntimeErrorWithCode("submit blew up", error_code=3002)) +def test_jobs_create_propagates_the_native_failure(fail_step): + original = RuntimeErrorWithCode("submit blew up", error_code=3002) + fail_step("run", original) 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 is original assert err.code == "sdk.agentic_rl.RuntimeErrorWithCode" + assert err.error_code == 3002 # 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")) +def test_native_error_is_catchable_as_dashscope_error(fail_step): + original = OSSUploadError("bucket denied") + fail_step("upload_datasets", original) client = _make_client(lambda request: httpx.Response(200, json={})) with pytest.raises(DashScopeError) as ei: client.rl.upload_data(training_files=["t.jsonl"]) + assert ei.value is original assert isinstance(ei.value, SdkError) @@ -709,9 +713,7 @@ def test_exact_catalog_hit_carries_its_own_solution(fail_step): 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. +def test_rl_base_class_uses_the_family_entry(fail_step): fail_step("register_functions", AgenticRLError("something odd")) client = _make_client(lambda request: httpx.Response(200, json={})) with pytest.raises(SdkError) as ei: @@ -719,7 +721,7 @@ def test_uncatalogued_rl_class_falls_back_to_the_family_entry(fail_step): assert ei.value.code == "sdk.agentic_rl.Error" -def test_test_functions_translates(fail_step): +def test_test_functions_propagates_native_error(fail_step): fail_step("test_functions", InstanceQueryError("no instance")) client = _make_client(lambda request: httpx.Response(200, json={})) with pytest.raises(SdkError) as ei: @@ -731,7 +733,7 @@ def test_test_functions_translates(fail_step): assert ei.value.code == "sdk.agentic_rl.InstanceQueryError" -def test_function_instance_delete_translates(fail_step): +def test_function_instance_delete_propagates_native_error(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: @@ -740,8 +742,6 @@ def test_function_instance_delete_translates(fail_step): 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: @@ -750,16 +750,18 @@ def test_non_rl_exceptions_pass_through(fail_step): @pytest.mark.asyncio -async def test_async_jobs_create_translates(fail_step): - fail_step("run", RuntimeErrorWithCode("submit blew up", error_code=3002)) +async def test_async_jobs_create_propagates_native_error(fail_step): + original = RuntimeErrorWithCode("submit blew up", error_code=3002) + fail_step("run", original) 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 is original assert ei.value.code == "sdk.agentic_rl.RuntimeErrorWithCode" @pytest.mark.asyncio -async def test_async_register_functions_translates(fail_step): +async def test_async_register_functions_propagates_native_error(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: diff --git a/tests/v2/test_settings.py b/tests/test_settings.py similarity index 96% rename from tests/v2/test_settings.py rename to tests/test_settings.py index 2fe0e55a..e150c845 100644 --- a/tests/v2/test_settings.py +++ b/tests/test_settings.py @@ -11,13 +11,13 @@ import pytest -from dashscope.v2 import settings -from dashscope.v2._config import ( +from dashscope import settings +from dashscope._config import ( DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, resolve_config, ) -from dashscope.v2._logging import ROOT_NAME +from dashscope._logging import ROOT_NAME @pytest.fixture @@ -156,6 +156,8 @@ def test_setter_validation(clean): settings.timeout = 0 with pytest.raises(ValueError, match="max_retries"): settings.max_retries = -1 + with pytest.raises(TypeError, match="base_url must be a string"): + settings.base_url = 123 def test_log_level_setter_applies_immediately(clean): @@ -187,7 +189,7 @@ def test_websocket_base_url_lazy_env(clean): def test_ws_session_uses_settings_url(clean): - from dashscope.v2.transport.ws import WsSession + from dashscope.transport.ws import WsSession captured = {} diff --git a/tests/v2/test_sse.py b/tests/test_sse.py similarity index 97% rename from tests/v2/test_sse.py rename to tests/test_sse.py index b57fdc41..eb49c047 100644 --- a/tests/v2/test_sse.py +++ b/tests/test_sse.py @@ -12,18 +12,19 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import ( +from dashscope import AsyncClient, Client +from dashscope.errors import ( ContentFilterError, DashScopeError, + InternalServerError, RateLimitError, ) -from dashscope.v2.resources._sse import _aiter_sse, _iter_sse, _stream_error -from dashscope.v2.resources.audio.http_tts import ( +from dashscope.resources._sse import _aiter_sse, _iter_sse, _stream_error +from dashscope.resources.audio.http_tts import ( _audio_stream_error, _iter_audio_sse, ) -from dashscope.v2.transport.sse import ( +from dashscope.transport.sse import ( parse_event_data, stream_error, ) @@ -600,8 +601,11 @@ def test_audio_sse_sync_malformed_json_response(): content=b'{"output":', headers={"content-type": "application/json"}, ) - with pytest.raises(ValueError): + with pytest.raises(InternalServerError) as caught: list(_iter_audio_sse(response)) + assert caught.value.code == "InternalServerError" + assert caught.value.status_code == 500 + assert isinstance(caught.value.__cause__, ValueError) @pytest.mark.asyncio diff --git a/tests/v2/test_sse_comprehensive_verify.py b/tests/test_sse_comprehensive_verify.py similarity index 93% rename from tests/v2/test_sse_comprehensive_verify.py rename to tests/test_sse_comprehensive_verify.py index 404b39b6..7a923af6 100644 --- a/tests/v2/test_sse_comprehensive_verify.py +++ b/tests/test_sse_comprehensive_verify.py @@ -11,8 +11,8 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import ( +from dashscope import AsyncClient, Client +from dashscope.errors import ( DashScopeError, RateLimitError, ) @@ -410,28 +410,10 @@ def handler(request): 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. - """ +def test_package_root_exports_errors(): import dashscope + from dashscope.errors import ErrorCode - # 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 dashscope.errors.ErrorCode is ErrorCode + assert "errors" in dashscope.__all__ assert ErrorCode.INVALID_API_KEY.value == "InvalidApiKey" diff --git a/tests/v2/test_tingwu.py b/tests/test_tingwu.py similarity index 99% rename from tests/v2/test_tingwu.py rename to tests/test_tingwu.py index 8d6c2e14..e4f42e1b 100644 --- a/tests/v2/test_tingwu.py +++ b/tests/test_tingwu.py @@ -12,8 +12,8 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.errors import DashScopeError +from dashscope import AsyncClient, Client +from dashscope.errors import DashScopeError BASE_URL = "https://api.test.local/api/v1" CALL_PATH = "/api/v1/services/aigc/multimodal-generation/generation" diff --git a/tests/v2/test_tools_resources.py b/tests/test_tools_resources.py similarity index 99% rename from tests/v2/test_tools_resources.py rename to tests/test_tools_resources.py index 94e33102..50e45959 100644 --- a/tests/v2/test_tools_resources.py +++ b/tests/test_tools_resources.py @@ -9,8 +9,8 @@ import httpx import pytest -from dashscope.v2 import AsyncClient, Client -from dashscope.v2.resources.embeddings import parse_embedding_response +from dashscope import AsyncClient, Client +from dashscope.resources.embeddings import parse_embedding_response BASE_URL = "https://api.test.local/api/v1" diff --git a/tests/v2/test_transport.py b/tests/test_transport.py similarity index 83% rename from tests/v2/test_transport.py rename to tests/test_transport.py index faf964f1..805a0d1c 100644 --- a/tests/v2/test_transport.py +++ b/tests/test_transport.py @@ -10,17 +10,17 @@ import httpx import pytest -from dashscope.v2.errors import ( +from dashscope.errors import ( APIConnectionError, APIStatusError, APITimeoutError, BadRequestError, response_error, ) -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 +from dashscope.transport import AsyncTransport, SyncTransport +from dashscope.transport import http as transport_http +from dashscope.transport._ident import SDK_CLIENT_VALUE +from dashscope.transport._retry import backoff_delay, retry_after_seconds BASE_URL = "https://api.test.local" @@ -133,6 +133,8 @@ def test_connection_error_retried_then_raises(no_sleep): with pytest.raises(APIConnectionError) as exc_info: t.request("GET", "/x") assert isinstance(exc_info.value.__cause__, httpx.ConnectError) + assert exc_info.value.code == "sdk.ConnectionError" + assert exc_info.value.allow_retry is True assert rec.calls == 3 assert len(no_sleep) == 2 @@ -148,16 +150,19 @@ def test_timeout_maps_to_api_timeout_error(no_sleep): t.request("GET", "/x") assert isinstance(exc_info.value, APIConnectionError) assert isinstance(exc_info.value.__cause__, httpx.ConnectTimeout) + assert exc_info.value.code == "sdk.Timeout" + assert exc_info.value.allow_retry is True assert rec.calls == 3 -def test_open_stream_transport_error_mapped(no_sleep): +def test_open_stream_transport_error_mapped_after_retries(no_sleep): rec = _Recorder() - t = _make_sync(rec, [httpx.ConnectError("boom")], max_retries=2) - with pytest.raises(APIConnectionError): + errors = [httpx.ConnectError("boom") for _ in range(3)] + t = _make_sync(rec, errors, max_retries=2) + with pytest.raises(APIConnectionError) as exc_info: t.open_stream("POST", "/x") - # Streams are never retried. - assert rec.calls == 1 + assert exc_info.value.code == "sdk.ConnectionError" + assert rec.calls == 3 _SSE_ERROR_BODY = ( @@ -242,8 +247,59 @@ def test_default_headers(no_sleep): assert sent["user-agent"] == "ua-test" +def test_injected_http_client_still_receives_client_configuration(no_sleep): + rec = _Recorder() + http_client = httpx.Client( + transport=httpx.MockTransport(rec.handler([])), + base_url="https://wrong.test/root", + ) + transport = SyncTransport( + base_url=BASE_URL, + api_key="sk-configured", + workspace="ws-configured", + user_agent="ua-configured", + timeout=17.0, + http_client=http_client, + ) + transport.request("GET", "/x") + request = rec.requests[0] + assert request.url == f"{BASE_URL}/x" + assert request.headers["authorization"] == "Bearer sk-configured" + assert request.headers["x-dashscope-workspace"] == "ws-configured" + assert request.headers["user-agent"] == "ua-configured" + assert request.extensions["timeout"]["read"] == 17.0 + http_client.close() + + +@pytest.mark.asyncio +async def test_injected_async_client_still_receives_client_configuration( + no_sleep, +): + rec = _Recorder() + http_client = httpx.AsyncClient( + transport=httpx.MockTransport(rec.handler([])), + base_url="https://wrong.test/root", + ) + transport = AsyncTransport( + base_url=BASE_URL, + api_key="sk-configured", + workspace="ws-configured", + user_agent="ua-configured", + timeout=19.0, + http_client=http_client, + ) + await transport.request("GET", "/x") + request = rec.requests[0] + assert request.url == f"{BASE_URL}/x" + assert request.headers["authorization"] == "Bearer sk-configured" + assert request.headers["x-dashscope-workspace"] == "ws-configured" + assert request.headers["user-agent"] == "ua-configured" + assert request.extensions["timeout"]["read"] == 19.0 + await http_client.aclose() + + def test_build_headers_and_timeout(): - from dashscope.v2.transport.http import _build_headers, _resolve_timeout + from dashscope.transport.http import _build_headers, _resolve_timeout headers = _build_headers("sk-1", "ws-1", "ua") assert headers == { @@ -348,8 +404,8 @@ def test_realtime_session_sends_sdk_headers(monkeypatch): # 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 ( + from dashscope.resources.audio.realtime import RealtimeSession + from dashscope.transport._ident import ( sdk_client_value, sdk_session_id, ) @@ -430,8 +486,8 @@ async def test_async_sdk_identification_headers(no_sleep): def test_sdk_session_id_cached_per_process(): - from dashscope.common import _sdk_session - from dashscope.v2.transport import _ident + from dashscope._shared import _sdk_session + from dashscope.transport import _ident original = _sdk_session._session_id try: @@ -447,8 +503,8 @@ def test_sdk_session_id_cached_per_process(): def test_ws_session_sends_sdk_headers(): - from dashscope.v2.transport._ident import sdk_session_id - from dashscope.v2.transport.ws import WsSession + from dashscope.transport._ident import sdk_session_id + from dashscope.transport.ws import WsSession captured = {} @@ -630,6 +686,36 @@ async def test_async_call_auth_wins_case_insensitively(method, lowercase): assert second.headers.get_list("x-dashscope-workspace") == ["pool-ws"] +def test_sync_open_stream_retries_initial_status(no_sleep): + rec = _Recorder() + transport = _make_sync(rec, [(503, {}), (200, {})], max_retries=1) + response = transport.open_stream("GET", "/x") + try: + assert response.status_code == 200 + assert rec.calls == 2 + finally: + response.close() + + +@pytest.mark.asyncio +async def test_async_open_stream_retries_initial_status(no_sleep): + rec = _Recorder() + mock = httpx.MockTransport(rec.handler([(503, {}), (200, {})])) + http_client = httpx.AsyncClient(transport=mock, base_url=BASE_URL) + transport = AsyncTransport( + base_url=BASE_URL, + http_client=http_client, + max_retries=1, + ) + response = await transport.open_stream("GET", "/x") + try: + assert response.status_code == 200 + assert rec.calls == 2 + finally: + await response.aclose() + await http_client.aclose() + + class _TrackedSyncStream(httpx.SyncByteStream): def __init__(self, read_error): self.read_error = read_error @@ -682,9 +768,18 @@ def handler(request): transport=httpx.MockTransport(handler), base_url=BASE_URL, ) as http_client: - transport = SyncTransport(base_url=BASE_URL, http_client=http_client) + transport = SyncTransport( + base_url=BASE_URL, + http_client=http_client, + max_retries=0, + ) if status >= 400: - with pytest.raises(read_error or APIStatusError) as caught: + expected = APIStatusError + if read_error is httpx.ReadError: + expected = APIConnectionError + elif read_error is httpx.ReadTimeout: + expected = APITimeoutError + with pytest.raises(expected) as caught: transport.open_stream("GET", "/x") if read_error is None: assert caught.value.status_code == status @@ -732,9 +827,18 @@ def handler(request): transport=httpx.MockTransport(handler), base_url=BASE_URL, ) as http_client: - transport = AsyncTransport(base_url=BASE_URL, http_client=http_client) + transport = AsyncTransport( + base_url=BASE_URL, + http_client=http_client, + max_retries=0, + ) if status >= 400: - with pytest.raises(read_error or APIStatusError) as caught: + expected = APIStatusError + if read_error is httpx.ReadError: + expected = APIConnectionError + elif read_error is httpx.ReadTimeout: + expected = APITimeoutError + with pytest.raises(expected) as caught: await transport.open_stream("GET", "/x") if read_error is None: assert caught.value.status_code == status diff --git a/tests/v2/test_types_messages.py b/tests/test_types_messages.py similarity index 99% rename from tests/v2/test_types_messages.py rename to tests/test_types_messages.py index e28c0cc1..fca794f8 100644 --- a/tests/v2/test_types_messages.py +++ b/tests/test_types_messages.py @@ -7,7 +7,7 @@ import pytest from pydantic import ValidationError -from dashscope.v2.types import ( +from dashscope.types import ( AudioPart, ImagePart, Message, @@ -16,7 +16,7 @@ extract_text, normalize_messages, ) -from dashscope.v2.types.messages import has_media_parts +from dashscope.types.messages import has_media_parts def test_plain_string_message(): diff --git a/tests/v2/test_ws_connection.py b/tests/test_ws_connection.py similarity index 92% rename from tests/v2/test_ws_connection.py rename to tests/test_ws_connection.py index 5716cb4c..f018dd08 100644 --- a/tests/v2/test_ws_connection.py +++ b/tests/test_ws_connection.py @@ -10,9 +10,16 @@ import pytest -from dashscope.v2.errors import APIConnectionError, DashScopeError -from dashscope.v2.transport.ws import WsEventError, WsSession -from dashscope.v2.transport.ws_connection import WsConnection +from dashscope.errors import ( + APITimeoutError, + APIConnectionError, + DashScopeError, + WsBinaryOwnerConflictError, + WsQueueCapacityExceededError, + WsStateError, +) +from dashscope.transport.ws import WsEventError, WsSession +from dashscope.transport.ws_connection import WsConnection URL = "wss://ws.test.local/api-ws/v1/inference" @@ -161,7 +168,7 @@ def test_binary_downlink_two_active_tasks_requires_claim(): _started(conn, t1) _started(conn, t2) t2.claim_binary_downlink() - with pytest.raises(RuntimeError, match="binary downlink"): + with pytest.raises(WsBinaryOwnerConflictError, match="binary downlink"): t1.claim_binary_downlink() conn._ws.inbox.put(b"audio-for-t2") assert t2.recv() == b"audio-for-t2" @@ -183,7 +190,7 @@ def test_binary_downlink_ambiguous_fails_connection(): with pytest.raises(WsEventError, match="claim_binary_downlink"): t2.recv() assert conn.closed - with pytest.raises(RuntimeError, match="closed"): + with pytest.raises(WsStateError, match="closed"): conn.open_task() @@ -194,7 +201,7 @@ def test_binary_uplink_single_owner_and_release_on_finish(): _started(conn, t1) _started(conn, t2) t1.send_binary(b"frame-1") - with pytest.raises(RuntimeError, match="binary uplink"): + with pytest.raises(WsBinaryOwnerConflictError, match="binary uplink"): t2.send_binary(b"frame-2") assert conn._ws.sent[-1] == b"frame-1" @@ -220,6 +227,40 @@ def test_socket_error_broadcasts_to_all_tasks(): assert conn.closed +def test_slow_consumer_queue_full_fails_only_that_task(): + """One stalled task must not tear down the shared connection. + + Overflowing t1's bounded receive queue fails t1 alone; t2 keeps + routing and the socket stays open for the remaining tasks. + """ + conn, _ = _make_connection(timeout=5, task_queue_size=2) + t1 = conn.open_task() + t2 = conn.open_task() + _started(conn, t1) + _started(conn, t2) + + # Flood t1 past its queue size without draining it, then a t2 frame. + for n in range(5): + conn._ws.inbox.put( + _event("result-generated", t1.task_id, {"n": n}), + ) + conn._ws.inbox.put(_event("result-generated", t2.task_id, {"n": 99})) + + # t2 is unaffected and the connection survives the overflow. + assert t2.recv_event()["payload"] == {"n": 99} + assert not conn.closed + assert not conn._ws.closed + + # t1 sees the catalog queue-capacity error, isolated to that task. + # Drain any + # frames queued ahead of the overflow marker, then expect the raise. + with pytest.raises(WsQueueCapacityExceededError, match="capacity"): + while True: + t1.recv() + assert not conn.closed + conn.close() + + 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.""" @@ -250,7 +291,7 @@ def test_task_close_detaches_and_late_events_drop(): 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"): + with pytest.raises(WsStateError, match="closed"): t1.recv() conn.close() @@ -302,7 +343,7 @@ def test_finish_task_and_cancel_semantics(): def test_recv_timeout_raises(): conn, _ = _make_connection() task = conn.open_task(timeout=0.05) - with pytest.raises(TimeoutError): + with pytest.raises(APITimeoutError): task.recv() conn.close() @@ -433,7 +474,7 @@ def send(self, payload, opcode=1): assert conn.closed and ws.closed with pytest.raises(APIConnectionError): other.recv() - with pytest.raises(RuntimeError, match="closed"): + with pytest.raises(WsStateError, match="closed"): conn.open_task() task.close() other.close() @@ -547,7 +588,7 @@ def test_inactive_task_cannot_reclaim_binary_downlink( else: conn._ws.inbox.put(_event(terminal, task.task_id)) task.recv_event() - with pytest.raises(RuntimeError): + with pytest.raises(WsStateError): task.claim_binary_downlink() next_task.claim_binary_downlink() conn._ws.inbox.put(b"next-output") @@ -572,7 +613,7 @@ def test_connection_shutdown_releases_binary_owners( task.recv() assert conn._binary_uplink_owner is None assert conn._binary_downlink_owner is None - with pytest.raises(RuntimeError, match="closed"): + with pytest.raises(WsStateError, match="closed"): task.claim_binary_downlink() assert conn._binary_downlink_owner is None @@ -601,9 +642,9 @@ def test_reader_releases_terminal_owners_before_consumer_finishes( 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"): + with pytest.raises(WsStateError, match="not active"): first.claim_binary_downlink() - with pytest.raises(RuntimeError, match="not active"): + with pytest.raises(WsStateError, match="not active"): first.send_binary(b"too late") second.send_binary(b"second-input") if claim: @@ -634,7 +675,7 @@ def test_binary_owner_must_be_attached_to_this_connection( foreign._task_id = ( task.task_id ) # Matching ids alone do not imply attachment. - with pytest.raises(RuntimeError, match="not active"): + with pytest.raises(WsStateError, match="not active"): getattr(conn, "_claim_binary_" + direction)(foreign) assert getattr(conn, "_binary_" + direction + "_owner") is None task.claim_binary_downlink() @@ -676,7 +717,7 @@ def receive(): receiver.join(timeout=1) assert not receiver.is_alive(), "close did not wake the receiver" expected = ( - RuntimeError if close_target == "task" else APIConnectionError + WsStateError if close_target == "task" else APIConnectionError ) assert len(errors) == 1 and isinstance(errors[0], expected), errors finally: @@ -742,7 +783,7 @@ def test_error_is_persistent_but_preserves_earlier_frames( 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 + expected = WsStateError if shutdown == "task" else APIConnectionError else: conn._ws.inbox.put( OSError("receive failed") if shutdown == "recv" else "{", @@ -765,7 +806,7 @@ def test_task_handshake_timeout_detaches_without_killing_connection( ): conn = bounded_connection() task = conn.open_task(timeout=0.02) - with pytest.raises(TimeoutError): + with pytest.raises(APITimeoutError): task.run_task( task_group="audio", task="asr", @@ -788,7 +829,7 @@ def test_task_timeout_is_isolated_and_explicit_close_releases_owners( _started(conn, second) first.send_binary(b"first") first.claim_binary_downlink() - with pytest.raises(TimeoutError): + with pytest.raises(APITimeoutError): first.recv() first.close() second.send_binary(b"second") diff --git a/tests/v2/test_package_root.py b/tests/v2/test_package_root.py deleted file mode 100644 index f363ed69..00000000 --- a/tests/v2/test_package_root.py +++ /dev/null @@ -1,114 +0,0 @@ -# -*- 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