diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 9beeb4e9..d6c14d06 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -15,8 +15,19 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + # ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the + # still-supported 3.9 leg to 24.04 and keep the rest on latest. + include: + - os: ubuntu-24.04 + python-version: '3.9' + - os: ubuntu-latest + python-version: '3.10' + - os: ubuntu-latest + python-version: '3.11' + - os: ubuntu-latest + python-version: '3.12' + - os: ubuntu-latest + python-version: '3.13' steps: - name: Checkout repository @@ -68,8 +79,19 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + # ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the + # still-supported 3.9 leg to 24.04 and keep the rest on latest. + include: + - os: ubuntu-24.04 + python-version: '3.9' + - os: ubuntu-latest + python-version: '3.10' + - os: ubuntu-latest + python-version: '3.11' + - os: ubuntu-latest + python-version: '3.12' + - os: ubuntu-latest + python-version: '3.13' steps: - name: Checkout repository diff --git a/README.md b/README.md index 3ed6ff90..6cecb3a4 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,26 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built - 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). +## Command line + +`pip install -U dashscope` puts two independent commands on your `PATH`. `dashscope` serves the SDK-shaped **2.0** command tree; `dashscope1` serves the frozen **1.x** implementation: + +| Entry point | Implementation | Examples | +|-------------|----------------|----------| +| `dashscope` | 2.0, following the SDK resource tree | `chat create`, `apps completions create`, `multimodal chat create`, `rl jobs create`, `rl functions register`, `images jobs create`, `audio asr transcribe`, `agentstudio sessions events send` | +| `dashscope1` | 1.x, using the 1.x SDK | `generation call`, `application call`, `image-synthesis create`, `multimodal-conversation call`, `fine-tunes call`, `agentic-rl run`, `auth login`, `oss upload` | + +The two CLI trees do not dispatch to or normalize arguments for each other. Running a 1.x-only command through `dashscope` exits with a usage error and points to `dashscope1`. Their module entry points are likewise independent: + +```bash +python -m dashscope.cli apps completions create ... +python -m dashscope.legacy_1x.cli generation call ... +``` + +Running `dashscope` with no arguments starts the [AI assistant](#ai-assistant-dashscope-sdk-expert) instead of the CLI. + +For the complete 2.0 CLI command map, output formats, exit codes, configuration precedence and CI recipes, see the CLI section in [`examples/README.md`](examples/README.md). + ## Shell completion Run the command for your shell once, then restart the shell (or re-source your config): @@ -453,6 +473,8 @@ Preview the script without installing: dashscope --show-completion bash ``` +Completion is installed through the 2.0 `dashscope` entry point. + ## Logging Set the level via settings or the environment: @@ -475,10 +497,11 @@ examples/ ├── 02_advanced_engineering/ Mock debugging, semantic cache, circuit breaker + multi-region DR ├── 03_agent_recipes/ Tool-call loop, RAG, MCP server, AgentStudio ├── 04_real_world_scenarios/ End-to-end production harnesses (RAG, realtime meetings, customer service) +├── 05_cli/ Offline CLI output/error and subprocess CI harnesses └── legacy_1x/ 1.x module-level API examples, for reference ``` -See [`examples/README.md`](examples/README.md). Several flagship cases run fully offline (mock transports) with no API key. +See [`examples/README.md`](examples/README.md) for the full guide, including the complete CLI command map and shell/CI recipes. Several flagship cases and both CLI harnesses run fully offline with no API key. ## Migrating from 1.x @@ -496,6 +519,8 @@ 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")` | +The CLI follows the same split — see [Command line](#command-line) above. + ### 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: diff --git a/README_zh.md b/README_zh.md index ff02e8b9..cbfd4ad4 100644 --- a/README_zh.md +++ b/README_zh.md @@ -437,6 +437,26 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 - 通过 `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)。 +## 命令行 + +`pip install -U dashscope` 会安装两个相互独立的命令。`dashscope` 提供按 SDK 资源树设计的 **2.0** 命令;`dashscope1` 提供冻结的 **1.x** 实现: + +| 入口 | 实现 | 示例 | +|------|------|------| +| `dashscope` | 2.0,对齐 SDK 资源树 | `chat create`、`apps completions create`、`multimodal chat create`、`rl jobs create`、`rl functions register`、`images jobs create`、`audio asr transcribe`、`agentstudio sessions events send` | +| `dashscope1` | 1.x,调用 1.x SDK | `generation call`、`application call`、`image-synthesis create`、`multimodal-conversation call`、`fine-tunes call`、`agentic-rl run`、`auth login`、`oss upload` | + +两套 CLI 不会相互分发命令或转换参数。通过 `dashscope` 运行 1.x 专有命令会返回用法错误,并提示改用 `dashscope1`。两者的模块入口也相互独立: + +```bash +python -m dashscope.cli apps completions create ... +python -m dashscope.legacy_1x.cli generation call ... +``` + +不带参数运行 `dashscope` 会启动 [AI 助手](#ai-助手dashscope-sdk-expert),而不是 CLI。 + +完整的 2.0 CLI 命令地图、输出格式、退出码、配置优先级和 CI 配方,见 [`examples/README_zh.md`](examples/README_zh.md) 的命令行章节。 + ## Shell 命令补全 针对你的 Shell 运行一次对应命令,然后重启 Shell(或重新 source 配置文件): @@ -453,6 +473,8 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 dashscope --show-completion bash ``` +命令补全通过 2.0 的 `dashscope` 入口安装。 + ## 日志 通过设置或环境变量配置日志级别: @@ -475,10 +497,11 @@ examples/ ├── 02_advanced_engineering/ 本地 mock 调试、语义缓存、熔断 + 多地域容灾 ├── 03_agent_recipes/ 工具调用循环、RAG、MCP server、AgentStudio ├── 04_real_world_scenarios/ 端到端生产级 harness(RAG、实时会议、智能客服) +├── 05_cli/ 离线 CLI 输出/错误契约与子进程 CI 示例 └── legacy_1x/ 1.x 模块级 API 示例,仅供参考 ``` -详见 [`examples/README_zh.md`](examples/README_zh.md)。其中若干旗舰案例可完全离线运行(mock 传输),无需 API Key。 +完整指南见 [`examples/README_zh.md`](examples/README_zh.md),其中包含 CLI 命令地图和 shell/CI 配方。若干旗舰案例与两个 CLI 示例都可完全离线运行,无需 API Key。 ## 从 1.x 迁移 @@ -496,6 +519,8 @@ examples/ | `dashscope.set_region(region, workspace_id)` | `dashscope.Client(region=..., workspace=...)` | | OpenAI 兼容通过 `dashscope.aigc.chat_completion.Completions` | `dashscope.Client(protocol="openai")` | +CLI 也遵循同样的拆分 —— 见上方[命令行](#命令行)一节。 + ### 包布局与兼容策略 2.0 实现直接位于 `dashscope` 根包,原 `dashscope.v2` 命名空间已移除。 diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 450c9eb3..893676f1 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -166,7 +166,6 @@ def set_region(region: str, workspace_id: str = None): "applications", "assistants", "audio", - "cli", "common", "embeddings", "files", diff --git a/dashscope/_shared/_sdk_ident.py b/dashscope/_shared/_sdk_ident.py index 83fa7de5..96090877 100644 --- a/dashscope/_shared/_sdk_ident.py +++ b/dashscope/_shared/_sdk_ident.py @@ -26,7 +26,7 @@ (``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 +(``dashscope-sdk-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 diff --git a/dashscope/acli/expert.py b/dashscope/acli/expert.py new file mode 100644 index 00000000..fbb97846 --- /dev/null +++ b/dashscope/acli/expert.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +"""Shared DashScope SDK Expert adapter for both console entry points.""" + +import sys +from pathlib import Path + +from rich.console import Console + +import dashscope + +err_console = Console(stderr=True) + +_MD_MARKER = "" +_PY_MARKER = "# managed-by: dashscope-expert" +_EXPERT_EXAMPLE = "dashscope-sdk-expert" + + +def _cleanup_legacy_expert_sync(): + """Remove files the deleted expert bundling synced into ~/.acli. + + Only files carrying the managed marker are deleted — without it the file + is user-owned and left untouched. Needed so stale global copies don't + shadow the workspace ./.acli/ configs installed via example download. + """ + try: + from dashscope.acli.config import CONFIG_DIR + except ImportError: + return + md_targets = [] + for subdir in ("skills", "references"): + target_dir = CONFIG_DIR / subdir + if target_dir.is_dir(): + md_targets.extend(target_dir.glob("*.md")) + for path in md_targets: + try: + if _MD_MARKER in path.read_text(encoding="utf-8"): + path.unlink() + except OSError: + pass + build = CONFIG_DIR / "build_sdk_index.py" + try: + if build.is_file() and _PY_MARKER in build.read_text(encoding="utf-8"): + build.unlink() + except OSError: + pass + + +def _maybe_offer_example_download(): + """First interactive run without ./.acli: offer the SDK-expert example. + + Accepting merges the example into ./.acli/ via the same code path as + `example download` (copied from the packaged install dir when present); + declining writes a marker into ./.acli/ so we never ask again here. + """ + if not sys.stdin.isatty() or (Path.cwd() / ".acli").is_dir(): + return + try: + from dashscope.acli.cli.examples import _handle_example_command + from dashscope.acli.cli.handlers_key import _GUIDE_DOC, _doc_locale + except ImportError: + return + err_console.print( + "[cyan]First run:[/cyan] download the dashscope-sdk-expert " + "example config into ./.acli/\n" + "(SDK Q&A expert persona + skill templates + SDK knowledge " + "index; pure config, editable anytime).", + ) + err_console.print( + f"[dim]Guide: {_GUIDE_DOC.format(_doc_locale())}[/dim]", + ) + try: + answer = input("Download the example config? [Y/n] ").strip().lower() + except (EOFError, KeyboardInterrupt): + err_console.print() + return + if answer in ("n", "no"): + try: + marker = Path.cwd() / ".acli" / ".dashscope-example-declined" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("declined\n", encoding="utf-8") + except OSError: + pass + err_console.print( + "[dim]Skipped. To customize later, run: " + "python -m dashscope.acli example download " + "dashscope-sdk-expert[/dim]", + ) + return + _handle_example_command(["download", _EXPERT_EXAMPLE]) + + +def _route_to_expert(command, tui=None): + """Run the vendored acli agent (bare ``dashscope`` or ``dashscope expert``). + + ``command`` is a one-shot prompt, or None for the interactive REPL. + """ + try: + from dashscope.acli.cli.handlers_key import _GUIDE_DOC, _doc_locale + from dashscope.acli.ui.embedded import run + except ImportError as exception: + err_console.print( + "[yellow]Tip:[/yellow] Install dashscope[acli] to enable " + "the agent " + f"({exception}).", + ) + return + + if command is None and not sys.stdin.isatty(): + # The interactive REPL needs a real terminal; prompt_toolkit crashes + # on non-tty stdin (e.g. kqueue rejects /dev/null with EINVAL). + # Treat piped text as a one-shot prompt instead. + try: + piped = sys.stdin.read().strip() + except OSError: + piped = "" + if not piped: + err_console.print( + "[red]Error:[/red] Interactive mode requires a terminal. " + 'Pass a prompt instead, e.g. dashscope expert "your question".', + ) + sys.exit(2) + command = piped + + _cleanup_legacy_expert_sync() + if command is None: + _maybe_offer_example_download() + + try: + run( + app_name="DashScope SDK Expert", + prompt_symbol="dashscope> ", + api_key=dashscope.api_key or None, + command=command, + tui=tui, + module="expert", + guide_url=_GUIDE_DOC.format(_doc_locale()), + ) + except SystemExit: + pass + except Exception as exception: # pylint: disable=broad-except + err_console.print( + f"[yellow]Tip:[/yellow] Agent failed to start ({exception}).", + ) diff --git a/dashscope/cli/__init__.py b/dashscope/cli/__init__.py new file mode 100644 index 00000000..83fd9de9 --- /dev/null +++ b/dashscope/cli/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The dashscope CLI (v2). + +Deliberately empty. ``import dashscope.cli`` runs for shell completion and +``--help``, so it must not pull in the router, Typer, or the SDK. + +There is no ``main`` re-export here on purpose: :mod:`dashscope.cli.main` +is a submodule, and binding the same name on the package would make +``from dashscope.cli import main`` resolve to a function or a module +depending on import order. The console script names the module directly +(``dashscope.cli.main:main``). +""" diff --git a/dashscope/cli/__main__.py b/dashscope/cli/__main__.py new file mode 100644 index 00000000..2f9efd0a --- /dev/null +++ b/dashscope/cli/__main__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``python -m dashscope.cli``.""" + +from dashscope.cli.main import main + +if __name__ == "__main__": + main() diff --git a/dashscope/cli/app.py b/dashscope/cli/app.py new file mode 100644 index 00000000..d6253efe --- /dev/null +++ b/dashscope/cli/app.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The root Typer app: global options, ``version``, and the command groups. + +Global options follow click's convention and precede the subcommand:: + + dashscope -k sk-xxx --output json chat create -m qwen-plus --prompt hi + +The 1.x command tree is installed separately as ``dashscope1`` and is never +imported or executed by this app. +""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from dashscope.cli.context import CliContext, GlobalOptions +from dashscope.cli.errors import ErrorHandlingGroup, usage_error +from dashscope.cli.output import OUTPUT_FORMATS + +# `\[` is Rich markup escaping: unescaped, Rich reads the bracketed run as a +# style tag and drops it from the rendered help. The string is raw so the +# backslash survives to Rich -- which also means it cannot open with `"""\`, +# since a raw string keeps that backslash too. +_HELP = r"""Manage DashScope resources from the command line. + +Global options come before the command: `dashscope -k sk-xxx --output json +ft list`. Errors go to stderr as `Error \[category]: message` and the exit +code says which kind: 0 ok, 1 server/rate-limit/network, 2 bad command +line, 3 auth, 4 not found. +""" + + +def _epilog() -> str: + return ( + "CLI 1.x commands such as `generation create` and `application create` " + "have moved to `dashscope1`. A bare `dashscope` opens the bundled " + "`expert` agent interactively." + ) + + +def build_app() -> typer.Typer: + """Assemble the v2 CLI. Called once per process by ``main()``.""" + app = typer.Typer( + name="dashscope", + cls=ErrorHandlingGroup, + help=_HELP, + epilog=_epilog(), + add_completion=True, + no_args_is_help=True, + rich_markup_mode="rich", + context_settings={"help_option_names": ["-h", "--help"]}, + ) + + @app.callback() + def root( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + api_key: Optional[str] = typer.Option( + None, + "-k", + "--api-key", + help="DashScope API key. Overrides the environment and config file.", + ), + protocol: Optional[str] = typer.Option( + None, + "--protocol", + help="Wire protocol: native, openai or anthropic.", + ), + base_url: Optional[str] = typer.Option( + None, + "--base-url", + help="Override the service base URL.", + ), + workspace: Optional[str] = typer.Option( + None, + "--workspace", + help="DashScope workspace (business space) id.", + ), + region: Optional[str] = typer.Option( + None, + "--region", + help="Service region, e.g. cn-beijing or ap-southeast-1.", + ), + timeout: Optional[float] = typer.Option( + None, + "--timeout", + help="Per-request timeout in seconds.", + ), + output: Optional[str] = typer.Option( + None, + "-o", + "--output", + help="Output format. Machine formats keep stdout parseable.", + ), + config_override: Optional[List[str]] = typer.Option( + None, + "-c", + metavar="KEY=VALUE", + help=( + "Override one config key for this invocation; repeatable. " + "SDK keys (api_key, protocol, timeout, ...) are passed to " + "the client, CLI keys (output, default_model) set defaults." + ), + ), + no_color: bool = typer.Option( + False, + "--no-color", + help="Disable colored output. Also honours $NO_COLOR.", + ), + verbose: bool = typer.Option( + False, + "-v", + "--verbose", + help="Show request metadata and tracebacks on failure.", + ), + ) -> None: + """Configure global options.""" + if output is not None and output not in OUTPUT_FORMATS: + raise usage_error( + f"invalid --output {output!r}, expected one of " + f"{', '.join(OUTPUT_FORMATS)}", + ) + ctx.obj = CliContext( + GlobalOptions( + api_key=api_key, + protocol=protocol, + base_url=base_url, + workspace=workspace, + region=region, + timeout=timeout, + output=output, + overrides=tuple(config_override or ()), + no_color=no_color, + verbose=verbose, + ), + ) + # Resolve the -c overrides now rather than on first SDK use, so a + # misspelled key fails on every command -- including ones like + # `version` that never touch the service. This reads no files and + # builds no client. + ctx.obj.sdk_overrides() + + @app.command() + def version(ctx: typer.Context) -> None: + """Print the installed dashscope version.""" + # Imported here rather than at module scope: this command is the + # cheapest thing the CLI does and should stay that way. + from dashscope.version import ( + __version__, + ) # pylint: disable=import-outside-toplevel + + renderer = ctx.obj.renderer + if renderer.table_mode: + renderer.echo(__version__) + else: + renderer.print_object({"version": __version__}) + + # Command modules are thin by contract (no SDK import at module + # scope), so mounting them all here costs nothing; the expensive part + # is the client, which stays lazy behind ``ctx.obj.client``. + from dashscope.cli.commands import ( + agentstudio as agentstudio_command, + apps as apps_command, + audio as audio_command, + auth as auth_command, + chat as chat_command, + config as config_command, + decision as decision_command, + deployments as deployments_command, + embeddings as embeddings_command, + files as files_command, + ft as ft_command, + images as images_command, + models as models_command, + multimodal as multimodal_command, + oss as oss_command, + rerank as rerank_command, + rl as rl_command, + tingwu as tingwu_command, + tokenization as tokenization_command, + videos as videos_command, + ) + + app.add_typer(auth_command.build_app(), name="auth") + app.add_typer(chat_command.build_app(), name="chat") + app.add_typer(ft_command.build_app(), name="ft") + # Hidden alias, following the 1.x tree's convention: the long name stays + # usable but out of the way. Built twice so the two registrations do not + # share one Typer instance. + app.add_typer(ft_command.build_app(), name="fine-tunes", hidden=True) + app.add_typer(files_command.build_app(), name="files") + app.add_typer(models_command.build_app(), name="models") + app.add_typer(oss_command.build_app(), name="oss") + app.add_typer(images_command.build_app(), name="images") + app.add_typer(videos_command.build_app(), name="videos") + app.add_typer(embeddings_command.build_app(), name="embeddings") + app.add_typer(multimodal_command.build_app(), name="multimodal") + app.add_typer(rerank_command.build_app(), name="rerank") + app.add_typer(tokenization_command.build_app(), name="tokenization") + app.add_typer(apps_command.build_app(), name="apps") + app.add_typer(deployments_command.build_app(), name="deployments") + app.add_typer(rl_command.build_app(), name="rl") + # Hidden alias of ``rl``, mirroring the ``fine-tunes`` alias of ``ft``. + app.add_typer(rl_command.build_app(), name="agentic-rl", hidden=True) + app.add_typer(audio_command.build_app(), name="audio") + app.add_typer(tingwu_command.build_app(), name="tingwu") + app.add_typer(decision_command.build_app(), name="decision") + app.add_typer(agentstudio_command.build_app(), name="agentstudio") + app.add_typer(config_command.build_app(), name="config") + + return app diff --git a/dashscope/cli/commands/__init__.py b/dashscope/cli/commands/__init__.py new file mode 100644 index 00000000..f4d8f6f8 --- /dev/null +++ b/dashscope/cli/commands/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Command groups for the dashscope CLI. + +Every module here follows the same shape, which is the pattern the rest +of the command tree is meant to copy: declare options with typer, take +``ctx: typer.Context`` as the first parameter, make exactly one SDK call +through ``ctx.obj.client``, and hand the result to ``ctx.obj.renderer``. + +The rule that keeps ``--help`` fast is structural: **no module in this +package may import ``dashscope`` at module scope.** Importing the SDK +pulls in pydantic, httpx and the 1.x resource tree; a user reading +``dashscope chat create --help`` should not wait for any of it. Reach the +SDK only through ``ctx.obj.client``, which is built on first use. +""" diff --git a/dashscope/cli/commands/agentstudio.py b/dashscope/cli/commands/agentstudio.py new file mode 100644 index 00000000..e7b1e9b9 --- /dev/null +++ b/dashscope/cli/commands/agentstudio.py @@ -0,0 +1,646 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope agentstudio`` -- the managed-agent core loop. + +Covers the one flow a shell script actually drives end to end: create an +agent, open a session on it, send a message, and follow the events back. +The rest of the AgentStudio surface (environments, files, deployments, +skills, vaults, memory stores, webhooks, security) is management-plane +CRUD that is far easier to read in Python than to spell in argv, so it +stays in the SDK. + +Two things here differ from every other v2 command group, both because +AgentStudio ships its own sub-SDK rather than riding ``client.transport``: + +* lists are **cursor**-paginated (``--limit`` plus an opaque ``--page`` + token), not page-number paginated; +* ``client.agentstudio`` is built lazily and resolves its own base URL + from ``workspace`` + ``region``, so it needs a workspace to be useful. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_arg, + parse_json_object_arg, + read_text_arg, +) + +_HELP = r"""Drive an AgentStudio managed agent. + +The core loop, in order: + + dashscope agentstudio agents create --name demo --model qwen3.8-max + dashscope agentstudio sessions create --agent "$AGENT_ID" + dashscope agentstudio sessions events send "$SESSION_ID" --message "hi" + dashscope agentstudio sessions events stream "$SESSION_ID" + +AgentStudio is workspace-scoped: set `workspace` in the config file or +pass `--workspace` before the subcommand. Lists are cursor-paginated, so +page through them with the `next_page` value the previous page reported. +""" + +_AGENTS_HELP = "Create and inspect agents." +_SESSIONS_HELP = "Open and inspect sessions on an agent." +_EVENTS_HELP = "Send messages to a session and read its events back." + +_AGENT_COLUMNS = ("id", "name", "version", "created_at") +_AGENT_LIST_COLUMNS = ("id", "name", "version") +_AGENT_VERSION_COLUMNS = ("agent_id", "version", "created_at") +_SESSION_COLUMNS = ("id", "title", "status", "created_at") +_SESSION_LIST_COLUMNS = ("id", "title", "status") +_EVENT_COLUMNS = ("type", "role", "sequence_number", "created_at") + + +def _envelope(page: Any) -> Dict[str, Any]: + """A machine-format envelope for one cursor page. + + ``CursorPage`` is not a response model, so the fields a caller needs + to keep paging are assembled here rather than dumped. + """ + payload: Dict[str, Any] = { + "data": page.data, + "next_page": page.next_page, + "request_id": page.request_id, + } + payload.update(page.extra) + return payload + + +def _render_page(renderer: Any, page: Any, columns: Any) -> None: + renderer.print_rows( + page.data, + columns=columns, + envelope=_envelope(page), + ) + if renderer.table_mode and page.next_page: + renderer.note(f"next page: {page.next_page}") + + +def _parse_array(raw: Optional[str], *, option_name: str) -> Optional[List]: + """A JSON array option, with the shared ``@file`` / stdin forms.""" + value = parse_json_arg(raw, option_name=option_name) + if value is None: + return None + if not isinstance(value, list): + raise usage_error( + f"{option_name}: expected a JSON array, got " + f"{type(value).__name__}", + ) + return value + + +def _render_event_stream( + renderer: Any, + stream: Any, + *, + deltas: bool, +) -> None: + """One session event stream, in whichever format is active. + + ``text_deltas`` only yields anything when the stream was opened with + ``event_deltas``; ``text_stream`` is the one a reconnect can rebuild, + so it is the default. No ``except``: a mid-stream failure has to reach + the error handler and exit non-zero. + """ + try: + if not renderer.table_mode: + for event in stream: + renderer.print_stream_chunk(event) + return + for text in stream.text_deltas if deltas else stream.text_stream: + renderer.write(text) + renderer.echo() + finally: + stream.close() + + +def build_app() -> typer.Typer: # pylint: disable=too-many-statements + app = typer.Typer(help=_HELP, no_args_is_help=True) + + # ---- agents subgroup ---- # + agents_app = typer.Typer(help=_AGENTS_HELP, no_args_is_help=True) + + @agents_app.command("create") + def agents_create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + name: str = typer.Option(..., "--name", help="Agent name."), + model: str = typer.Option( + ..., + "-m", + "--model", + help="Model id, e.g. qwen3.8-max.", + ), + description: Optional[str] = typer.Option(None, "--description"), + system_prompt: Optional[str] = typer.Option( + None, + "--system-prompt", + help="System prompt. Accepts @file and stdin (-).", + ), + tools: Optional[str] = typer.Option( + None, + "--tools", + help="Tool definitions as a JSON array. Accepts @file.", + ), + mcp_servers: Optional[str] = typer.Option( + None, + "--mcp-servers", + help="MCP server definitions as a JSON array. Accepts @file.", + ), + skills: Optional[str] = typer.Option( + None, + "--skills", + help="Skill definitions as a JSON array. Accepts @file.", + ), + metadata: Optional[str] = typer.Option( + None, + "--metadata", + help="Metadata as a JSON object. Accepts @file.", + ), + ) -> None: + """Create an agent.""" + obj = ctx.obj + agent = obj.client.agentstudio.agents.create( + name=name, + model=model, + description=description, + system_prompt=read_text_arg( + system_prompt, + option_name="--system-prompt", + ), + tools=_parse_array(tools, option_name="--tools"), + mcp_servers=_parse_array(mcp_servers, option_name="--mcp-servers"), + skills=_parse_array(skills, option_name="--skills"), + metadata=parse_json_object_arg( + metadata, + option_name="--metadata", + ), + ) + obj.renderer.print_object(agent, columns=_AGENT_COLUMNS) + + @agents_app.command("get") + def agents_get( + ctx: typer.Context, + agent_id: str = typer.Argument(..., help="Agent id."), + version: Optional[int] = typer.Option( + None, + "--version", + help="Read one specific version instead of the latest.", + ), + ) -> None: + """Show one agent.""" + obj = ctx.obj + agent = obj.client.agentstudio.agents.retrieve( + agent_id, + version=version, + ) + obj.renderer.print_object(agent, columns=_AGENT_COLUMNS) + + @agents_app.command("list") + def agents_list( + ctx: typer.Context, + limit: Optional[int] = typer.Option( + None, + "--limit", + help="Maximum items on this page.", + ), + page: Optional[str] = typer.Option( + None, + "--page", + help="Opaque cursor from a previous page's next_page.", + ), + include_archived: Optional[bool] = typer.Option( + None, + "--include-archived/--exclude-archived", + ), + ) -> None: + """List agents.""" + obj = ctx.obj + result = obj.client.agentstudio.agents.list( + limit=limit, + page=page, + include_archived=include_archived, + ) + _render_page(obj.renderer, result, _AGENT_LIST_COLUMNS) + + @agents_app.command("versions") + def agents_versions( + ctx: typer.Context, + agent_id: str = typer.Argument(..., help="Agent id."), + limit: Optional[int] = typer.Option( + None, + "--limit", + help="Maximum items on this page.", + ), + page: Optional[str] = typer.Option( + None, + "--page", + help="Opaque cursor from a previous page's next_page.", + ), + ) -> None: + """List an agent's versions. + + `agents get --version N` reads one of these back; this is how you + find out which N to ask for. + """ + obj = ctx.obj + result = obj.client.agentstudio.agents.list_versions( + agent_id, + limit=limit, + page=page, + ) + _render_page(obj.renderer, result, _AGENT_VERSION_COLUMNS) + + @agents_app.command("update") + def agents_update( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + agent_id: str = typer.Argument(..., help="Agent id."), + version: int = typer.Option( + ..., + "--version", + help="The agent's current version; a mismatch is a 409.", + ), + name: Optional[str] = typer.Option( + None, + "--name", + help="New name. The server requires one on every update.", + ), + model: Optional[str] = typer.Option(None, "-m", "--model"), + description: Optional[str] = typer.Option(None, "--description"), + system_prompt: Optional[str] = typer.Option( + None, + "--system-prompt", + help="System prompt. Accepts @file and stdin (-).", + ), + tools: Optional[str] = typer.Option( + None, + "--tools", + help="Tool definitions as a JSON array. Accepts @file.", + ), + mcp_servers: Optional[str] = typer.Option( + None, + "--mcp-servers", + help="MCP server definitions as a JSON array. Accepts @file.", + ), + skills: Optional[str] = typer.Option( + None, + "--skills", + help="Skill definitions as a JSON array. Accepts @file.", + ), + metadata: Optional[str] = typer.Option( + None, + "--metadata", + help="Metadata as a JSON object. Accepts @file.", + ), + ) -> None: + """Update the latest version of an agent.""" + obj = ctx.obj + agent = obj.client.agentstudio.agents.update( + agent_id, + version=version, + name=name, + model=model, + description=description, + system_prompt=read_text_arg( + system_prompt, + option_name="--system-prompt", + ), + tools=_parse_array(tools, option_name="--tools"), + mcp_servers=_parse_array(mcp_servers, option_name="--mcp-servers"), + skills=_parse_array(skills, option_name="--skills"), + metadata=parse_json_object_arg( + metadata, + option_name="--metadata", + ), + ) + obj.renderer.print_object(agent, columns=_AGENT_COLUMNS) + + @agents_app.command("archive") + def agents_archive( + ctx: typer.Context, + agent_id: str = typer.Argument(..., help="Agent id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Archive an agent.""" + confirm_or_die(f"archive agent {agent_id}?", yes=yes) + obj = ctx.obj + agent = obj.client.agentstudio.agents.archive(agent_id) + obj.renderer.print_object(agent, columns=_AGENT_COLUMNS) + + # ---- sessions subgroup ---- # + sessions_app = typer.Typer(help=_SESSIONS_HELP, no_args_is_help=True) + + @sessions_app.command("create") + def sessions_create( # pylint: disable=too-many-arguments + ctx: typer.Context, + agent: str = typer.Option( + ..., + "--agent", + help="Agent id to run this session on.", + ), + environment_id: Optional[str] = typer.Option( + None, + "--environment-id", + ), + title: Optional[str] = typer.Option(None, "--title"), + vault_ids: Optional[str] = typer.Option( + None, + "--vault-ids", + help="Vault ids as a JSON array. Accepts @file.", + ), + environment_variables: Optional[str] = typer.Option( + None, + "--env", + help="Environment variables as a JSON object. Accepts @file.", + ), + mcp_configs: Optional[str] = typer.Option( + None, + "--mcp-configs", + help="MCP configs as a JSON array. Accepts @file.", + ), + metadata: Optional[str] = typer.Option( + None, + "--metadata", + help="Metadata as a JSON object. Accepts @file.", + ), + ) -> None: + """Open a session on an agent.""" + obj = ctx.obj + session = obj.client.agentstudio.sessions.create( + agent=agent, + environment_id=environment_id, + title=title, + vault_ids=_parse_array(vault_ids, option_name="--vault-ids"), + environment_variables=parse_json_object_arg( + environment_variables, + option_name="--env", + ), + mcp_configs=_parse_array(mcp_configs, option_name="--mcp-configs"), + metadata=parse_json_object_arg( + metadata, + option_name="--metadata", + ), + ) + obj.renderer.print_object(session, columns=_SESSION_COLUMNS) + + @sessions_app.command("get") + def sessions_get( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Session id."), + ) -> None: + """Show one session.""" + obj = ctx.obj + session = obj.client.agentstudio.sessions.retrieve(session_id) + obj.renderer.print_object(session, columns=_SESSION_COLUMNS) + + @sessions_app.command("list") + def sessions_list( + ctx: typer.Context, + limit: Optional[int] = typer.Option(None, "--limit"), + page: Optional[str] = typer.Option( + None, + "--page", + help="Opaque cursor from a previous page's next_page.", + ), + agent_id: Optional[str] = typer.Option( + None, + "--agent-id", + help="Only sessions of this agent.", + ), + status: Optional[List[str]] = typer.Option( + None, + "--status", + help="Only sessions in this status. Repeatable.", + ), + ) -> None: + """List sessions.""" + obj = ctx.obj + result = obj.client.agentstudio.sessions.list( + limit=limit, + page=page, + agent_id=agent_id, + statuses=list(status) if status else None, + ) + _render_page(obj.renderer, result, _SESSION_LIST_COLUMNS) + + @sessions_app.command("update") + def sessions_update( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Session id."), + title: Optional[str] = typer.Option(None, "--title"), + vault_ids: Optional[str] = typer.Option( + None, + "--vault-ids", + help="Vault ids as a JSON array. Accepts @file. An empty " + "array clears them.", + ), + environment_variables: Optional[str] = typer.Option( + None, + "--env", + help="Environment variables as a JSON object. Accepts @file. " + "An empty object clears them.", + ), + mcp_configs: Optional[str] = typer.Option( + None, + "--mcp-configs", + help="MCP configs as a JSON array. Accepts @file.", + ), + metadata: Optional[str] = typer.Option( + None, + "--metadata", + help="Metadata as a JSON object. Accepts @file.", + ), + agent_config: Optional[str] = typer.Option( + None, + "--agent-config", + help="Session-level agent overrides as a JSON object carrying " + "system / tools / mcp_servers / skills. Accepts @file. Not the " + "agent id -- that is set at create time.", + ), + ) -> None: + """Patch a session. Omitted fields keep their current value.""" + obj = ctx.obj + parsed = { + "title": title, + "vault_ids": _parse_array(vault_ids, option_name="--vault-ids"), + "environment_variables": parse_json_object_arg( + environment_variables, + option_name="--env", + ), + "mcp_configs": _parse_array( + mcp_configs, + option_name="--mcp-configs", + ), + "metadata": parse_json_object_arg( + metadata, + option_name="--metadata", + ), + "agent": parse_json_object_arg( + agent_config, + option_name="--agent-config", + ), + } + if all(value is None for value in parsed.values()): + raise usage_error( + "nothing to update: pass at least one of --title, " + "--vault-ids, --env, --mcp-configs, --metadata, " + "--agent-config", + ) + session = obj.client.agentstudio.sessions.update( + session_id, + **{k: v for k, v in parsed.items() if v is not None}, + ) + obj.renderer.print_object(session, columns=_SESSION_COLUMNS) + + @sessions_app.command("archive") + def sessions_archive( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Session id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Archive a session.""" + confirm_or_die(f"archive session {session_id}?", yes=yes) + obj = ctx.obj + session = obj.client.agentstudio.sessions.archive(session_id) + obj.renderer.print_object(session, columns=_SESSION_COLUMNS) + + @sessions_app.command("delete") + def sessions_delete( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Session id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a session.""" + confirm_or_die(f"delete session {session_id}?", yes=yes) + obj = ctx.obj + result = obj.client.agentstudio.sessions.delete(session_id) + obj.renderer.print_object(result) + + # ---- session events subgroup ---- # + events_app = typer.Typer(help=_EVENTS_HELP, no_args_is_help=True) + + @events_app.command("send") + def events_send( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Session id."), + message: Optional[str] = typer.Option( + None, + "--message", + help="Text to send as one user message. Accepts @file and " + "stdin (-).", + ), + events: Optional[str] = typer.Option( + None, + "--events", + help="Client events as a raw JSON array, sent as-is. Accepts " + "@file and stdin (-). Overrides --message.", + ), + thread_id: Optional[str] = typer.Option( + None, + "--thread-id", + help="Session thread to post the --message into.", + ), + ) -> None: + """Send events to a session.""" + obj = ctx.obj + payload = _parse_array(events, option_name="--events") + if payload is None: + text = read_text_arg(message, option_name="--message") + if not text: + raise usage_error( + "no input given: pass --message or --events", + ) + # Imported here, not at module scope: a command module must + # not pay for the SDK when the app tree is being built. + # pylint: disable-next=import-outside-toplevel + from dashscope.agentstudio import user_message + + payload = [user_message(text, session_thread_id=thread_id)] + result = obj.client.agentstudio.sessions.events.send( + session_id, + payload, + ) + obj.renderer.print_object(result) + + @events_app.command("list") + def events_list( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Session id."), + limit: Optional[int] = typer.Option(None, "--limit"), + page: Optional[str] = typer.Option( + None, + "--page", + help="Opaque cursor from a previous page's next_page.", + ), + event_type: Optional[List[str]] = typer.Option( + None, + "--type", + help="Only events of this type. Repeatable.", + ), + order: Optional[str] = typer.Option( + None, + "--order", + help="Sort order, e.g. asc or desc.", + ), + ) -> None: + """Read a session's persisted events.""" + obj = ctx.obj + result = obj.client.agentstudio.sessions.events.list( + session_id, + types=list(event_type) if event_type else None, + limit=limit, + order=order, + page=page, + ) + _render_page(obj.renderer, result, _EVENT_COLUMNS) + + @events_app.command("stream") + def events_stream( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Session id."), + deltas: Optional[List[str]] = typer.Option( + None, + "--deltas", + help="Opt into incremental frames for these event types " + "(message, reasoning). Repeatable.", + ), + timeout: Optional[float] = typer.Option( + None, + "--timeout", + help="Stream read timeout in seconds.", + ), + ) -> None: + """Follow a session's event stream until the run settles.""" + obj = ctx.obj + stream = obj.client.agentstudio.sessions.events.stream( + session_id, + event_deltas=list(deltas) if deltas else None, + timeout=timeout, + ) + _render_event_stream(obj.renderer, stream, deltas=bool(deltas)) + + sessions_app.add_typer(events_app, name="events") + app.add_typer(agents_app, name="agents") + app.add_typer(sessions_app, name="sessions") + return app diff --git a/dashscope/cli/commands/apps.py b/dashscope/cli/commands/apps.py new file mode 100644 index 00000000..45c922c7 --- /dev/null +++ b/dashscope/cli/commands/apps.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope apps`` -- Bailian application calls. + +An application carries its own prompt, flow, plugins and knowledge base, so +the request has no ``model``: ``--app-id`` identifies what to call. What the +CLI adds on top is the streaming default -- the service sends cumulative text +unless asked otherwise, which would print the same sentence over and over, so +``--stream`` asks for incremental frames unless told not to. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +import typer + +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_object_arg, + parse_messages_arg, + read_stdin, + read_text_arg, + stdin_is_piped, +) +from dashscope.cli.output import Renderer, render_text_stream + +_HELP = r"""Call a Bailian application. + +An application is addressed by `--app-id`; its own prompt, flow, plugins and +knowledge base decide the rest. Send one turn with `--prompt`, a transcript +you maintain with `--messages`, or let the service keep the state with +`--session-id` / `--memory-id`. + +`--stream` writes incremental text to stdout in table mode, and one JSON +object per line in json/yaml mode. +""" + +_COMPLETIONS_HELP = r"""Application completions.""" + + +def _read_inputs( + prompt: Optional[str], + messages: Optional[str], +) -> Tuple[Optional[str], Optional[List[Dict[str, Any]]]]: + """Unlike chat, ``--prompt`` and ``--messages`` combine here. + + The SDK appends ``prompt`` as the latest user turn when both are given, + so neither one shadows the other. + """ + text = read_text_arg(prompt, option_name="--prompt") + conversation = ( + parse_messages_arg(messages) if messages is not None else None + ) + if text is None and conversation is None and stdin_is_piped(): + text = read_stdin().strip() or None + if not text and not conversation: + raise usage_error( + "no input given: pass --prompt, --messages, or pipe text on stdin", + ) + return text, conversation + + +def _render_completion(renderer: Renderer, result: Any) -> None: + """Answer on stdout; thoughts, citations and usage on stderr. + + Citations go to stderr because they are diagnostics for a human, not + part of the answer -- ``dashscope apps completions create ... > a.txt`` + should capture the text alone. + """ + if not renderer.table_mode: + renderer.print_object(result) + return + for thought in result.thoughts or (): + if thought.thought: + renderer.note(thought.thought) + renderer.echo(result.text or "") + for reference in result.doc_references or (): + name = reference.doc_name or reference.title or reference.doc_id or "-" + renderer.note(f"source: {name} {reference.doc_url or ''}".rstrip()) + if result.session_id: + renderer.note(f"session_id: {result.session_id}") + if result.usage is not None: + renderer.note(f"usage: {result.usage.model_dump(exclude_none=True)}") + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + completions_app = typer.Typer(help=_COMPLETIONS_HELP, no_args_is_help=True) + + @completions_app.command("create") + def completions_create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + app_id: str = typer.Option( + ..., + "--app-id", + help="Application id, from the Bailian console.", + ), + prompt: Optional[str] = typer.Option( + None, + "--prompt", + help="Prompt text, @file, or - to read stdin.", + ), + messages: Optional[str] = typer.Option( + None, + "--messages", + help="Conversation as a JSON array, JSONL, or an object with a " + "messages key. Accepts @file and - for stdin.", + ), + stream: bool = typer.Option( + False, + "--stream", + help="Stream the response instead of waiting for it to finish.", + ), + session_id: Optional[str] = typer.Option( + None, + "--session-id", + help="Let the service keep the conversation state.", + ), + memory_id: Optional[str] = typer.Option(None, "--memory-id"), + biz_params: Optional[str] = typer.Option( + None, + "--biz-params", + help="Application flow and plugin inputs as a JSON object. " + "Accepts @file.", + ), + image: Optional[List[str]] = typer.Option( + None, + "--image", + help="Image URL for the application to read. Repeatable.", + ), + file: Optional[List[str]] = typer.Option( + None, + "--file", + help="File URL for the application to read. Repeatable.", + ), + rag_options: Optional[str] = typer.Option( + None, + "--rag-options", + help="Retrieval options as a JSON object. Accepts @file.", + ), + has_thoughts: Optional[bool] = typer.Option( + None, + "--has-thoughts/--no-has-thoughts", + help="Ask for the planning and tool-call steps.", + ), + doc_tag_codes: Optional[List[str]] = typer.Option( + None, + "--doc-tag-code", + help="Restrict retrieval to a tag. Repeatable.", + ), + doc_reference_type: Optional[str] = typer.Option( + None, + "--doc-reference-type", + help="Citation style for retrieved documents.", + ), + incremental_output: Optional[bool] = typer.Option( + None, + "--incremental-output/--no-incremental-output", + help="Stream deltas rather than cumulative text. Defaults to on " + "with --stream.", + ), + temperature: Optional[float] = typer.Option(None, "--temperature"), + top_p: Optional[float] = typer.Option(None, "--top-p"), + top_k: Optional[int] = typer.Option(None, "--top-k"), + seed: Optional[int] = typer.Option(None, "--seed"), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object, passed through " + "verbatim. Accepts @file.", + ), + ) -> None: + """Send one application completion request.""" + obj = ctx.obj + renderer = obj.renderer + text, conversation = _read_inputs(prompt, messages) + completions = obj.client.apps.completions + kwargs: Dict[str, Any] = { + "app_id": app_id, + "prompt": text, + "messages": conversation, + "session_id": session_id, + "memory_id": memory_id, + "biz_params": parse_json_object_arg( + biz_params, + option_name="--biz-params", + ), + "image_list": list(image) if image else None, + "file_list": list(file) if file else None, + "rag_options": parse_json_object_arg( + rag_options, + option_name="--rag-options", + ), + "has_thoughts": has_thoughts, + "doc_tag_codes": list(doc_tag_codes) if doc_tag_codes else None, + "doc_reference_type": doc_reference_type, + "incremental_output": ( + incremental_output + if incremental_output is not None + else (True if stream else None) + ), + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "seed": seed, + "parameters": parse_json_object_arg( + parameters, + option_name="--parameters", + ), + } + + if not stream: + _render_completion(renderer, completions.create(**kwargs)) + return + + result = completions.create(stream=True, **kwargs) + try: + render_text_stream(renderer, result) + finally: + # Runs on Ctrl-C too, so the httpx stream is not left open. + result.close() + + app.add_typer(completions_app, name="completions") + return app diff --git a/dashscope/cli/commands/audio.py b/dashscope/cli/commands/audio.py new file mode 100644 index 00000000..df46a929 --- /dev/null +++ b/dashscope/cli/commands/audio.py @@ -0,0 +1,718 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope audio`` -- speech synthesis, recognition and their registries. + +Four subgroups: ``tts`` turns text into audio, ``asr`` turns audio into +text, ``realtime`` runs one turn over the duplex Omni/Qwen-Audio socket, +and the management plane (``vocabularies``, ``phrases``, ``voices``, +``voice-design``) holds the registries the first three consume. + +``tts synthesize`` runs over websocket and hands back raw bytes, so the CLI +writes them to ``--out`` or to stdout -- ``dashscope audio tts synthesize +"hi" > hi.mp3``. ``tts stream`` writes each chunk as it arrives. ``tts http`` +is the one-POST variant that answers with a URL instead of a payload, which +is what a pipeline that stores audio wants. + +``asr transcribe`` is the batch service: submit file URLs and wait. ``asr +stream`` streams one local file over websocket and prints the sentences. + +``realtime run`` is the one-turn slice of a socket that is otherwise +bidirectional and open-ended: it feeds a whole audio file, asks for one +response, prints the events and hangs up. A live microphone is not a shell +shape and stays in the SDK. + +The management plane lives in :mod:`dashscope.cli.commands. +audio_customization` and is mounted by :func:`build_app` below. +""" + +from __future__ import annotations + +import base64 +import sys +from pathlib import Path +from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union + +import typer + +from dashscope.cli.commands.audio_customization import build_admin_apps +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_object_arg, + read_text_arg, + stdin_is_piped, +) +from dashscope.cli.output import Renderer +from dashscope.cli.progress import validate_wait_timeout, wait_job + +_HELP = r"""Speech synthesis and recognition. + +`audio tts synthesize` writes audio bytes to --out or stdout; `audio tts +stream` writes chunks as they arrive; `audio tts http` answers with a download +URL. `audio asr transcribe` submits file URLs to the batch service and waits; +`audio asr stream` streams one local file. `audio realtime run` feeds a file +to the duplex Omni socket and prints one turn. The `vocabularies`, `phrases`, +`voices` and `voice-design` subgroups manage the registries those commands +consume. +""" + +_TTS_HELP = r"""Text to speech. + +`synthesize` returns the complete audio; `stream` writes chunks as they +arrive. Both run over websocket and write to --out or stdout. +`http` is a single POST that answers with a URL. +""" + +_ASR_HELP = r"""Speech to text. + +`transcribe` submits public file URLs to the batch service and waits for the +transcripts. `stream` streams one local audio file over websocket and prints +the recognized sentences as they complete. `get` checks a batch task submitted +with --no-wait. +""" + +_REALTIME_HELP = r"""One turn over the duplex realtime socket. + +`run` opens a session, feeds a whole audio file, asks for one response and +hangs up -- the scriptable slice of a protocol that is otherwise an +open-ended conversation. Every server event goes out as one line, so +`-o json` gives an NDJSON trace of the turn. + +A live microphone needs the SDK: `client.audio.realtime.connect()` stays +open across turns and expects audio at realtime pace. +""" + +# Mirrors the SDK's own defaults so `--help` is self-documenting; the +# websocket resource takes them as non-optional keyword arguments. +DEFAULT_TTS_MODEL = "cosyvoice-v1" +DEFAULT_TTS_VOICE = "longxiaochun" +DEFAULT_TTS_FORMAT = "mp3" +DEFAULT_TTS_SAMPLE_RATE = 22050 + +DEFAULT_TRANSCRIBE_MODEL = "qwen-audio-3.1-asr-flash-filetrans" + +DEFAULT_WAIT_TIMEOUT = 3600.0 + +_JOB_COLUMNS = ("task_id", "status", "submitted_at", "finished_at") + +# The wire event that carries a recognized sentence. +_RESULT_EVENT = "result-generated" + +# Same chunk size the SDK's own file-driven recognizer uses. +AUDIO_CHUNK_SIZE = 12800 + +# Realtime wire events, spelled as the service spells them. +_TEXT_DELTAS = ("response.text.delta", "response.audio_transcript.delta") +_AUDIO_DELTA = "response.audio.delta" +_TRANSCRIPT_DONE = ("conversation.item.input_audio_transcription.completed",) +_RESPONSE_DONE = "response.done" + + +def _read_audio(raw: Optional[str]) -> Optional[bytes]: + """``--audio``: a path to raw audio, or ``-`` for stdin. + + There is no literal form here -- the value is always a source -- so + ``speech.pcm`` and ``@speech.pcm`` mean the same thing. + """ + if raw is None: + return None + if raw == "-": + stream = getattr(sys.stdin, "buffer", None) + if stream is None: # pragma: no cover - stdin replaced by a text stub + raise usage_error("cannot read audio from stdin: pass a path") + return stream.read() + path = Path(raw[1:] if raw.startswith("@") else raw).expanduser() + if not path.is_file(): + raise usage_error(f"audio file not found: {path}") + try: + return path.read_bytes() + except OSError as exc: + raise usage_error(f"cannot read {path}: {exc}") from exc + + +def _render_realtime_events( + renderer: Renderer, + events: Iterator[Dict[str, Any]], + out: Optional[str], +) -> None: + """Drain one realtime turn. + + Machine formats get every event verbatim as NDJSON: the trace *is* the + result, and picking a subset here would hide the fields a caller came + for. Table mode keeps stdout for the words -- the input transcript and + the answer text -- and puts the spoken reply in ``--out`` only when one + was named, because audio bytes and text cannot share a redirect. + """ + audio = bytearray() + for event in events: + kind = event.get("type") + if not renderer.table_mode: + renderer.print_stream_chunk(event) + elif kind in _TEXT_DELTAS: + renderer.write(event.get("delta") or "") + elif kind in _TRANSCRIPT_DONE: + renderer.note(f"input: {event.get('transcript') or ''}") + elif kind == _AUDIO_DELTA and out: + audio.extend(base64.b64decode(event.get("delta") or "")) + elif kind == _RESPONSE_DONE: + response = event.get("response") or {} + if response.get("usage"): + renderer.note(f"usage: {response['usage']}") + if kind == _RESPONSE_DONE: + break + if renderer.table_mode: + renderer.echo() + if out and audio: + _write_audio(bytes(audio), out, renderer) + + +def _read_text(text: Optional[str], *, option_name: str) -> str: + resolved = read_text_arg(text, option_name=option_name) + if not resolved or not resolved.strip(): + raise usage_error(f"{option_name}: no text given") + return resolved + + +def _write_audio( + data: Union[bytes, Iterable[bytes]], + out: Optional[str], + renderer: Renderer, +) -> None: + """Bytes to a file, or to stdout when no ``--out`` was given.""" + chunks = (data,) if isinstance(data, bytes) else data + if out: + path = Path(out).expanduser() + try: + output = path.open("wb") + except OSError as exc: + raise usage_error(f"cannot write {path}: {exc}") from exc + size = 0 + with output: + for chunk in chunks: + try: + output.write(chunk) + except OSError as exc: + raise usage_error(f"cannot write {path}: {exc}") from exc + size += len(chunk) + renderer.note(f"wrote {size} bytes to {path}") + return + stream = getattr(sys.stdout, "buffer", None) + if stream is None: # pragma: no cover - stdout replaced by a text stub + raise usage_error( + "cannot write audio to stdout: pass --out PATH instead", + ) + for chunk in chunks: + stream.write(chunk) + stream.flush() + + +def _render_synthesis(renderer: Renderer, result: Any) -> None: + if not renderer.table_mode: + renderer.print_object(result) + return + url = result.audio_url + if url: + renderer.echo(url) + return + renderer.print_object(result) + + +def _sentences(events: Iterator[Dict[str, Any]]) -> Iterator[Tuple[Any, Dict]]: + """Recognized sentences out of a raw websocket event stream. + + Heartbeats and empty frames are dropped: they carry no text and would + print blank lines. + """ + for event in events: + header = event.get("header") or {} + if header.get("event") != _RESULT_EVENT: + continue + payload = event.get("payload") or {} + output = payload.get("output") or {} + sentence = output.get("sentence") or {} + if not sentence.get("text") or sentence.get("heartbeat"): + continue + yield event, sentence + + +def _render_job(renderer: Renderer, job: Any) -> None: + """A finished transcription shows its transcript URLs.""" + if not renderer.table_mode: + renderer.print_object(job) + return + urls = [ + result.transcription_url or result.url + for result in job.results + if result.transcription_url or result.url + ] + if urls: + for url in urls: + renderer.echo(url) + return + renderer.print_object(job, columns=_JOB_COLUMNS) + + +def _asr_jobs(client: Any) -> Any: + from dashscope.resources._jobs import Jobs + + return Jobs(client, "audio/asr/transcription", "audio") + + +def build_app() -> typer.Typer: # pylint: disable=too-many-statements + app = typer.Typer(help=_HELP, no_args_is_help=True) + + # ---- tts subgroup ---- # + tts_app = typer.Typer(help=_TTS_HELP, no_args_is_help=True) + + @tts_app.command( + "stream", + help="Stream synthesized audio chunks as they arrive.", + ) + @tts_app.command("synthesize") + def tts_synthesize( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + text: str = typer.Argument( + ..., + help="Text to speak. Accepts @file and - for stdin.", + ), + model: str = typer.Option(DEFAULT_TTS_MODEL, "-m", "--model"), + voice: str = typer.Option(DEFAULT_TTS_VOICE, "--voice"), + audio_format: str = typer.Option( + DEFAULT_TTS_FORMAT, + "--format", + help="Audio container: mp3, wav, pcm.", + ), + sample_rate: int = typer.Option( + DEFAULT_TTS_SAMPLE_RATE, + "--sample-rate", + ), + volume: Optional[int] = typer.Option(None, "--volume"), + rate: Optional[float] = typer.Option( + None, + "--rate", + help="Speaking rate multiplier.", + ), + pitch: Optional[float] = typer.Option(None, "--pitch"), + language_hint: Optional[List[str]] = typer.Option( + None, + "--language-hint", + help="Language hint, e.g. zh or en. Repeatable.", + ), + enable_ssml: Optional[bool] = typer.Option( + None, + "--enable-ssml/--no-enable-ssml", + help="Parse the text as SSML.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + out: Optional[str] = typer.Option( + None, + "-o", + "--out", + help="Where to write the audio. Defaults to stdout.", + ), + ) -> None: + """Synthesize speech and write the audio out.""" + obj = ctx.obj + tts = obj.client.audio.tts + method = tts.stream if ctx.command.name == "stream" else tts.synthesize + data = method( + _read_text(text, option_name="TEXT"), + model=model, + voice=voice, + format=audio_format, + sample_rate=sample_rate, + volume=volume, + rate=rate, + pitch=pitch, + language_hints=list(language_hint) if language_hint else None, + enable_ssml=enable_ssml, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + _write_audio(data, out, obj.renderer) + + @tts_app.command("http") + def tts_http( # pylint: disable=too-many-arguments + ctx: typer.Context, + text: str = typer.Argument( + ..., + help="Text to speak. Accepts @file and - for stdin.", + ), + model: str = typer.Option( + ..., + "-m", + "--model", + help="Synthesis model, e.g. qwen3-tts-flash or cosyvoice-v2.", + ), + voice: str = typer.Option(..., "--voice"), + audio_format: Optional[str] = typer.Option( + None, + "--format", + help="Audio container. Defaults to the model's own.", + ), + sample_rate: Optional[int] = typer.Option(None, "--sample-rate"), + language_type: Optional[str] = typer.Option( + None, + "--language-type", + help="Dialect or language code, for the models that take one.", + ), + extra_input: Optional[str] = typer.Option( + None, + "--extra-input", + help="Extra input fields as a JSON object. Accepts @file.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + ) -> None: + """Synthesize over HTTP; prints the audio URL.""" + obj = ctx.obj + result = obj.client.audio.tts.http.synthesize( + _read_text(text, option_name="TEXT"), + model=model, + voice=voice, + format=audio_format, + sample_rate=sample_rate, + language_type=language_type, + extra_input=parse_json_object_arg( + extra_input, + option_name="--extra-input", + ), + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + _render_synthesis(obj.renderer, result) + + # ---- asr subgroup ---- # + asr_app = typer.Typer(help=_ASR_HELP, no_args_is_help=True) + + @asr_app.command("transcribe") + def asr_transcribe( # pylint: disable=too-many-arguments + ctx: typer.Context, + file_urls: List[str] = typer.Argument( + ..., + help="Public audio/video URLs to transcribe.", + ), + model: str = typer.Option( + DEFAULT_TRANSCRIBE_MODEL, + "-m", + "--model", + ), + phrase_id: Optional[str] = typer.Option( + None, + "--phrase-id", + help="Hot-word list to apply.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + wait: bool = typer.Option( + True, + "--wait/--no-wait", + help="Block until the transcripts are ready.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="With --wait, how long to block before giving up (seconds).", + ), + submit_timeout: Optional[float] = typer.Option( + None, + "--submit-timeout", + help="Timeout for the submit request only.", + ), + ) -> None: + """Transcribe files with the batch service.""" + obj = ctx.obj + renderer = obj.renderer + kwargs = { + "model": model, + "phrase_id": phrase_id, + "parameters": parse_json_object_arg( + parameters, + option_name="--parameters", + ), + } + if not wait: + job = obj.client.audio.asr.jobs.create( + file_urls=list(file_urls), + timeout=submit_timeout, + **kwargs, + ) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + return + + validate_wait_timeout(timeout) + job = obj.client.audio.asr.transcribe( + list(file_urls), + timeout=timeout, + submit_timeout=submit_timeout, + **kwargs, + ) + _render_job(renderer, job) + + @asr_app.command("get") + def asr_get( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Transcription task id."), + ) -> None: + """Check a batch transcription task.""" + obj = ctx.obj + renderer = obj.renderer + job = obj.client.audio.asr.jobs.fetch(task_id) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @asr_app.command("wait") + def asr_wait( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Transcription task id."), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to block before giving up (seconds).", + ), + ) -> None: + """Block until a batch transcription task finishes.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + job = wait_job( + obj.client, + task_id, + timeout=timeout, + renderer=renderer, + label="transcription task", + waiter=obj.client.audio.asr.jobs, + ) + _render_job(renderer, job) + + @asr_app.command("list") + def asr_list( # pylint: disable=too-many-arguments + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Tasks per page."), + status: Optional[str] = typer.Option(None, "--status"), + model_name: Optional[str] = typer.Option(None, "--model"), + api_key_id: Optional[str] = typer.Option(None, "--api-key-id"), + region: Optional[str] = typer.Option(None, "--region"), + start_time: Optional[str] = typer.Option(None, "--start-time"), + end_time: Optional[str] = typer.Option(None, "--end-time"), + ) -> None: + """List batch transcription tasks.""" + obj = ctx.obj + result = _asr_jobs(obj.client).list( + page_no=page, + page_size=size, + status=status, + model_name=model_name, + api_key_id=api_key_id, + region=region, + start_time=start_time, + end_time=end_time, + ) + obj.renderer.print_rows( + result.tasks, + columns=_JOB_COLUMNS, + envelope=result, + ) + + @asr_app.command("cancel") + def asr_cancel( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Transcription task id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Cancel a batch transcription task.""" + confirm_or_die(f"cancel transcription task {task_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(_asr_jobs(obj.client).cancel(task_id)) + + @asr_app.command("stream") + def asr_stream( # pylint: disable=too-many-arguments + ctx: typer.Context, + path: str = typer.Argument(..., help="Local audio file to recognize."), + model: str = typer.Option( + ..., + "-m", + "--model", + help="Recognition model, e.g. paraformer-realtime-v2.", + ), + audio_format: str = typer.Option( + "pcm", + "--format", + help="Container of the input file: pcm, wav, mp3, opus, speex.", + ), + sample_rate: int = typer.Option(16000, "--sample-rate"), + vocabulary_id: Optional[str] = typer.Option( + None, + "--vocabulary-id", + help="Custom vocabulary to apply.", + ), + disfluency_removal: Optional[bool] = typer.Option( + None, + "--disfluency-removal/--no-disfluency-removal", + help="Strip filler words from the transcript.", + ), + diarization: Optional[bool] = typer.Option( + None, + "--diarization/--no-diarization", + help="Label who speaks each sentence.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + ) -> None: + """Recognize one local audio file, printing each sentence.""" + obj = ctx.obj + renderer = obj.renderer + source = Path(path).expanduser() + if not source.is_file(): + raise usage_error(f"audio file not found: {source}") + events = obj.client.audio.asr.recognize_file( + str(source), + model=model, + format=audio_format, + sample_rate=sample_rate, + vocabulary_id=vocabulary_id, + disfluency_removal_enabled=disfluency_removal, + diarization_enabled=diarization, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if not renderer.table_mode: + for event in events: + renderer.print_stream_chunk(event) + return + for _event, sentence in _sentences(events): + # Interim results repeat the same growing sentence; only the + # one carrying end_time is final. + if sentence.get("end_time") is not None: + renderer.echo(sentence["text"]) + + # ---- realtime subgroup ---- # + realtime_app = typer.Typer(help=_REALTIME_HELP, no_args_is_help=True) + + @realtime_app.command("run") + def realtime_run( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Realtime model, e.g. qwen3.8-omni-flash-realtime.", + ), + audio: Optional[str] = typer.Option( + None, + "--audio", + help="Raw audio to feed: a path, @path, or - for stdin. " + "Defaults to piped stdin.", + ), + instructions: Optional[str] = typer.Option( + None, + "--instructions", + help="Instructions for this one response. Accepts @file.", + ), + voice: Optional[str] = typer.Option(None, "--voice"), + input_format: str = typer.Option( + "pcm", + "--input-format", + help="Container of the audio being fed: pcm, wav.", + ), + output_format: Optional[str] = typer.Option( + None, + "--output-format", + help="Container for the spoken reply, e.g. wav.", + ), + modality: Optional[List[str]] = typer.Option( + None, + "--modality", + help="Output modality, e.g. text or audio. Repeatable.", + ), + transcribe_input: Optional[bool] = typer.Option( + None, + "--transcribe-input/--no-transcribe-input", + help="Ask for a transcript of the audio being fed.", + ), + out: Optional[str] = typer.Option( + None, + "-o", + "--out", + help="Where to write the spoken reply. Without it the reply " + "audio is dropped, so stdout stays readable.", + ), + timeout: Optional[float] = typer.Option( + None, + "--timeout", + help="Socket timeout in seconds.", + ), + ) -> None: + """Feed one audio file and print the turn that comes back.""" + obj = ctx.obj + renderer = obj.renderer + data = _read_audio(audio) + if data is None and stdin_is_piped(): + data = _read_audio("-") + if not data: + raise usage_error( + "no audio given: pass --audio PATH or pipe audio on stdin", + ) + # The SDK's session options are sentinel-defaulted, so an unset + # flag must stay absent from the call rather than arrive as None. + settings: Dict[str, Any] = {"input_audio_format": input_format} + if voice is not None: + settings["voice"] = voice + if output_format is not None: + settings["output_audio_format"] = output_format + if modality: + settings["output_modalities"] = list(modality) + if transcribe_input is not None: + settings["enable_input_audio_transcription"] = transcribe_input + prompt = read_text_arg(instructions, option_name="--instructions") + session = obj.client.audio.realtime.connect( + model=model, + timeout=timeout, + ) + with session: + session.update_session(**settings) + for start in range(0, len(data), AUDIO_CHUNK_SIZE): + session.append_audio(data[start : start + AUDIO_CHUNK_SIZE]) + session.commit() + if prompt: + session.create_response(instructions=prompt) + else: + session.create_response() + _render_realtime_events(renderer, session.events(), out) + + app.add_typer(tts_app, name="tts") + app.add_typer(asr_app, name="asr") + app.add_typer(realtime_app, name="realtime") + for name, group in build_admin_apps().items(): + app.add_typer(group, name=name) + return app diff --git a/dashscope/cli/commands/audio_customization.py b/dashscope/cli/commands/audio_customization.py new file mode 100644 index 00000000..f0d63278 --- /dev/null +++ b/dashscope/cli/commands/audio_customization.py @@ -0,0 +1,567 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope audio`` management plane -- hot words, phrases and voices. + +Four registries that the speech commands consume but do not own: + +* ``vocabularies`` -- ASR hot-word tables, referenced by + ``audio asr stream --vocabulary-id``; +* ``phrases`` -- compiled phrase sets, referenced by + ``audio asr transcribe --phrase-id``; +* ``voices`` -- voices cloned from a sample recording; +* ``voice-design`` -- voices described in words instead of audio. + +``voices`` and ``voice-design`` share one registry, one ``voice_id`` +namespace and one quota, so ``voice-design list`` returns cloned voices +too. Both are mounted as subgroups of ``audio`` by +:func:`dashscope.cli.commands.audio.build_app`. + +Pagination is not uniform across these endpoints and is exposed as the +service spells it rather than normalised away: the three customization +registries take a **0-based** ``--page-index``, while ``phrases`` is a +fine-tune-shaped endpoint and takes the 1-based ``--page``/``--size`` the +rest of the CLI uses. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_arg, + parse_json_object_arg, + read_text_arg, +) + +_VOCABULARIES_HELP = "Manage ASR hot-word tables." +_PHRASES_HELP = "Manage compiled ASR phrase sets." +_VOICES_HELP = "Manage voices cloned from a sample recording." +_VOICE_DESIGN_HELP = "Manage voices described in words rather than audio." + +_VOCABULARY_COLUMNS = ( + "vocabulary_id", + "prefix", + "target_model", + "status", +) +_VOICE_COLUMNS = ("voice_id", "status", "target_model") +_PHRASE_COLUMNS = ("phrase_id", "status") + + +def _parse_entries(raw: Optional[str]) -> List[Dict[str, Any]]: + """``--vocabulary``: a required JSON array of hot-word entries.""" + value = parse_json_arg(raw, option_name="--vocabulary") + if value is None: + raise usage_error("--vocabulary is required") + if not isinstance(value, list): + raise usage_error( + f"--vocabulary: expected a JSON array, got {type(value).__name__}", + ) + return value + + +def _parse_phrase_map(raw: Optional[str]) -> Dict[str, Any]: + """``--phrases``: a required JSON object of phrase lists.""" + value = parse_json_object_arg(raw, option_name="--phrases") + if value is None: + raise usage_error("--phrases is required") + if not value: + raise usage_error("--phrases must not be empty") + return value + + +def build_vocabularies_app() -> typer.Typer: + """``audio vocabularies`` -- hot-word tables.""" + app = typer.Typer(help=_VOCABULARIES_HELP, no_args_is_help=True) + + @app.command("create") + def create( + ctx: typer.Context, + target_model: str = typer.Option( + ..., + "--target-model", + help="Recognizer the table is for, e.g. paraformer-v2.", + ), + prefix: str = typer.Option( + ..., + "--prefix", + help="Becomes part of the generated vocabulary_id.", + ), + vocabulary: str = typer.Option( + ..., + "--vocabulary", + help="Hot-word entries as a JSON array. Accepts @file.", + ), + model: Optional[str] = typer.Option( + None, + "-m", + "--model", + help="Customization model. Defaults to the service's own.", + ), + ) -> None: + """Create a hot-word table.""" + obj = ctx.obj + result = obj.client.audio.vocabularies.create( + target_model=target_model, + prefix=prefix, + vocabulary=_parse_entries(vocabulary), + model=model, + ) + obj.renderer.print_object(result, columns=_VOCABULARY_COLUMNS) + + @app.command("list") + def list_tables( + ctx: typer.Context, + prefix: Optional[str] = typer.Option( + None, + "--prefix", + help="Only tables whose id starts with this prefix.", + ), + page_index: int = typer.Option( + 0, + "--page-index", + help="0-based page number.", + ), + page_size: int = typer.Option( + 10, + "--page-size", + help="Rows per page.", + ), + ) -> None: + """List hot-word tables.""" + obj = ctx.obj + result = obj.client.audio.vocabularies.list( + prefix=prefix, + page_index=page_index, + page_size=page_size, + ) + obj.renderer.print_rows( + result.vocabularies, + columns=_VOCABULARY_COLUMNS, + envelope=result, + ) + + @app.command("get") + def get( + ctx: typer.Context, + vocabulary_id: str = typer.Argument(..., help="Vocabulary id."), + ) -> None: + """Show one hot-word table and its contents.""" + obj = ctx.obj + result = obj.client.audio.vocabularies.get(vocabulary_id) + obj.renderer.print_object(result, columns=_VOCABULARY_COLUMNS) + + @app.command("update") + def update( + ctx: typer.Context, + vocabulary_id: str = typer.Argument(..., help="Vocabulary id."), + vocabulary: str = typer.Option( + ..., + "--vocabulary", + help="Replacement entries as a JSON array. Accepts @file.", + ), + ) -> None: + """Replace one table's contents.""" + obj = ctx.obj + result = obj.client.audio.vocabularies.update( + vocabulary_id, + vocabulary=_parse_entries(vocabulary), + ) + obj.renderer.print_object(result) + + @app.command("delete") + def delete( + ctx: typer.Context, + vocabulary_id: str = typer.Argument(..., help="Vocabulary id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a hot-word table.""" + confirm_or_die(f"delete vocabulary {vocabulary_id}?", yes=yes) + obj = ctx.obj + result = obj.client.audio.vocabularies.delete(vocabulary_id) + obj.renderer.print_object(result) + + return app + + +def build_phrases_app() -> typer.Typer: + """``audio phrases`` -- compiled phrase sets.""" + app = typer.Typer(help=_PHRASES_HELP, no_args_is_help=True) + + @app.command("create") + def create( + ctx: typer.Context, + phrases: str = typer.Option( + ..., + "--phrases", + help="Phrase lists as a JSON object. Accepts @file.", + ), + training_type: str = typer.Option( + "compile_asr_phrase", + "--training-type", + ), + ) -> None: + """Compile a phrase set.""" + obj = ctx.obj + result = obj.client.audio.phrases.create( + phrases=_parse_phrase_map(phrases), + training_type=training_type, + ) + obj.renderer.print_object(result, columns=_PHRASE_COLUMNS) + + @app.command("list") + def list_phrases( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Rows per page."), + ) -> None: + """List compiled phrase sets.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.audio.phrases.list(page_no=page, page_size=size) + renderer.print_rows( + result.phrases, + columns=_PHRASE_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total_count: + pages = max(1, -(-result.total_count // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total_count}", + ) + + @app.command("get") + def get( + ctx: typer.Context, + phrase_id: str = typer.Argument(..., help="Phrase set id."), + ) -> None: + """Show one compiled phrase set.""" + obj = ctx.obj + result = obj.client.audio.phrases.get(phrase_id) + obj.renderer.print_object(result, columns=_PHRASE_COLUMNS) + + @app.command("update") + def update( + ctx: typer.Context, + phrase_id: str = typer.Argument(..., help="Phrase set id."), + phrases: str = typer.Option( + ..., + "--phrases", + help="Replacement phrase lists as a JSON object. Accepts @file.", + ), + training_type: str = typer.Option( + "compile_asr_phrase", + "--training-type", + ), + ) -> None: + """Recompile a phrase set with new contents.""" + obj = ctx.obj + result = obj.client.audio.phrases.update( + phrase_id, + phrases=_parse_phrase_map(phrases), + training_type=training_type, + ) + obj.renderer.print_object(result, columns=_PHRASE_COLUMNS) + + @app.command("delete") + def delete( + ctx: typer.Context, + phrase_id: str = typer.Argument(..., help="Phrase set id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a compiled phrase set.""" + confirm_or_die(f"delete phrase set {phrase_id}?", yes=yes) + obj = ctx.obj + result = obj.client.audio.phrases.delete(phrase_id) + obj.renderer.print_object(result) + + return app + + +def build_voices_app() -> typer.Typer: + """``audio voices`` -- voice cloning.""" + app = typer.Typer(help=_VOICES_HELP, no_args_is_help=True) + + @app.command("create") + def create( # pylint: disable=too-many-arguments + ctx: typer.Context, + target_model: str = typer.Option( + ..., + "--target-model", + help="Synthesis model the voice is for, e.g. cosyvoice-v3-plus.", + ), + prefix: str = typer.Option( + ..., + "--prefix", + help="Becomes part of the generated voice_id.", + ), + url: str = typer.Option( + ..., + "--url", + help="Publicly readable sample recording to clone from.", + ), + language_hint: Optional[List[str]] = typer.Option( + None, + "--language-hint", + help="Language of the sample, e.g. zh. Repeatable.", + ), + max_prompt_audio_length: Optional[float] = typer.Option( + None, + "--max-prompt-audio-length", + help="Seconds of the sample to use.", + ), + extra_input: Optional[str] = typer.Option( + None, + "--extra-input", + help="Extra input fields as a JSON object. Accepts @file.", + ), + ) -> None: + """Clone a voice from a sample recording.""" + obj = ctx.obj + result = obj.client.audio.voices.create( + target_model=target_model, + prefix=prefix, + url=url, + language_hints=( + list(language_hint) if language_hint is not None else None + ), + max_prompt_audio_length=max_prompt_audio_length, + extra_input=parse_json_object_arg( + extra_input, + option_name="--extra-input", + ), + ) + obj.renderer.print_object(result, columns=_VOICE_COLUMNS) + + @app.command("list") + def list_voices( + ctx: typer.Context, + prefix: Optional[str] = typer.Option( + None, + "--prefix", + help="Only voices whose id starts with this prefix.", + ), + page_index: int = typer.Option( + 0, + "--page-index", + help="0-based page number.", + ), + page_size: int = typer.Option( + 10, + "--page-size", + help="Rows per page.", + ), + ) -> None: + """List cloned and designed voices (they share one registry).""" + obj = ctx.obj + result = obj.client.audio.voices.list( + prefix=prefix, + page_index=page_index, + page_size=page_size, + ) + obj.renderer.print_rows( + result.voices, + columns=_VOICE_COLUMNS, + envelope=result, + ) + + @app.command("get") + def get( + ctx: typer.Context, + voice_id: str = typer.Argument(..., help="Voice id."), + ) -> None: + """Show one cloned voice.""" + obj = ctx.obj + result = obj.client.audio.voices.get(voice_id) + obj.renderer.print_object(result, columns=_VOICE_COLUMNS) + + @app.command("update") + def update( + ctx: typer.Context, + voice_id: str = typer.Argument(..., help="Voice id."), + url: str = typer.Option( + ..., + "--url", + help="Publicly readable replacement sample recording.", + ), + ) -> None: + """Re-clone a voice from a different sample.""" + obj = ctx.obj + result = obj.client.audio.voices.update(voice_id, url=url) + obj.renderer.print_object(result) + + @app.command("delete") + def delete( + ctx: typer.Context, + voice_id: str = typer.Argument(..., help="Voice id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a cloned voice.""" + confirm_or_die(f"delete voice {voice_id}?", yes=yes) + obj = ctx.obj + result = obj.client.audio.voices.delete(voice_id) + obj.renderer.print_object(result) + + return app + + +def build_voice_design_app() -> typer.Typer: + """``audio voice-design`` -- voices described in words.""" + app = typer.Typer(help=_VOICE_DESIGN_HELP, no_args_is_help=True) + + @app.command("create") + def create( # pylint: disable=too-many-arguments + ctx: typer.Context, + target_model: str = typer.Option( + ..., + "--target-model", + help="Synthesis model the voice is for.", + ), + prefix: str = typer.Option( + ..., + "--prefix", + help="Becomes part of the generated voice_id.", + ), + voice_prompt: str = typer.Option( + ..., + "--voice-prompt", + help="What the voice should sound like. Accepts @file and " + "stdin (-).", + ), + preview_text: str = typer.Option( + ..., + "--preview-text", + help="Text the preview is spoken with. Accepts @file and " + "stdin (-).", + ), + language_hint: Optional[List[str]] = typer.Option( + None, + "--language-hint", + help="Language of --preview-text. Only the first is honoured.", + ), + extra_input: Optional[str] = typer.Option( + None, + "--extra-input", + help="Extra input fields as a JSON object. Accepts @file.", + ), + ) -> None: + """Design a voice from a text prompt. + + The voice is unusable until its status leaves DEPLOYING; poll it + with `audio voice-design get`. + """ + obj = ctx.obj + prompt = read_text_arg(voice_prompt, option_name="--voice-prompt") + preview = read_text_arg(preview_text, option_name="--preview-text") + if not prompt or not prompt.strip(): + raise usage_error("--voice-prompt must not be empty") + if not preview or not preview.strip(): + raise usage_error("--preview-text must not be empty") + result = obj.client.audio.voice_design.create( + target_model=target_model, + prefix=prefix, + voice_prompt=prompt, + preview_text=preview, + language_hints=( + list(language_hint) if language_hint is not None else None + ), + extra_input=parse_json_object_arg( + extra_input, + option_name="--extra-input", + ), + ) + obj.renderer.print_object(result, columns=_VOICE_COLUMNS) + + @app.command("list") + def list_voices( + ctx: typer.Context, + prefix: Optional[str] = typer.Option( + None, + "--prefix", + help="Only voices whose id starts with this prefix.", + ), + page_index: int = typer.Option( + 0, + "--page-index", + help="0-based page number.", + ), + page_size: int = typer.Option( + 10, + "--page-size", + help="Rows per page.", + ), + ) -> None: + """List designed voices (cloned ones share the registry).""" + obj = ctx.obj + result = obj.client.audio.voice_design.list( + prefix=prefix, + page_index=page_index, + page_size=page_size, + ) + obj.renderer.print_rows( + result.voices, + columns=_VOICE_COLUMNS, + envelope=result, + ) + + @app.command("get") + def get( + ctx: typer.Context, + voice_id: str = typer.Argument(..., help="Voice id."), + ) -> None: + """Show one designed voice.""" + obj = ctx.obj + result = obj.client.audio.voice_design.get(voice_id) + obj.renderer.print_object(result, columns=_VOICE_COLUMNS) + + @app.command("delete") + def delete( + ctx: typer.Context, + voice_id: str = typer.Argument(..., help="Voice id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a designed voice.""" + confirm_or_die(f"delete voice {voice_id}?", yes=yes) + obj = ctx.obj + result = obj.client.audio.voice_design.delete(voice_id) + obj.renderer.print_object(result) + + return app + + +def build_admin_apps() -> Dict[str, typer.Typer]: + """The four management subgroups, keyed by their command name. + + Returned as a dict rather than mounted here so ``audio``'s own + ``build_app`` stays the single place that decides the group's shape. + """ + return { + "vocabularies": build_vocabularies_app(), + "phrases": build_phrases_app(), + "voices": build_voices_app(), + "voice-design": build_voice_design_app(), + } diff --git a/dashscope/cli/commands/auth.py b/dashscope/cli/commands/auth.py new file mode 100644 index 00000000..e7755eac --- /dev/null +++ b/dashscope/cli/commands/auth.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope auth`` -- manage and verify the saved API key.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +import typer + +from dashscope.cli import config_store +from dashscope.cli.errors import AUTH, CliError, usage_error + +_HELP = r"""Manage the DashScope API key. + +`auth login` writes the same ~/.dashscope/api_key file as 1.x. `auth whoami` +validates the resolved key with a one-item models request. `auth logout` +removes only the default saved-key file; environment and config.toml values +are left untouched. Top-level `login`, `whoami`, and `logout` are shortcuts. +""" + + +def _default_key_path() -> Path: + return Path("~/.dashscope/api_key").expanduser() + + +def _masked(key: str) -> str: + return key[:6] + "..." + key[-4:] if len(key) > 10 else "***" + + +def _key_source(obj) -> str: + if "api_key" in obj.sdk_overrides(): + return "command line" + + from dashscope import settings + + if settings.get_explicit("api_key") is not None: + return "settings" + if os.environ.get("DASHSCOPE_API_KEY"): + return "environment" + if config_store.load_raw().get("api_key") is not None: + return f"file ({config_store.config_path()})" + + configured = os.environ.get("DASHSCOPE_API_KEY_FILE_PATH") + path = Path(configured).expanduser() if configured else _default_key_path() + return f"file ({path})" + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("login") + def login( + ctx: typer.Context, + key: Optional[str] = typer.Option( + None, + "--key", + "-k", + help="API key to save. Prompted interactively if omitted.", + ), + ) -> None: + """Save an API key to ~/.dashscope/api_key.""" + if key is None: + key = typer.prompt("Enter your DashScope API key", hide_input=True) + key = key.strip() + if not key: + raise usage_error("API key cannot be empty") + + from dashscope import save_api_key + + save_api_key(key) + path = _default_key_path() + if ctx.obj.renderer.table_mode: + ctx.obj.renderer.echo(f"Saved API key to {path}") + else: + ctx.obj.renderer.print_object({"saved": True, "path": str(path)}) + + @app.command("whoami") + def whoami(ctx: typer.Context) -> None: + """Verify the resolved API key and display its source.""" + obj = ctx.obj + client = obj.client + key = client.config.api_key + if not key: + raise CliError( + AUTH, + "no API key configured", + solution=( + "Run `dashscope auth login` or set DASHSCOPE_API_KEY." + ), + ) + + client.models.list(page_no=1, page_size=1) + result = { + "authenticated": True, + "key": _masked(key), + "source": _key_source(obj), + } + if obj.renderer.table_mode: + obj.renderer.echo( + f"Authenticated key={result['key']} source={result['source']}", + ) + else: + obj.renderer.print_object(result) + + @app.command("logout") + def logout(ctx: typer.Context) -> None: + """Remove the saved API key from ~/.dashscope/api_key.""" + path = _default_key_path() + removed = path.exists() + if removed: + path.unlink() + if ctx.obj.renderer.table_mode: + if removed: + ctx.obj.renderer.echo(f"Removed API key file {path}") + else: + ctx.obj.renderer.echo( + "No saved API key found -- nothing to remove.", + ) + else: + ctx.obj.renderer.print_object( + {"removed": removed, "path": str(path)}, + ) + + return app diff --git a/dashscope/cli/commands/chat.py b/dashscope/cli/commands/chat.py new file mode 100644 index 00000000..c8e36eab --- /dev/null +++ b/dashscope/cli/commands/chat.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope chat`` -- text generation against ``client.chat.completions``.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + STDIN_TOKEN, + parse_json_object_arg, + parse_tools_arg, + read_prompt_or_messages, +) +from dashscope.cli.output import render_completion, render_text_stream + +_HELP = r"""Generate text with a chat model. + +The conversation comes from `--messages` (JSON or JSONL, `@file` or `-` for +stdin), from `--prompt` (optionally prefixed with `--system`), or from stdin +-- in that order of preference. A positional `-` explicitly selects stdin. +Local image and video paths inside the messages are uploaded by the SDK +itself; pass them through untouched. + +Streaming writes incremental text to stdout in table mode, and one JSON +object per line in json/yaml mode, so `--stream --output json | jq` works. +""" + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("create") + def create( + ctx: typer.Context, + source: Optional[str] = typer.Argument( + None, + metavar="-", + help="Read the prompt or messages from stdin.", + ), + model: Optional[str] = typer.Option( + None, + "-m", + "--model", + help="Model to call. Falls back to the configured default_model.", + ), + prompt: Optional[str] = typer.Option( + None, + "--prompt", + help="Prompt text, @file, or - to read stdin.", + ), + messages: Optional[str] = typer.Option( + None, + "--messages", + help="Conversation as a JSON array, JSONL, or an object with a " + "messages key. Accepts @file and - for stdin.", + ), + system: Optional[str] = typer.Option( + None, + "--system", + help="System prompt prepended to --prompt.", + ), + stream: bool = typer.Option( + False, + "--stream", + help="Stream the response instead of waiting for it to finish.", + ), + tools: Optional[str] = typer.Option( + None, + "--tools", + help="Tool definitions as a JSON array. Accepts @file.", + ), + temperature: Optional[float] = typer.Option(None, "--temperature"), + top_p: Optional[float] = typer.Option(None, "--top-p"), + top_k: Optional[int] = typer.Option(None, "--top-k"), + max_tokens: Optional[int] = typer.Option(None, "--max-tokens"), + stop: Optional[List[str]] = typer.Option( + None, + "--stop", + help="Stop sequence. Repeatable.", + ), + seed: Optional[int] = typer.Option(None, "--seed"), + repetition_penalty: Optional[float] = typer.Option( + None, + "--repetition-penalty", + ), + presence_penalty: Optional[float] = typer.Option( + None, + "--presence-penalty", + ), + n: Optional[int] = typer.Option(None, "--n"), + enable_thinking: Optional[bool] = typer.Option( + None, + "--enable-thinking/--no-enable-thinking", + help="Toggle reasoning output. Omit to use the model's default.", + ), + enable_search: Optional[bool] = typer.Option( + None, + "--enable-search/--no-enable-search", + help="Toggle web search grounding. Omit to use the default.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra request parameters as a JSON object, passed through " + "verbatim. Accepts @file.", + ), + ) -> None: + """Send one chat completion request.""" + obj = ctx.obj + renderer = obj.renderer + if source not in (None, STDIN_TOKEN): + raise usage_error("chat create: positional input must be '-'") + conversation = read_prompt_or_messages( + prompt=prompt, + messages=messages, + system=system, + explicit_stdin=source == STDIN_TOKEN, + ) + kwargs: Dict[str, Any] = { + "model": obj.resolve_model(model), + "messages": conversation, + "tools": parse_tools_arg(tools), + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "max_tokens": max_tokens, + "stop": list(stop) if stop else None, + "seed": seed, + "repetition_penalty": repetition_penalty, + "presence_penalty": presence_penalty, + "n": n, + "enable_thinking": enable_thinking, + "enable_search": enable_search, + "parameters": parse_json_object_arg( + parameters, + option_name="--parameters", + ), + } + # The SDK drops None values when building the request, so spelling + # every parameter out costs nothing and keeps the call readable. + completions = obj.client.chat.completions + + if not stream: + render_completion(renderer, completions.create(**kwargs)) + return + + result = completions.create(stream=True, **kwargs) + try: + render_text_stream(renderer, result) + finally: + # Runs on Ctrl-C too, so the httpx stream is not left open. + result.close() + + return app diff --git a/dashscope/cli/commands/config.py b/dashscope/cli/commands/config.py new file mode 100644 index 00000000..da98c330 --- /dev/null +++ b/dashscope/cli/commands/config.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope config`` -- read and write ``~/.dashscope/config.toml``.""" + +from __future__ import annotations + +import typer + +from dashscope.cli import config_store +from dashscope.cli.config_store import API_KEY + +# `\[` is Rich markup escaping: unescaped, Rich reads the bracketed run as a +# style tag and drops it from the rendered help. +_HELP = r"""Read and write ~/.dashscope/config.toml. + +SDK keys (api_key, protocol, base_url, workspace, region, timeout, +max_retries, ...) are read by the client itself. CLI-only defaults live in +the \[cli] table: `output` and `default_model`. `config list` also shows +which layer each value came from, since the environment overrides the file. +""" + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("set") + def set_command( + ctx: typer.Context, + key: str = typer.Argument( + ..., + help="Config key, e.g. api_key or output.", + ), + value: str = typer.Argument(..., help="Value to store."), + ) -> None: + """Store one config value. + + The value is never echoed back: a terminal recording or a CI log + should not capture a key that was just typed in. + """ + bare, _ = config_store.set_value(key, value) + if bare == API_KEY: + # The file now holds a secret; owner-only from here on. + config_store.restrict_permissions() + renderer = ctx.obj.renderer + if renderer.table_mode: + renderer.success(f"set {bare}") + else: + renderer.print_object({"key": bare}) + + @app.command("get") + def get_command( + ctx: typer.Context, + key: str = typer.Argument(..., help="Config key to read."), + reveal: bool = typer.Option( + False, + "--reveal", + help="Print api_key in full instead of masked.", + ), + ) -> None: + """Print one config value. + + An unset key prints nothing and exits 0, so a script can use the + output directly; a misspelled key is a usage error instead. + """ + bare = config_store.bare_key(key) + value = config_store.get_value(key) + if bare == API_KEY and value is not None and not reveal: + value = config_store.mask(value) + renderer = ctx.obj.renderer + if renderer.table_mode: + renderer.echo("" if value is None else str(value)) + else: + renderer.print_object({"key": bare, "value": value}) + + @app.command("list") + def list_command(ctx: typer.Context) -> None: + """Show every config key, its value, and where the value came from.""" + rows = config_store.effective_values() + ctx.obj.renderer.print_rows( + rows, + columns=["key", "value", "source"], + ) + + return app diff --git a/dashscope/cli/commands/decision.py b/dashscope/cli/commands/decision.py new file mode 100644 index 00000000..051adaea --- /dev/null +++ b/dashscope/cli/commands/decision.py @@ -0,0 +1,141 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope decision`` -- the System One decision model. + +One forward pass answers a set of *typed* questions about a business +``state``: multi-choice (``choice``), yes/no (``noul``) and ordinal scale +(``score``), each with its probability distribution and -- for ``choice`` +and ``score`` -- a confidence. The model generates no text, so this is +the cheap way to route a ticket, moderate a payload or verify an agent +result from a shell script. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_arg, + parse_json_object_arg, + read_text_arg, +) + +_HELP = r"""Answer typed questions about a business state. + +`decision create` takes a `--state` (free text, a dialogue or a JSON +object) and a `--questions` JSON object whose values declare their own +`type`: + + {"department": {"type": "choice", + "instructions": "Which team?", + "criteria": {"billing": "...", "technical": "..."}}, + "escalate": {"type": "noul", "instructions": "Page on-call?"}, + "severity": {"type": "score", "criteria": ["low", "mid", "high"]}} + +The answer keys mirror the question ids. `choice` answers name an option, +`noul` answers are P(yes), and `score` answers are a probability-weighted +level index that may fall between two levels. +""" + +_COLUMNS = ("question", "type", "answer", "confidence") + + +def _parse_state(raw: Optional[str]) -> Any: + """``--state``: a JSON object/array, or a literal string. + + A ticket body is usually plain text and a routing payload is usually + structured, so the same option takes both -- decided by the first + character, the way ``embeddings create --input`` decides. + """ + text = read_text_arg(raw, option_name="--state") + if text is None: + return None + stripped = text.strip() + if stripped[:1] in ("{", "["): + value = parse_json_arg(raw, option_name="--state") + if not isinstance(value, (dict, list)): + raise usage_error( + "--state: expected a JSON object or array, got " + f"{type(value).__name__}", + ) + return value + return text + + +def _answer_value(answer: Any) -> Any: + """Whichever of the three typed fields the answer actually filled.""" + for field in ("choice", "noul", "score"): + value = getattr(answer, field, None) + if value is not None: + return value + return None + + +def _rows(result: Any) -> List[Dict[str, Any]]: + return [ + { + "question": name, + "type": getattr(answer, "type", None), + "answer": _answer_value(answer), + "confidence": getattr(answer, "confidence", None), + } + for name, answer in (result.answers or {}).items() + ] + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("create") + def create( + ctx: typer.Context, + model: str = typer.Option( + "decision-model-preview", + "-m", + "--model", + help="Decision model name.", + ), + state: Optional[str] = typer.Option( + None, + "--state", + help="Business state under decision: text, or a JSON " + "object/array. Accepts @file and stdin (-).", + ), + questions: str = typer.Option( + ..., + "--questions", + help="Questions as a JSON object keyed by question id. " + "Accepts @file and stdin (-).", + ), + ) -> None: + """Run one decision round.""" + obj = ctx.obj + renderer = obj.renderer + parsed_state = _parse_state(state) + if parsed_state is None: + raise usage_error("--state is required") + parsed_questions = parse_json_object_arg( + questions, + option_name="--questions", + ) + if not parsed_questions: + raise usage_error("--questions must not be empty") + result = obj.client.decision.create( + model=model, + state=parsed_state, + questions=parsed_questions, + ) + renderer.print_rows( + _rows(result), + columns=_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.usage is not None: + renderer.note( + f"usage: {result.usage.model_dump(exclude_none=True)}", + ) + + return app diff --git a/dashscope/cli/commands/deployments.py b/dashscope/cli/commands/deployments.py new file mode 100644 index 00000000..035ca67d --- /dev/null +++ b/dashscope/cli/commands/deployments.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope deployments`` -- provisioned model deployments. + +A deployment is capacity you pay for by the hour, which is why ``scale`` +and ``delete`` confirm before acting: both change what a running endpoint +costs or whether it exists at all. ``create`` does not confirm -- it is +the command whose whole purpose is to spend, and asking "are you sure you +want to do the thing you just typed" would only train people into +``--yes``. + +``stream`` follows the deployment's server-side event feed, which is how +you watch a deployment come up without polling ``get`` in a loop. +""" + +from __future__ import annotations + +import time +from typing import Any, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import SERVER, CliError +from dashscope.cli.input import parse_json_object_arg +from dashscope.cli.output import render_stream_event +from dashscope.cli.progress import validate_wait_timeout + +_HELP = r"""Provision and manage model deployments. + +A deployment gives a model dedicated capacity. `deployments create` +provisions it, `deployments stream` watches it come up, `deployments +scale` resizes it, and `deployments delete` tears it down. + +`scale` and `delete` are destructive and ask first. Pass `--yes` to skip +the prompt; without a terminal and without `--yes` they refuse with exit 2. +""" + +_DEPLOYMENT_COLUMNS = ( + "deployed_model", + "model", + "status", + "capacity", + "ready_capacity", + "created_at", +) + +_LIST_COLUMNS = ("deployed_model", "model", "status", "capacity") + +DEFAULT_WAIT_TIMEOUT = 3600.0 +POLL_INTERVAL = 30.0 +_PENDING_STATUSES = {"PENDING", "DEPLOYING"} + + +def _wait_for_deployment( + client: Any, + deployment: Any, + deployed_model: Optional[str], + timeout: float, +) -> Any: + name = deployed_model or getattr(deployment, "deployed_model", None) + if not name: + raise CliError(SERVER, "deployment response has no deployed_model") + deadline = time.monotonic() + timeout + current = deployment + while getattr(current, "status", None) in _PENDING_STATUSES: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise CliError( + SERVER, + f"timed out after {timeout}s waiting for deployment {name}", + ) + time.sleep(min(POLL_INTERVAL, remaining)) + current = client.deployments.get(name) + return current + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("create") + def create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Model to deploy, e.g. a fine-tune output model.", + ), + capacity: int = typer.Option( + 1, + "-c", + "--capacity", + help="Provisioned capacity to request.", + ), + model_version: Optional[str] = typer.Option( + None, + "--model-version", + help="Model version; defaults to the latest.", + ), + suffix: Optional[str] = typer.Option( + None, + "--suffix", + help="Suffix for the generated deployed-model name.", + ), + plan: Optional[str] = typer.Option( + None, + "--plan", + help="Deployment plan.", + ), + template_id: Optional[str] = typer.Option(None, "--template-id"), + deploy_spec: Optional[str] = typer.Option( + None, + "--deploy-spec", + help="Deploy spec as a JSON object. Required by MU deployments. " + "Accepts @file.", + ), + billing_method: Optional[str] = typer.Option( + None, + "--billing-method", + help="Required by MU deployments, together with --deploy-spec.", + ), + wait: bool = typer.Option( + False, + "--wait/--no-wait", + help="Block until the deployment leaves PENDING/DEPLOYING.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="With --wait, how long to block before giving up (seconds).", + ), + ) -> None: + """Create a deployment.""" + obj = ctx.obj + renderer = obj.renderer + deployment = obj.client.deployments.create( + model=model, + capacity=capacity, + model_version=model_version, + suffix=suffix, + plan=plan, + template_id=template_id, + deploy_spec=parse_json_object_arg( + deploy_spec, + option_name="--deploy-spec", + ), + billing_method=billing_method, + ) + if wait: + validate_wait_timeout(timeout) + deployment = _wait_for_deployment( + obj.client, + deployment, + None, + timeout, + ) + if renderer.table_mode: + renderer.print_object(deployment, columns=_DEPLOYMENT_COLUMNS) + else: + renderer.print_object(deployment) + + @app.command("get") + def get( + ctx: typer.Context, + deployed_model: str = typer.Argument(..., help="Deployed model name."), + ) -> None: + """Show one deployment.""" + obj = ctx.obj + renderer = obj.renderer + deployment = obj.client.deployments.get(deployed_model) + if renderer.table_mode: + renderer.print_object(deployment, columns=_DEPLOYMENT_COLUMNS) + else: + renderer.print_object(deployment) + + @app.command("wait") + def wait( + ctx: typer.Context, + deployed_model: str = typer.Argument(..., help="Deployed model name."), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to block before giving up (seconds).", + ), + ) -> None: + """Wait until a deployment leaves PENDING/DEPLOYING.""" + obj = ctx.obj + validate_wait_timeout(timeout) + deployment = _wait_for_deployment( + obj.client, + obj.client.deployments.get(deployed_model), + deployed_model, + timeout, + ) + if obj.renderer.table_mode: + obj.renderer.print_object(deployment, columns=_DEPLOYMENT_COLUMNS) + else: + obj.renderer.print_object(deployment) + + @app.command("list") + def list_deployments( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Deployments per page."), + ) -> None: + """List deployments.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.deployments.list(page_no=page, page_size=size) + renderer.print_rows( + result.deployments, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total_count: + pages = max(1, -(-result.total_count // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total_count}", + ) + + @app.command("scale") + def scale( + ctx: typer.Context, + deployed_model: str = typer.Argument(..., help="Deployed model name."), + capacity: int = typer.Option( + ..., + "--capacity", + help="New provisioned capacity.", + ), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Resize a deployment.""" + confirm_or_die( + f"scale deployment {deployed_model} to capacity {capacity}?", + yes=yes, + ) + obj = ctx.obj + renderer = obj.renderer + deployment = obj.client.deployments.scale( + deployed_model, + capacity=capacity, + ) + if renderer.table_mode: + renderer.print_object(deployment, columns=_DEPLOYMENT_COLUMNS) + else: + renderer.print_object(deployment) + + @app.command("delete") + def delete( + ctx: typer.Context, + deployed_model: str = typer.Argument(..., help="Deployed model name."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a deployment.""" + confirm_or_die(f"delete deployment {deployed_model}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object( + obj.client.deployments.delete(deployed_model), + ) + + @app.command("stream") + def stream( + ctx: typer.Context, + deployed_model: str = typer.Argument(..., help="Deployed model name."), + ) -> None: + """Follow a deployment's event stream until the server closes it.""" + obj = ctx.obj + renderer = obj.renderer + for event in obj.client.deployments.stream_events(deployed_model): + render_stream_event(renderer, event) + + return app diff --git a/dashscope/cli/commands/embeddings.py b/dashscope/cli/commands/embeddings.py new file mode 100644 index 00000000..0f5098f6 --- /dev/null +++ b/dashscope/cli/commands/embeddings.py @@ -0,0 +1,303 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope embeddings`` -- text embedding and batch jobs. + +Two modes: ``create`` for synchronous single-call embedding, and the +``jobs`` subgroup for asynchronous batch tasks over a file URL. The +batch jobs follow the same lifecycle as fine-tune jobs (create, fetch, +wait, cancel, list), so the ``--wait`` spinner pattern carries over. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_arg, + parse_json_object_arg, + read_text_arg, +) +from dashscope.cli.progress import validate_wait_timeout, wait_job + +_HELP = r"""Generate text embeddings. + +`embeddings create` runs a synchronous call and returns the vectors +immediately. For large batches, `embeddings jobs create` submits an +async task over a file URL; use `--wait` to block until it finishes. +""" + +_JOBS_HELP = r"""Manage async batch embedding tasks. + +A batch task processes a file of texts asynchronously. `jobs create` +submits one; `jobs get` checks the current state; `jobs list` pages +through all tasks; `jobs cancel` stops a pending task. + +`cancel` is destructive and asks first. Pass `--yes` to skip the prompt; +without a terminal and without `--yes` it refuses with exit 2. +""" + +_JOB_COLUMNS = ( + "task_id", + "status", + "model", + "created_at", +) + +_LIST_COLUMNS = ("task_id", "status", "model", "created_at") + + +def _parse_input(raw: Optional[str], *, option_name: str) -> Any: + """Parse --input: a JSON array of strings, or a single literal string.""" + text = read_text_arg(raw, option_name=option_name) + if text is None: + return None + stripped = text.strip() + if stripped.startswith("["): + value = parse_json_arg(raw, option_name=option_name) + if not isinstance(value, list): + raise usage_error(f"{option_name}: expected a JSON array") + return value + return text + + +def _render_job(renderer, job: Any) -> None: + """Table mode trims to the lifecycle columns; JSON shows everything.""" + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("create") + def create( + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Embedding model name.", + ), + input_text: Optional[str] = typer.Option( + None, + "--input", + help="Text to embed. A JSON array for multiple texts, or a " + "single string. Accepts @file and stdin (-).", + ), + text_type: Optional[str] = typer.Option( + None, + "--text-type", + help="Type of text: query or document.", + ), + dimension: Optional[int] = typer.Option( + None, + "--dimension", + help="Output vector dimension.", + ), + output_type: Optional[str] = typer.Option( + None, + "--output-type", + help="Output format.", + ), + instruct: Optional[str] = typer.Option( + None, + "--instruct", + help="Instruction prefix for the embedding model.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + ) -> None: + """Generate embeddings synchronously.""" + obj = ctx.obj + renderer = obj.renderer + parsed_input = _parse_input(input_text, option_name="--input") + if parsed_input is None: + raise usage_error("--input is required") + result = obj.client.embeddings.create( + model=model, + input=parsed_input, + text_type=text_type, + dimension=dimension, + output_type=output_type, + instruct=instruct, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if renderer.table_mode: + count = len(result.embeddings) + dims = ( + len(result.embeddings[0].embedding) + if count and result.embeddings[0].embedding + else 0 + ) + renderer.echo(f"{count} embedding(s), {dims} dimensions") + if result.usage: + renderer.note( + f"usage: {result.usage.model_dump(exclude_none=True)}", + ) + else: + renderer.print_object(result) + + # ---- jobs subgroup ---- # + jobs_app = typer.Typer(help=_JOBS_HELP, no_args_is_help=True) + + @jobs_app.command("create") + def jobs_create( + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Embedding model name.", + ), + file_url: str = typer.Option( + ..., + "--file-url", + help="URL of the file containing texts to embed.", + ), + text_type: Optional[str] = typer.Option( + None, + "--text-type", + help="Type of text: query or document.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + wait: bool = typer.Option( + False, + "--wait", + help="Block until the task reaches a terminal status.", + ), + timeout: float = typer.Option( + 3600.0, + "--timeout", + help="With --wait, how long to block before giving up (seconds).", + ), + ) -> None: + """Submit an async batch embedding task.""" + obj = ctx.obj + renderer = obj.renderer + if wait: + validate_wait_timeout(timeout) + job = obj.client.embeddings.jobs.create( + model=model, + file_url=file_url, + text_type=text_type, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if wait: + job = wait_job( + obj.client, + job, + timeout=timeout, + renderer=renderer, + label="embedding task", + waiter=obj.client.embeddings.jobs, + ) + _render_job(renderer, job) + + @jobs_app.command("get") + def jobs_get( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Batch task id."), + ) -> None: + """Check the current state of a batch task.""" + obj = ctx.obj + renderer = obj.renderer + job = obj.client.embeddings.jobs.fetch(task_id) + _render_job(renderer, job) + + @jobs_app.command("wait") + def jobs_wait( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Batch task id."), + timeout: float = typer.Option( + 3600.0, + "--timeout", + help="How long to block before giving up (seconds).", + ), + ) -> None: + """Block until a batch task reaches a terminal status.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + job = wait_job( + obj.client, + task_id, + timeout=timeout, + renderer=renderer, + label="embedding task", + waiter=obj.client.embeddings.jobs, + ) + _render_job(renderer, job) + + @jobs_app.command("list") + def jobs_list( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Tasks per page."), + status: Optional[str] = typer.Option( + None, + "--status", + help="Filter by status.", + ), + model_name: Optional[str] = typer.Option( + None, + "--model", + help="Filter by model name.", + ), + ) -> None: + """List batch embedding tasks.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.embeddings.jobs.list( + page_no=page, + page_size=size, + status=status, + model_name=model_name, + ) + renderer.print_rows( + result.tasks, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total: + pages = max(1, -(-result.total // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total}", + ) + + @jobs_app.command("cancel") + def jobs_cancel( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Batch task id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Cancel a pending batch task.""" + confirm_or_die(f"cancel embedding task {task_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.embeddings.jobs.cancel(task_id)) + + app.add_typer(jobs_app, name="jobs") + return app diff --git a/dashscope/cli/commands/files.py b/dashscope/cli/commands/files.py new file mode 100644 index 00000000..325a875c --- /dev/null +++ b/dashscope/cli/commands/files.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope files`` -- upload, list, inspect and delete files. + +Files are the prerequisite for fine-tuning: ``ft create -t `` +needs an id that only ``files upload`` can produce. The group covers the +full CRUD lifecycle so a pipeline can upload, verify, and clean up +without leaving the CLI. +""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die + +_HELP = r"""Upload and manage files. + +A file must be uploaded before it can be used as training data: +`dashscope files upload data.jsonl` returns a `file_id` that +`dashscope ft create -t ` consumes. + +`delete` is destructive and asks first. Pass `--yes` to skip the prompt; +without a terminal and without `--yes` it refuses with exit 2. +""" + +_FILE_COLUMNS = ( + "file_id", + "name", + "purpose", + "size", + "uploaded_at", +) + +_LIST_COLUMNS = ("file_id", "name", "purpose", "size") + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("upload") + def upload( + ctx: typer.Context, + file_path: str = typer.Argument( + ..., + help="Path to the file to upload.", + ), + purpose: str = typer.Option( + "fine-tune", + "--purpose", + help="Intended use of the file.", + ), + description: Optional[str] = typer.Option( + None, + "--description", + help="Optional description attached to the file.", + ), + ) -> None: + """Upload a file.""" + obj = ctx.obj + result = obj.client.files.upload( + file_path=file_path, + purpose=purpose, + description=description, + ) + if obj.renderer.table_mode: + obj.renderer.print_object(result, columns=_FILE_COLUMNS) + else: + obj.renderer.print_object(result) + + @app.command("list") + def list_files( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Files per page."), + ) -> None: + """List uploaded files.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.files.list(page_no=page, page_size=size) + renderer.print_rows( + result.files, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total_count: + pages = max(1, -(-result.total_count // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total_count}", + ) + + @app.command("get") + def get( + ctx: typer.Context, + file_id: str = typer.Argument(..., help="File id."), + ) -> None: + """Show one file.""" + obj = ctx.obj + result = obj.client.files.get(file_id) + if obj.renderer.table_mode: + obj.renderer.print_object(result, columns=_FILE_COLUMNS) + else: + obj.renderer.print_object(result) + + @app.command("delete") + def delete( + ctx: typer.Context, + file_id: str = typer.Argument(..., help="File id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a file.""" + confirm_or_die(f"delete file {file_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.files.delete(file_id)) + + return app diff --git a/dashscope/cli/commands/ft.py b/dashscope/cli/commands/ft.py new file mode 100644 index 00000000..97120379 --- /dev/null +++ b/dashscope/cli/commands/ft.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope ft`` -- fine-tune jobs, the second pattern command group. + +Covers the full lifecycle the design doc calls the differentiator: create, +inspect, list, follow the event stream, wait to completion, cancel, delete. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import parse_json_object_arg +from dashscope.cli.output import render_stream_event +from dashscope.cli.progress import validate_wait_timeout, wait_job + +_HELP = r"""Create and manage fine-tune jobs. + +Long-running work is what this group is for: `create --wait` blocks with a +spinner until the job reaches a terminal status, and `stream` follows the +server's event feed. A job that fails or times out exits 1 -- the command +never reports success for a job that did not succeed. + +`cancel` and `delete` are destructive and ask first. Pass `--yes` to skip +the prompt; without a terminal and without `--yes` they refuse with exit 2. +""" + +# Mirrors the 1.x `fine_tunes.py` default so muscle memory carries over. +DEFAULT_WAIT_TIMEOUT = 3600.0 + +_JOB_COLUMNS = ( + "job_id", + "status", + "model", + "finetuned_model", + "created_at", + "usage", +) + +_LIST_COLUMNS = ("job_id", "status", "model", "created_at") + + +def _merge_hyper_parameters( + explicit: Optional[Dict[str, Any]], + shortcuts: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Fold the shortcut flags into ``--hyper-parameters``. + + A key given both ways is refused rather than resolved: silently picking + a winner would hide a typo in whichever spelling lost, and the two + spellings are equally explicit. + """ + merged = dict(explicit or {}) + for key, value in shortcuts.items(): + if key in merged: + raise usage_error( + f"{key} was given twice: once in --hyper-parameters and once " + f"as a shortcut flag. Pass it one way only.", + ) + merged[key] = value + return merged or None + + +def _render_job(renderer, job: Any) -> None: + """Table mode trims to the lifecycle columns; JSON shows everything.""" + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("create") + def create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Base model to fine-tune. Deliberately does not fall back " + "to default_model: a chat default is not a fine-tune base.", + ), + training_file_ids: List[str] = typer.Option( + ..., + "-t", + "--training-file-ids", + help="Training file id. Repeatable.", + ), + validation_file_ids: Optional[List[str]] = typer.Option( + None, + "-v", + "--validation-file-ids", + help="Validation file id. Repeatable.", + ), + mode: Optional[str] = typer.Option( + None, + "--mode", + help="Training mode, e.g. sft or dpo.", + ), + hyper_parameters: Optional[str] = typer.Option( + None, + "--hyper-parameters", + help="Hyper-parameters as a JSON object. Accepts @file.", + ), + n_epochs: Optional[int] = typer.Option(None, "-e", "--n-epochs"), + batch_size: Optional[int] = typer.Option(None, "-b", "--batch-size"), + learning_rate: Optional[float] = typer.Option( + None, + "-l", + "--learning-rate", + ), + prompt_loss: Optional[float] = typer.Option( + None, + "-p", + "--prompt-loss", + ), + finetuned_model: Optional[str] = typer.Option( + None, + "--finetuned-model", + help="Name to give the resulting model.", + ), + wait: bool = typer.Option( + False, + "--wait", + help="Block until the job reaches a terminal status.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="With --wait, how long to block before giving up (seconds).", + ), + ) -> None: + """Start a fine-tune job.""" + obj = ctx.obj + renderer = obj.renderer + if wait: + validate_wait_timeout(timeout) + shortcuts = { + key: value + for key, value in { + "n_epochs": n_epochs, + "batch_size": batch_size, + "learning_rate": learning_rate, + "prompt_loss": prompt_loss, + }.items() + if value is not None + } + job = obj.client.fine_tunes.create( + model=model, + training_file_ids=list(training_file_ids), + validation_file_ids=( + list(validation_file_ids) if validation_file_ids else None + ), + mode=mode, + hyper_parameters=_merge_hyper_parameters( + parse_json_object_arg( + hyper_parameters, + option_name="--hyper-parameters", + ), + shortcuts, + ), + finetuned_model=finetuned_model, + ) + if wait: + job = wait_job( + obj.client, + job, + timeout=timeout, + renderer=renderer, + ) + _render_job(renderer, job) + + @app.command("get") + def get( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Fine-tune job id."), + ) -> None: + """Show one job.""" + obj = ctx.obj + renderer = obj.renderer + job = obj.client.fine_tunes.get(job_id) + _render_job(renderer, job) + + @app.command("wait") + def wait( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Fine-tune job id."), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to block before giving up (seconds).", + ), + ) -> None: + """Block until a job reaches a terminal status.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + job = wait_job( + obj.client, + job_id, + timeout=timeout, + renderer=renderer, + ) + _render_job(renderer, job) + + @app.command("list") + def list_jobs( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Jobs per page."), + ) -> None: + """List fine-tune jobs.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.fine_tunes.list(page_no=page, page_size=size) + renderer.print_rows( + result.jobs, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total_count: + pages = max(1, -(-result.total_count // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total_count}", + ) + + @app.command("stream") + def stream( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Fine-tune job id."), + ) -> None: + """Follow a job's event stream until the server closes it.""" + obj = ctx.obj + renderer = obj.renderer + for event in obj.client.fine_tunes.stream_events(job_id): + render_stream_event(renderer, event) + + @app.command("logs") + def logs( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Fine-tune job id."), + offset: int = typer.Option( + 1, + "--offset", + help="1-based first log line to read.", + ), + lines: int = typer.Option( + 1000, + "--lines", + help="How many lines to read.", + ), + ) -> None: + """Read a job's training logs.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.fine_tunes.logs( + job_id, + offset=offset, + lines=lines, + ) + if renderer.table_mode: + # Raw text, not a table: the value here is the log itself, and + # a redirect should capture exactly what the trainer printed. + renderer.echo(result.content) + else: + renderer.print_object(result) + + @app.command("cancel") + def cancel( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Fine-tune job id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Cancel a running job.""" + confirm_or_die(f"cancel fine-tune job {job_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.fine_tunes.cancel(job_id)) + + @app.command("delete") + def delete( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Fine-tune job id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a job and its output model.""" + confirm_or_die(f"delete fine-tune job {job_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.fine_tunes.delete(job_id)) + + return app diff --git a/dashscope/cli/commands/images.py b/dashscope/cli/commands/images.py new file mode 100644 index 00000000..f016d5fd --- /dev/null +++ b/dashscope/cli/commands/images.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope images`` -- text-to-image generation and its async tasks. + +``generate`` is the command people want: it submits and waits, then prints +the image URLs. The ``jobs`` subgroup is for a pipeline that would rather +submit now and collect later -- the same task lifecycle the other +job-shaped groups expose. + +Which wire protocol runs is the SDK's decision, not the CLI's: wan2.6 +models answer synchronously on the multimodal endpoint, everything else +goes through the text2image task endpoint. Both return the same ``Job``, +so the rendering below does not branch on the model. +""" + +from __future__ import annotations + +from typing import Any, List, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import parse_json_object_arg, read_text_arg +from dashscope.cli.progress import validate_wait_timeout, wait_job + +_HELP = r"""Generate images from text. + +`images generate` submits and waits, then prints one image URL per line -- +the shape a shell pipeline wants. `images jobs` exposes the async task +directly for a caller that would rather submit now and collect later. + +Reference images (`--image-url`) apply to the image-editing models; a local +path is uploaded for you. +""" + +_JOBS_HELP = r"""Manage async image generation tasks. + +`jobs create` submits a task and returns its id immediately; add `--wait` +to block until it finishes. `jobs get` checks one task, `jobs list` pages +through them, and `jobs cancel` stops a pending one. + +`cancel` is destructive and asks first. Pass `--yes` to skip the prompt; +without a terminal and without `--yes` it refuses with exit 2. +""" + +DEFAULT_WAIT_TIMEOUT = 3600.0 + +_JOB_COLUMNS = ("task_id", "status", "submitted_at", "finished_at") + +_LIST_COLUMNS = ("task_id", "status", "submitted_at") + + +def _read_prompt(raw: Optional[str]) -> str: + text = read_text_arg(raw, option_name="--prompt") + if not text or not text.strip(): + raise usage_error("--prompt must not be empty") + return text + + +def _render_job(renderer, job: Any) -> None: + """A finished job shows its URLs; anything else shows its state.""" + if not renderer.table_mode: + renderer.print_object(job) + return + urls = job.image_urls + if urls: + for url in urls: + renderer.echo(url) + return + renderer.print_object(job, columns=_JOB_COLUMNS) + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("generate") + def generate( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Image model, e.g. wan2.2-t2i-flash or wan2.6-t2i.", + ), + prompt: str = typer.Option( + ..., + "-p", + "--prompt", + help="What to draw. Accepts @file and stdin (-).", + ), + negative_prompt: Optional[str] = typer.Option( + None, + "--negative-prompt", + help="What to leave out.", + ), + n: Optional[int] = typer.Option(None, "-n", help="Number of images."), + size: Optional[str] = typer.Option( + None, + "--size", + help="Image size, e.g. 1024*1024 (1024x1024 also accepted).", + ), + image_url: Optional[List[str]] = typer.Option( + None, + "--image-url", + help="Reference image: URL, local path, or oss:// key. Repeatable.", + ), + function: Optional[str] = typer.Option( + None, + "--function", + help="Editing function name, for the models that take one.", + ), + seed: Optional[int] = typer.Option(None, "--seed"), + style: Optional[str] = typer.Option(None, "--style"), + prompt_extend: Optional[bool] = typer.Option( + None, + "--prompt-extend/--no-prompt-extend", + help="Let the model rewrite the prompt.", + ), + watermark: Optional[bool] = typer.Option( + None, + "--watermark/--no-watermark", + ), + extra_input: Optional[str] = typer.Option( + None, + "--extra-input", + help="Extra input fields as a JSON object. Accepts @file.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to wait for the task before giving up (seconds).", + ), + submit_timeout: Optional[float] = typer.Option( + None, + "--submit-timeout", + help="Timeout for the submit request only.", + ), + ) -> None: + """Generate an image and wait for the result.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + job = obj.client.images.generate( + model=model, + prompt=_read_prompt(prompt), + negative_prompt=negative_prompt, + n=n, + size=size, + image_urls=list(image_url) if image_url else None, + extra_input=parse_json_object_arg( + extra_input, + option_name="--extra-input", + ), + function=function, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + timeout=timeout, + submit_timeout=submit_timeout, + ) + _render_job(renderer, job) + + # ---- jobs subgroup ---- # + jobs_app = typer.Typer(help=_JOBS_HELP, no_args_is_help=True) + + @jobs_app.command("create") + def jobs_create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Image model, e.g. wan2.2-t2i-flash or wan2.6-t2i.", + ), + prompt: str = typer.Option( + ..., + "-p", + "--prompt", + help="What to draw. Accepts @file and stdin (-).", + ), + negative_prompt: Optional[str] = typer.Option( + None, + "--negative-prompt", + ), + n: Optional[int] = typer.Option(None, "-n", help="Number of images."), + size: Optional[str] = typer.Option(None, "--size"), + image_url: Optional[List[str]] = typer.Option( + None, + "--image-url", + help="Reference image: URL, local path, or oss:// key. Repeatable.", + ), + function: Optional[str] = typer.Option(None, "--function"), + seed: Optional[int] = typer.Option(None, "--seed"), + style: Optional[str] = typer.Option(None, "--style"), + prompt_extend: Optional[bool] = typer.Option( + None, + "--prompt-extend/--no-prompt-extend", + ), + watermark: Optional[bool] = typer.Option( + None, + "--watermark/--no-watermark", + ), + extra_input: Optional[str] = typer.Option( + None, + "--extra-input", + help="Extra input fields as a JSON object. Accepts @file.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + wait: bool = typer.Option( + False, + "--wait", + help="Block until the task reaches a terminal status.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="With --wait, how long to block before giving up (seconds).", + ), + ) -> None: + """Submit an image task without waiting for it.""" + obj = ctx.obj + renderer = obj.renderer + if wait: + validate_wait_timeout(timeout) + job = obj.client.images.jobs.create( + model=model, + prompt=_read_prompt(prompt), + negative_prompt=negative_prompt, + n=n, + size=size, + image_urls=list(image_url) if image_url else None, + extra_input=parse_json_object_arg( + extra_input, + option_name="--extra-input", + ), + function=function, + seed=seed, + style=style, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if wait: + job = wait_job( + obj.client, + job, + timeout=timeout, + renderer=renderer, + label="image task", + waiter=obj.client.images.jobs, + ) + _render_job(renderer, job) + elif renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @jobs_app.command("get") + def jobs_get( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Image task id."), + ) -> None: + """Check the current state of an image task.""" + obj = ctx.obj + renderer = obj.renderer + job = obj.client.images.jobs.fetch(task_id) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @jobs_app.command("wait") + def jobs_wait( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Image task id."), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to block before giving up (seconds).", + ), + ) -> None: + """Block until an image task reaches a terminal state.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + job = wait_job( + obj.client, + task_id, + timeout=timeout, + renderer=renderer, + label="image task", + waiter=obj.client.images.jobs, + ) + _render_job(renderer, job) + + @jobs_app.command("list") + def jobs_list( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Tasks per page."), + status: Optional[str] = typer.Option(None, "--status"), + model_name: Optional[str] = typer.Option( + None, + "--model", + help="Filter by model name.", + ), + api_key_id: Optional[str] = typer.Option(None, "--api-key-id"), + region: Optional[str] = typer.Option(None, "--region"), + start_time: Optional[str] = typer.Option( + None, + "--start-time", + help="Only tasks submitted at or after this time.", + ), + end_time: Optional[str] = typer.Option( + None, + "--end-time", + help="Only tasks submitted at or before this time.", + ), + ) -> None: + """List image tasks.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.images.jobs.list( + page_no=page, + page_size=size, + status=status, + model_name=model_name, + api_key_id=api_key_id, + region=region, + start_time=start_time, + end_time=end_time, + ) + renderer.print_rows( + result.tasks, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total: + pages = max(1, -(-result.total // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total}", + ) + + @jobs_app.command("cancel") + def jobs_cancel( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Image task id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Cancel a pending image task.""" + confirm_or_die(f"cancel image task {task_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.images.jobs.cancel(task_id)) + + app.add_typer(jobs_app, name="jobs") + return app diff --git a/dashscope/cli/commands/models.py b/dashscope/cli/commands/models.py new file mode 100644 index 00000000..226fd9a8 --- /dev/null +++ b/dashscope/cli/commands/models.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope models`` -- list and inspect available models. + +The cheapest discovery command: no side effects, no state, just the +catalog. Useful in a pipeline to verify that a model name exists before +handing it to ``chat create`` or ``ft create``. +""" + +from __future__ import annotations + +from typing import Optional + +import typer + +_HELP = r"""List and inspect available models. + +`models list` pages through the catalog; `models get ` looks up one +model by name and exits 4 if it does not exist. +""" + +_MODEL_COLUMNS = ( + "model", + "source", + "provider", + "open_source", +) + +_LIST_COLUMNS = ("model", "source", "provider") + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("list") + def list_models( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Models per page."), + provider: Optional[str] = typer.Option( + None, + "--provider", + help="Filter by provider or source (client-side, per page).", + ), + ) -> None: + """List available models.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.models.list( + page_no=page, + page_size=size, + provider=provider, + ) + renderer.print_rows( + result.models, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total_count: + pages = max(1, -(-result.total_count // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total_count}", + ) + + @app.command("get") + def get( + ctx: typer.Context, + model: str = typer.Argument(..., help="Model name."), + ) -> None: + """Show one model.""" + obj = ctx.obj + result = obj.client.models.get(model) + if obj.renderer.table_mode: + obj.renderer.print_object(result, columns=_MODEL_COLUMNS) + else: + obj.renderer.print_object(result) + + return app diff --git a/dashscope/cli/commands/multimodal.py b/dashscope/cli/commands/multimodal.py new file mode 100644 index 00000000..85c95b12 --- /dev/null +++ b/dashscope/cli/commands/multimodal.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope multimodal`` -- the explicit multimodal-generation endpoint. + +``dashscope chat create`` already routes a conversation that carries media +parts to this endpoint on its own, so these commands are for the two cases +that routing does not cover: a text-only conversation that still has to hit +the multimodal service (a qwen-vl model, say), and multimodal embeddings, +which have no text-only equivalent. + +Message content parts are passed through untouched -- ``{"image": "a.png"}`` +included -- because the SDK uploads local paths itself. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_arg, + parse_json_object_arg, + parse_tools_arg, + read_prompt_or_messages, + read_text_arg, +) +from dashscope.cli.output import render_completion, render_text_stream + +_HELP = r"""Call the multimodal-generation endpoint directly. + +`multimodal chat create` is `chat create` against the multimodal service; +`multimodal embeddings create` embeds text, image, audio and video into one +shared vector space. +""" + +_CHAT_HELP = r"""Multimodal chat completions. + +Same conversation input as `dashscope chat create`: `--messages` (JSON or +JSONL, `@file` or `-` for stdin), `--prompt` with an optional `--system`, or +piped stdin. Content parts may name local paths; the SDK uploads them. +""" + +_EMBEDDINGS_HELP = r"""Multimodal embeddings. + +Each input item carries exactly one modality. Repeat `--text`, `--image`, +`--audio` or `--video` to embed several items in one call, or pass the whole +list as JSON with `--input` when the ordering across modalities matters. +""" + +_MODALITIES = ("text", "image", "audio", "video") + + +def _parse_embedding_input( + raw: Optional[str], + modalities: Dict[str, Optional[List[str]]], +) -> List[Dict[str, Any]]: + """Resolve the items to embed: ``--input`` wins over the flags.""" + if raw is not None: + value = parse_json_arg(raw, option_name="--input") + items = [value] if isinstance(value, dict) else value + if not isinstance(items, list): + raise usage_error( + f"--input: expected a JSON object or array, got " + f"{type(value).__name__}", + ) + for index, item in enumerate(items): + if not isinstance(item, dict): + raise usage_error( + f"--input: entry {index} is not a JSON object " + f"(got {type(item).__name__})", + ) + if not items: + raise usage_error("--input: no items to embed") + return list(items) + + items = [] + for modality in _MODALITIES: + for value in modalities.get(modality) or (): + items.append({modality: value}) + if not items: + raise usage_error( + "nothing to embed: pass --text, --image, --audio, --video " + "or --input", + ) + return items + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + # ---- chat subgroup ---- # + chat_app = typer.Typer(help=_CHAT_HELP, no_args_is_help=True) + + @chat_app.command("create") + def chat_create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: Optional[str] = typer.Option( + None, + "-m", + "--model", + help="Model to call. Falls back to the configured default_model.", + ), + prompt: Optional[str] = typer.Option( + None, + "--prompt", + help="Prompt text, @file, or - to read stdin.", + ), + messages: Optional[str] = typer.Option( + None, + "--messages", + help="Conversation as a JSON array, JSONL, or an object with a " + "messages key. Accepts @file and - for stdin.", + ), + system: Optional[str] = typer.Option( + None, + "--system", + help="System prompt prepended to --prompt.", + ), + stream: bool = typer.Option( + False, + "--stream", + help="Stream the response instead of waiting for it to finish.", + ), + tools: Optional[str] = typer.Option( + None, + "--tools", + help="Tool definitions as a JSON array. Accepts @file.", + ), + temperature: Optional[float] = typer.Option(None, "--temperature"), + top_p: Optional[float] = typer.Option(None, "--top-p"), + top_k: Optional[int] = typer.Option(None, "--top-k"), + max_tokens: Optional[int] = typer.Option(None, "--max-tokens"), + stop: Optional[List[str]] = typer.Option( + None, + "--stop", + help="Stop sequence. Repeatable.", + ), + seed: Optional[int] = typer.Option(None, "--seed"), + enable_thinking: Optional[bool] = typer.Option( + None, + "--enable-thinking/--no-enable-thinking", + help="Toggle reasoning output. Omit to use the model's default.", + ), + enable_search: Optional[bool] = typer.Option( + None, + "--enable-search/--no-enable-search", + help="Toggle web search grounding. Omit to use the default.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra request parameters as a JSON object, passed through " + "verbatim. Accepts @file.", + ), + ) -> None: + """Send one multimodal chat completion request.""" + obj = ctx.obj + renderer = obj.renderer + kwargs: Dict[str, Any] = { + "model": obj.resolve_model(model), + "messages": read_prompt_or_messages( + prompt=prompt, + messages=messages, + system=system, + ), + "tools": parse_tools_arg(tools), + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "max_tokens": max_tokens, + "stop": list(stop) if stop else None, + "seed": seed, + "enable_thinking": enable_thinking, + "enable_search": enable_search, + "parameters": parse_json_object_arg( + parameters, + option_name="--parameters", + ), + } + chat = obj.client.multimodal.chat + + if not stream: + render_completion(renderer, chat.create(**kwargs)) + return + + result = chat.create(stream=True, **kwargs) + try: + render_text_stream(renderer, result) + finally: + # Runs on Ctrl-C too, so the httpx stream is not left open. + result.close() + + # ---- embeddings subgroup ---- # + embeddings_app = typer.Typer(help=_EMBEDDINGS_HELP, no_args_is_help=True) + + @embeddings_app.command("create") + def embeddings_create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Multimodal embedding model, e.g. multimodal-embedding-v1.", + ), + text: Optional[List[str]] = typer.Option( + None, + "--text", + help="Text to embed. Repeatable. Accepts @file.", + ), + image: Optional[List[str]] = typer.Option( + None, + "--image", + help="Image URL or local path. Repeatable.", + ), + audio: Optional[List[str]] = typer.Option( + None, + "--audio", + help="Audio URL or local path. Repeatable.", + ), + video: Optional[List[str]] = typer.Option( + None, + "--video", + help="Video URL or local path. Repeatable.", + ), + input_json: Optional[str] = typer.Option( + None, + "--input", + help="Items as a JSON array (or one object), each setting a " + "single modality key. Overrides the per-modality flags. " + "Accepts @file and - for stdin.", + ), + dimension: Optional[int] = typer.Option( + None, + "--dimension", + help="Output vector dimension.", + ), + output_type: Optional[str] = typer.Option(None, "--output-type"), + fps: Optional[float] = typer.Option( + None, + "--fps", + help="Video sampling rate (snapshot models only).", + ), + instruct: Optional[str] = typer.Option( + None, + "--instruct", + help="Instruction prefix for the embedding model.", + ), + enable_fusion: Optional[bool] = typer.Option( + None, + "--enable-fusion/--no-enable-fusion", + help="Fuse the items into one vector instead of one per item.", + ), + res_level: Optional[int] = typer.Option( + None, + "--res-level", + help="Resolution tier, 0-3 (snapshot models only).", + ), + max_video_frames: Optional[int] = typer.Option( + None, + "--max-video-frames", + help="Frame cap (snapshot models only).", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + ) -> None: + """Embed text, image, audio and video into one vector space.""" + obj = ctx.obj + renderer = obj.renderer + # --text accepts @file like every other payload option, so each + # value goes through the reader rather than being taken literally. + resolved = { + modality: [ + read_text_arg(value, option_name=f"--{modality}") + for value in values + ] + for modality, values in ( + ("text", text), + ("image", image), + ("audio", audio), + ("video", video), + ) + if values + } + result = obj.client.multimodal.embeddings.create( + model=model, + input=_parse_embedding_input(input_json, resolved), + dimension=dimension, + output_type=output_type, + fps=fps, + instruct=instruct, + enable_fusion=enable_fusion, + res_level=res_level, + max_video_frames=max_video_frames, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if not renderer.table_mode: + renderer.print_object(result) + return + count = len(result.embeddings) + dims = ( + len(result.embeddings[0].embedding) + if count and result.embeddings[0].embedding + else 0 + ) + renderer.echo(f"{count} embedding(s), {dims} dimensions") + if result.usage: + renderer.note( + f"usage: {result.usage.model_dump(exclude_none=True)}", + ) + + app.add_typer(chat_app, name="chat") + app.add_typer(embeddings_app, name="embeddings") + return app diff --git a/dashscope/cli/commands/oss.py b/dashscope/cli/commands/oss.py new file mode 100644 index 00000000..50feaed3 --- /dev/null +++ b/dashscope/cli/commands/oss.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope oss`` -- upload a local file for a DashScope model.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from dashscope.cli.errors import usage_error + +_HELP = r"""Upload a local file to DashScope-managed OSS. + +The returned oss:// URL can be passed to models that accept OSS inputs. This +uses the SDK's upload-policy request and credential-free OSS POST; no +DashScope API key is sent to the OSS endpoint. +""" + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("upload") + def upload( + ctx: typer.Context, + file: str = typer.Option( + ..., + "-f", + "--file", + help="Path to the file to upload.", + ), + model: str = typer.Option( + ..., + "-m", + "--model", + help="Model the file will be used with.", + ), + ) -> None: + """Upload one file and print its oss:// URL.""" + if not file or not model: + raise usage_error("--model and --file must not be empty") + path = Path(file).expanduser() + if not path.is_file(): + raise usage_error(f"--file: file not found: {path}") + + from dashscope.resources._oss_upload import upload_local_file + + url, _ = upload_local_file(ctx.obj.client, model, str(path)) + if ctx.obj.renderer.table_mode: + ctx.obj.renderer.echo(url) + else: + ctx.obj.renderer.print_object({"url": url}) + + return app diff --git a/dashscope/cli/commands/rerank.py b/dashscope/cli/commands/rerank.py new file mode 100644 index 00000000..f64d632f --- /dev/null +++ b/dashscope/cli/commands/rerank.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope rerank`` -- document reranking. + +One synchronous call: give it a query and a list of documents, get back +a relevance-sorted list. The CLI accepts documents as a JSON array or +as repeatable ``--document`` flags, whichever is easier in a pipeline. +""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_arg, + parse_json_object_arg, + read_text_arg, +) + +_HELP = r"""Rerank documents by relevance to a query. + +`rerank create` takes a query and a list of documents, and returns them +sorted by relevance score. Documents can be passed as a JSON array via +`--documents`, or one at a time via repeatable `--document` flags. +""" + +_RESULT_COLUMNS = ( + "index", + "relevance_score", +) + + +def _parse_documents( + raw: Optional[str], + repeatable: Optional[List[str]], +) -> List[str]: + """Merge --documents (JSON array) and --document (repeatable) into one list.""" + docs: List[str] = [] + if raw is not None: + text = read_text_arg(raw, option_name="--documents") + if text is not None: + value = parse_json_arg(raw, option_name="--documents") + if not isinstance(value, list): + raise usage_error( + "--documents: expected a JSON array of strings", + ) + docs.extend(str(item) for item in value) + if repeatable: + docs.extend(repeatable) + return docs + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("create") + def create( + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Rerank model name.", + ), + query: str = typer.Option( + ..., + "-q", + "--query", + help="The query to rank documents against. Accepts @file and stdin (-).", + ), + documents: Optional[str] = typer.Option( + None, + "--documents", + help="Documents as a JSON array of strings. Accepts @file.", + ), + document: Optional[List[str]] = typer.Option( + None, + "--document", + "-d", + help="One document. Repeatable.", + ), + top_n: Optional[int] = typer.Option( + None, + "--top-n", + help="Return only the top N results.", + ), + return_documents: Optional[bool] = typer.Option( + None, + "--return-documents/--no-return-documents", + help="Include the document text in each result.", + ), + instruct: Optional[str] = typer.Option( + None, + "--instruct", + help="Instruction prefix for the rerank model.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + ) -> None: + """Rerank documents by relevance.""" + obj = ctx.obj + renderer = obj.renderer + query_text = read_text_arg(query, option_name="--query") + if not query_text: + raise usage_error("--query is required") + docs = _parse_documents(documents, document) + if not docs: + raise usage_error( + "no documents given: pass --documents (JSON array) " + "or repeatable --document", + ) + result = obj.client.rerank.create( + model=model, + query=query_text, + documents=docs, + top_n=top_n, + return_documents=return_documents, + instruct=instruct, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if renderer.table_mode: + renderer.print_rows(result.results, columns=_RESULT_COLUMNS) + if result.usage: + renderer.note( + f"usage: total_tokens={result.usage.total_tokens}", + ) + else: + renderer.print_object(result) + + return app diff --git a/dashscope/cli/commands/rl.py b/dashscope/cli/commands/rl.py new file mode 100644 index 00000000..41f8cc35 --- /dev/null +++ b/dashscope/cli/commands/rl.py @@ -0,0 +1,462 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope rl`` -- agentic reinforcement learning. + +Split into the two halves the SDK resource already has: + +``rl jobs`` the training-job lifecycle. RL jobs *are* fine-tune jobs + (same backend, same response model), so the verbs and the + table columns mirror ``dashscope ft`` deliberately. +``rl functions`` the RL-specific workflow steps -- registering the + customer's rollout/reward code, testing a registered + instance, uploading datasets, and reading or deleting the + function instances those steps mint. + +``jobs create`` runs the whole workflow (register, upload, submit) and +returns without blocking; ``--wait`` polls it to a terminal status. It +wraps ``asyncio.run``, so it cannot be used from inside a running event +loop -- which a CLI process never is. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import parse_json_object_arg +from dashscope.cli.progress import validate_wait_timeout, wait_job + +_HELP = r"""Run agentic reinforcement-learning training. + +`rl jobs` manages the training jobs; `rl functions` manages the rollout +and reward code those jobs run. A typical pass registers functions, +uploads data, then creates a job: + + dashscope rl functions register --rollout mypkg.rollout:MyRollout + dashscope rl functions upload-data --training-file train.jsonl + dashscope rl jobs create --config rl.yaml --wait + +`cancel` and `delete` are destructive and ask first. Pass `--yes` to skip +the prompt; without a terminal and without `--yes` they refuse with exit 2. +""" + +_JOBS_HELP = r"""Manage RL training jobs. + +`create` runs the full workflow -- register functions, upload datasets, +submit -- and returns the job without blocking. Add `--wait` to poll it to +a terminal status; a job that fails or times out exits 1. +""" + +_FUNCTIONS_HELP = r"""Manage the rollout and reward functions an RL job runs. + +`register` uploads your function components and returns entity and +instance ids; `test` runs one instance against a sample input; +`upload-data` uploads datasets and returns file ids; `logs` reads a +function instance's logs; `delete` removes an instance. +""" + +DEFAULT_WAIT_TIMEOUT = 3600.0 + +_JOB_COLUMNS = ( + "job_id", + "job_name", + "status", + "model", + "created_at", +) + +_LIST_COLUMNS = ("job_id", "job_name", "status", "model", "created_at") + + +def _parse_overrides(pairs: Optional[List[str]]) -> Dict[str, Any]: + """``--set key=value`` into a dict, JSON-decoding the value. + + The JSON decode with a string fallback is what lets one flag carry + both ``--set epochs=3`` and ``--set files=["a","b"]``; a config + override is typed by the config, not by the shell. + """ + overrides: Dict[str, Any] = {} + for pair in pairs or []: + key, sep, raw = pair.partition("=") + if not sep or not key.strip(): + raise usage_error(f"--set expects key=value, got {pair!r}") + try: + overrides[key.strip()] = json.loads(raw) + except json.JSONDecodeError: + overrides[key.strip()] = raw + return overrides + + +def build_app() -> typer.Typer: # pylint: disable=too-many-statements + app = typer.Typer(help=_HELP, no_args_is_help=True) + + # ---- jobs subgroup ---- # + jobs_app = typer.Typer(help=_JOBS_HELP, no_args_is_help=True) + + @jobs_app.command("create") + def jobs_create( + ctx: typer.Context, + config: Optional[str] = typer.Option( + None, + "--config", + help="Path to the training config file.", + ), + job_name: Optional[str] = typer.Option( + None, + "--job-name", + help="Name to give the job.", + ), + overrides: Optional[List[str]] = typer.Option( + None, + "--set", + metavar="KEY=VALUE", + help="Override one config key; repeatable. The value is parsed " + "as JSON and falls back to a plain string.", + ), + wait: bool = typer.Option( + False, + "--wait", + help="Block until the job reaches a terminal status.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="With --wait, how long to block before giving up (seconds).", + ), + ) -> None: + """Submit an RL training job.""" + obj = ctx.obj + renderer = obj.renderer + if wait: + validate_wait_timeout(timeout) + job = obj.client.rl.jobs.create( + config_path=config, + job_name=job_name, + **_parse_overrides(overrides), + ) + if wait: + job = wait_job( + obj.client, + job, + timeout=timeout, + renderer=renderer, + label="rl job", + waiter=obj.client.rl.jobs, + ) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @jobs_app.command("get") + def jobs_get( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="RL job id."), + ) -> None: + """Show one RL job.""" + obj = ctx.obj + renderer = obj.renderer + job = obj.client.rl.jobs.fetch(job_id) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @jobs_app.command("wait") + def jobs_wait( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="RL job id."), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to block before giving up (seconds).", + ), + ) -> None: + """Block until an RL job reaches a terminal status.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + job = wait_job( + obj.client, + job_id, + timeout=timeout, + renderer=renderer, + label="rl job", + waiter=obj.client.rl.jobs, + ) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @jobs_app.command("list") + def jobs_list( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Jobs per page."), + ) -> None: + """List RL jobs.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.rl.jobs.list(page_no=page, page_size=size) + renderer.print_rows( + result.jobs, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total_count: + pages = max(1, -(-result.total_count // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total_count}", + ) + + @jobs_app.command("logs") + def jobs_logs( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="RL job id."), + offset: int = typer.Option(1, "--offset", help="First log line."), + lines: int = typer.Option(1000, "--lines", help="How many lines."), + ) -> None: + """Read a job's training logs.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.rl.jobs.logs(job_id, offset=offset, lines=lines) + if renderer.table_mode: + renderer.echo(result.content) + else: + renderer.print_object(result) + + @jobs_app.command("cancel") + def jobs_cancel( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="RL job id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Cancel a running RL job.""" + confirm_or_die(f"cancel rl job {job_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.rl.jobs.cancel(job_id)) + + @jobs_app.command("delete") + def jobs_delete( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="RL job id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete an RL job.""" + confirm_or_die(f"delete rl job {job_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.rl.jobs.delete(job_id)) + + # ---- functions subgroup ---- # + functions_app = typer.Typer(help=_FUNCTIONS_HELP, no_args_is_help=True) + + @functions_app.command("register") + def functions_register( # pylint: disable=too-many-arguments + ctx: typer.Context, + rollout: Optional[List[str]] = typer.Option( + None, + "--rollout", + help="Rollout classpath. Repeatable.", + ), + reward: Optional[List[str]] = typer.Option( + None, + "--reward", + help="Reward classpath. Repeatable.", + ), + group_reward: Optional[List[str]] = typer.Option( + None, + "--group-reward", + help="Group-reward classpath. Repeatable.", + ), + workspace_dir: str = typer.Option( + "./", + "--workspace-dir", + help="Directory the classpaths are resolved against.", + ), + lazy_load: bool = typer.Option( + True, + "--lazy-load/--no-lazy-load", + ), + ) -> None: + """Register function components and get their ids.""" + if not (rollout or reward or group_reward): + raise usage_error( + "no functions given: pass --rollout, --reward or " + "--group-reward", + ) + obj = ctx.obj + obj.renderer.print_object( + obj.client.rl.register_functions( + rollout_classpaths=list(rollout or []), + reward_classpaths=list(reward or []), + group_reward_classpaths=list(group_reward or []), + workspace_dir=workspace_dir, + lazy_load=lazy_load, + ), + ) + + @functions_app.command("test") + def functions_test( # pylint: disable=too-many-arguments + ctx: typer.Context, + instance_id: str = typer.Option( + ..., + "--instance-id", + help="Function instance id, from `rl functions register`.", + ), + func_type: str = typer.Option( + ..., + "--type", + help="Function type: rollout, reward or group_reward.", + ), + input_data: str = typer.Option( + ..., + "--input", + help="Input payload as a JSON object. Accepts @file and stdin (-).", + ), + pull_logs: bool = typer.Option( + False, + "--pull-logs", + help="Also pull the instance's logs after verifying.", + ), + log_page_size: int = typer.Option(100, "--log-page-size"), + start_time: Optional[int] = typer.Option( + None, + "--start-time", + help="Log window start, epoch seconds.", + ), + end_time: Optional[int] = typer.Option( + None, + "--end-time", + help="Log window end, epoch seconds.", + ), + ) -> None: + """Run a registered function against a sample input.""" + obj = ctx.obj + obj.renderer.print_object( + obj.client.rl.test_functions( + instance_id=instance_id, + func_type=func_type, + input_data=parse_json_object_arg( + input_data, + option_name="--input", + ), + pull_logs=pull_logs, + log_page_size=log_page_size, + start_time=start_time, + end_time=end_time, + ), + ) + + @functions_app.command("upload-data") + def functions_upload_data( + ctx: typer.Context, + training_file: List[str] = typer.Option( + ..., + "--training-file", + help="Training dataset path. Repeatable.", + ), + validation_file: Optional[List[str]] = typer.Option( + None, + "--validation-file", + help="Validation dataset path. Repeatable.", + ), + ) -> None: + """Upload datasets and get their file ids.""" + obj = ctx.obj + obj.renderer.print_object( + obj.client.rl.upload_data( + list(training_file), + list(validation_file) if validation_file else None, + ), + ) + + @functions_app.command("logs") + def functions_logs( # pylint: disable=too-many-arguments + ctx: typer.Context, + instance_id: str = typer.Argument(..., help="Function instance id."), + all_pages: bool = typer.Option( + False, + "--all", + help="Aggregate every page instead of returning one.", + ), + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(100, "--size", help="Lines per page."), + start_time: Optional[int] = typer.Option( + None, + "--start-time", + help="Log window start, epoch seconds.", + ), + end_time: Optional[int] = typer.Option( + None, + "--end-time", + help="Log window end, epoch seconds.", + ), + keyword: Optional[List[str]] = typer.Option( + None, + "--keyword", + help="Only lines containing this keyword. Repeatable.", + ), + ) -> None: + """Read a function instance's logs.""" + obj = ctx.obj + renderer = obj.renderer + if all_pages: + lines = obj.client.rl.query_all_function_instance_logs( + instance_id, + page_size=size, + start_time=start_time, + end_time=end_time, + keywords=list(keyword) if keyword else None, + ) + if renderer.table_mode: + for line in lines: + renderer.echo(line) + else: + renderer.print_object(lines) + return + result = obj.client.rl.query_function_instance_logs( + instance_id, + page_number=page, + page_size=size, + start_time=start_time, + end_time=end_time, + keywords=list(keyword) if keyword else None, + ) + renderer.print_object(result) + + @functions_app.command("delete") + def functions_delete( + ctx: typer.Context, + instance_id: str = typer.Argument(..., help="Function instance id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Delete a function instance.""" + confirm_or_die(f"delete function instance {instance_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object( + obj.client.rl.delete_function_instance(instance_id), + ) + + app.add_typer(jobs_app, name="jobs") + app.add_typer(functions_app, name="functions") + return app diff --git a/dashscope/cli/commands/tingwu.py b/dashscope/cli/commands/tingwu.py new file mode 100644 index 00000000..fc17f599 --- /dev/null +++ b/dashscope/cli/commands/tingwu.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope tingwu`` -- offline TingWu summarization. + +One synchronous POST: a model plus a business-defined ``input`` payload +(``fileUrl``, ``appid`` and whatever the task type asks for), answered +with the parsed ``output`` dict. + +``client.tingwu.realtime`` is deliberately not exposed here. It is a +duplex websocket that needs a TingWu app id and a live audio feed, which +is not a shape a shell pipeline can own; ``audio asr stream`` already +covers the file-driven recognizer case. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional + +import typer + +from dashscope.cli.errors import usage_error +from dashscope.cli.input import parse_json_object_arg + +_HELP = r"""Run an offline TingWu task. + +`tingwu call` posts one task payload and prints the parsed result. The +payload is business-defined, so it is given either as a JSON object +(`--input @task.json`) or through the two fields almost every task type +wants (`--file-url`, `--app-id`); the flags win over the JSON. + +The realtime websocket session is SDK-only -- see the module docstring. +""" + + +def _build_input( + raw: Optional[str], + file_url: Optional[str], + app_id: Optional[str], +) -> Dict[str, Any]: + """``--input`` as the base, with the dedicated flags layered on top.""" + payload = parse_json_object_arg(raw, option_name="--input") or {} + if file_url is not None: + payload["fileUrl"] = file_url + if app_id is not None: + payload["appid"] = app_id + if not payload: + raise usage_error( + "nothing to run: pass --input, --file-url or --app-id", + ) + return payload + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("call") + def call( + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="TingWu model, e.g. tingwu-turbo.", + ), + task_input: Optional[str] = typer.Option( + None, + "--input", + help="Task payload as a JSON object. Accepts @file and stdin (-).", + ), + file_url: Optional[str] = typer.Option( + None, + "--file-url", + help="Publicly readable audio/video URL to analyze.", + ), + app_id: Optional[str] = typer.Option( + None, + "--app-id", + help="TingWu application id.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + ) -> None: + """Run one offline TingWu task and print its result.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.tingwu.call( + model=model, + input=_build_input(task_input, file_url, app_id), + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if renderer.table_mode: + # ``output`` is the parsed result dict (TaskId / Result / ...); + # it is the whole answer, so it gets the field table rather + # than a summary line. + renderer.print_object(result.output) + if result.usage is not None: + renderer.note( + f"usage: {result.usage.model_dump(exclude_none=True)}", + ) + else: + renderer.print_object(result) + + return app diff --git a/dashscope/cli/commands/tokenization.py b/dashscope/cli/commands/tokenization.py new file mode 100644 index 00000000..c2e896ff --- /dev/null +++ b/dashscope/cli/commands/tokenization.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope tokenization`` -- token counting. + +One synchronous call: give it a model and some text (or messages), get +back the token count. Useful for budgeting context windows before +handing a prompt to ``chat create``. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_object_arg, + parse_messages_arg, + read_text_arg, +) + +_HELP = r"""Count tokens for a model. + +`tokenization count` takes a model name and either a prompt or a +message list, and returns the token count. Use it to check whether a +payload fits the context window before sending it. +""" + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("count") + def count( + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Model to count tokens for.", + ), + prompt: Optional[str] = typer.Option( + None, + "--prompt", + help="Text to count. Accepts @file and stdin (-).", + ), + messages: Optional[str] = typer.Option( + None, + "--messages", + help='Messages as JSON array, JSONL, or {"messages": [...]}. ' + "Accepts @file and stdin (-).", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + ) -> None: + """Count tokens for a prompt or message list.""" + obj = ctx.obj + renderer = obj.renderer + parsed_messages: Optional[List[Dict[str, Any]]] = None + parsed_prompt: Optional[str] = None + if messages is not None: + parsed_messages = parse_messages_arg(messages) + if prompt is not None: + parsed_prompt = read_text_arg(prompt, option_name="--prompt") + if parsed_messages is None and parsed_prompt is None: + raise usage_error( + "no input given: pass --prompt or --messages", + ) + result = obj.client.tokenization.count( + model=model, + prompt=parsed_prompt, + messages=parsed_messages, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if renderer.table_mode: + renderer.echo(f"input_tokens: {result.input_tokens}") + if result.usage: + renderer.echo( + f"total_tokens: {result.usage.total_tokens}", + ) + else: + renderer.print_object(result) + + return app diff --git a/dashscope/cli/commands/videos.py b/dashscope/cli/commands/videos.py new file mode 100644 index 00000000..3cea7155 --- /dev/null +++ b/dashscope/cli/commands/videos.py @@ -0,0 +1,405 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope videos`` -- text/image-to-video generation. + +Shaped exactly like ``dashscope images``: ``generate`` submits and waits, +then prints the video URL; the ``jobs`` subgroup is for a caller that +would rather submit now and collect later. + +Video generation is slow enough (minutes, not seconds) that the wait is +the normal path rather than an opt-in -- which is also why ``--timeout`` +defaults to an hour here instead of a request timeout. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.errors import usage_error +from dashscope.cli.input import ( + parse_json_arg, + parse_json_object_arg, + read_text_arg, +) +from dashscope.cli.progress import validate_wait_timeout, wait_job + +_HELP = r"""Generate video from text or an image. + +`videos generate` submits and waits, then prints the video URL -- the +shape a shell pipeline wants. `videos jobs` exposes the async task +directly for a caller that would rather submit now and collect later. + +Generation takes minutes; raise `--timeout` for long clips. +""" + +_JOBS_HELP = r"""Manage async video generation tasks. + +`jobs create` submits a task and returns its id immediately; add `--wait` +to block until it finishes. `jobs get` checks one task, `jobs list` pages +through them, and `jobs cancel` stops a pending one. + +`cancel` is destructive and asks first. Pass `--yes` to skip the prompt; +without a terminal and without `--yes` it refuses with exit 2. +""" + +DEFAULT_WAIT_TIMEOUT = 3600.0 + +_JOB_COLUMNS = ("task_id", "status", "submitted_at", "finished_at") + +_LIST_COLUMNS = ("task_id", "status", "submitted_at") + + +def _read_prompt(raw: Optional[str]) -> Optional[str]: + return read_text_arg(raw, option_name="--prompt") + + +def _parse_media(raw: Optional[str]) -> Optional[List[Dict[str, Any]]]: + """``--media``: a JSON array of ``{"first_frame": ...}``-style objects.""" + value = parse_json_arg(raw, option_name="--media") + if value is None: + return None + if not isinstance(value, list): + raise usage_error( + f"--media: expected a JSON array, got {type(value).__name__}", + ) + for index, item in enumerate(value): + if not isinstance(item, dict): + raise usage_error( + f"--media: entry {index} is not a JSON object " + f"(got {type(item).__name__})", + ) + return list(value) + + +def _require_an_input( + prompt: Optional[str], + image_url: Optional[str], + media: Optional[List[Dict[str, Any]]], +) -> None: + if not (prompt or image_url or media): + raise usage_error( + "nothing to generate from: pass --prompt, --image-url or --media", + ) + + +def _render_job(renderer, job: Any) -> None: + """A finished job shows its URL; anything else shows its state.""" + if not renderer.table_mode: + renderer.print_object(job) + return + url = job.video_url or job.result_url + if url: + renderer.echo(url) + return + renderer.print_object(job, columns=_JOB_COLUMNS) + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("generate") + def generate( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Video model, e.g. wan2.2-t2v-plus or wan2.7-i2v.", + ), + prompt: Optional[str] = typer.Option( + None, + "-p", + "--prompt", + help="What to generate. Accepts @file and stdin (-).", + ), + image_url: Optional[str] = typer.Option( + None, + "--image-url", + help="First frame: URL, local path, or oss:// key.", + ), + media: Optional[str] = typer.Option( + None, + "--media", + help='Assets as a JSON array, e.g. \'[{"first_frame": "a.png"}]\'. ' + "Required by the media-protocol models. Accepts @file.", + ), + duration: Optional[int] = typer.Option( + None, + "--duration", + help="Clip length in seconds.", + ), + resolution: Optional[str] = typer.Option(None, "--resolution"), + ratio: Optional[str] = typer.Option( + None, + "--ratio", + help="Aspect ratio, e.g. 16:9.", + ), + size: Optional[str] = typer.Option(None, "--size"), + function: Optional[str] = typer.Option(None, "--function"), + seed: Optional[int] = typer.Option(None, "--seed"), + prompt_extend: Optional[bool] = typer.Option( + None, + "--prompt-extend/--no-prompt-extend", + help="Let the model rewrite the prompt.", + ), + watermark: Optional[bool] = typer.Option( + None, + "--watermark/--no-watermark", + ), + extra_input: Optional[str] = typer.Option( + None, + "--extra-input", + help="Extra input fields as a JSON object. Accepts @file.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to wait for the task before giving up (seconds).", + ), + submit_timeout: Optional[float] = typer.Option( + None, + "--submit-timeout", + help="Timeout for the submit request only.", + ), + ) -> None: + """Generate a video and wait for the result.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + text = _read_prompt(prompt) + assets = _parse_media(media) + _require_an_input(text, image_url, assets) + job = obj.client.videos.generate( + model=model, + prompt=text, + image_url=image_url, + media=assets, + extra_input=parse_json_object_arg( + extra_input, + option_name="--extra-input", + ), + function=function, + seed=seed, + size=size, + duration=duration, + resolution=resolution, + ratio=ratio, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + timeout=timeout, + submit_timeout=submit_timeout, + ) + _render_job(renderer, job) + + # ---- jobs subgroup ---- # + jobs_app = typer.Typer(help=_JOBS_HELP, no_args_is_help=True) + + @jobs_app.command("create") + def jobs_create( # pylint: disable=too-many-arguments,too-many-locals + ctx: typer.Context, + model: str = typer.Option( + ..., + "-m", + "--model", + help="Video model, e.g. wan2.2-t2v-plus or wan2.7-i2v.", + ), + prompt: Optional[str] = typer.Option( + None, + "-p", + "--prompt", + help="What to generate. Accepts @file and stdin (-).", + ), + image_url: Optional[str] = typer.Option(None, "--image-url"), + media: Optional[str] = typer.Option( + None, + "--media", + help="Assets as a JSON array. Accepts @file.", + ), + duration: Optional[int] = typer.Option(None, "--duration"), + resolution: Optional[str] = typer.Option(None, "--resolution"), + ratio: Optional[str] = typer.Option(None, "--ratio"), + size: Optional[str] = typer.Option(None, "--size"), + function: Optional[str] = typer.Option(None, "--function"), + seed: Optional[int] = typer.Option(None, "--seed"), + prompt_extend: Optional[bool] = typer.Option( + None, + "--prompt-extend/--no-prompt-extend", + ), + watermark: Optional[bool] = typer.Option( + None, + "--watermark/--no-watermark", + ), + extra_input: Optional[str] = typer.Option( + None, + "--extra-input", + help="Extra input fields as a JSON object. Accepts @file.", + ), + parameters: Optional[str] = typer.Option( + None, + "--parameters", + help="Extra parameters as a JSON object. Accepts @file.", + ), + wait: bool = typer.Option( + False, + "--wait", + help="Block until the task reaches a terminal status.", + ), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="With --wait, how long to block before giving up (seconds).", + ), + ) -> None: + """Submit a video task without waiting for it.""" + obj = ctx.obj + renderer = obj.renderer + if wait: + validate_wait_timeout(timeout) + text = _read_prompt(prompt) + assets = _parse_media(media) + _require_an_input(text, image_url, assets) + job = obj.client.videos.jobs.create( + model=model, + prompt=text, + image_url=image_url, + media=assets, + extra_input=parse_json_object_arg( + extra_input, + option_name="--extra-input", + ), + function=function, + seed=seed, + size=size, + duration=duration, + resolution=resolution, + ratio=ratio, + prompt_extend=prompt_extend, + watermark=watermark, + parameters=parse_json_object_arg( + parameters, + option_name="--parameters", + ), + ) + if wait: + job = wait_job( + obj.client, + job, + timeout=timeout, + renderer=renderer, + label="video task", + waiter=obj.client.videos.jobs, + ) + _render_job(renderer, job) + elif renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @jobs_app.command("get") + def jobs_get( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Video task id."), + ) -> None: + """Check the current state of a video task.""" + obj = ctx.obj + renderer = obj.renderer + job = obj.client.videos.jobs.fetch(task_id) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(job) + + @jobs_app.command("wait") + def jobs_wait( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Video task id."), + timeout: float = typer.Option( + DEFAULT_WAIT_TIMEOUT, + "--timeout", + help="How long to block before giving up (seconds).", + ), + ) -> None: + """Block until a video task reaches a terminal state.""" + obj = ctx.obj + renderer = obj.renderer + validate_wait_timeout(timeout) + job = wait_job( + obj.client, + task_id, + timeout=timeout, + renderer=renderer, + label="video task", + waiter=obj.client.videos.jobs, + ) + _render_job(renderer, job) + + @jobs_app.command("list") + def jobs_list( + ctx: typer.Context, + page: int = typer.Option(1, "--page", help="1-based page number."), + size: int = typer.Option(10, "--size", help="Tasks per page."), + status: Optional[str] = typer.Option(None, "--status"), + model_name: Optional[str] = typer.Option( + None, + "--model", + help="Filter by model name.", + ), + api_key_id: Optional[str] = typer.Option(None, "--api-key-id"), + region: Optional[str] = typer.Option(None, "--region"), + start_time: Optional[str] = typer.Option(None, "--start-time"), + end_time: Optional[str] = typer.Option(None, "--end-time"), + ) -> None: + """List video tasks.""" + obj = ctx.obj + renderer = obj.renderer + result = obj.client.videos.jobs.list( + page_no=page, + page_size=size, + status=status, + model_name=model_name, + api_key_id=api_key_id, + region=region, + start_time=start_time, + end_time=end_time, + ) + renderer.print_rows( + result.tasks, + columns=_LIST_COLUMNS, + envelope=result, + ) + if renderer.table_mode and result.total: + pages = max(1, -(-result.total // max(1, result.page_size))) + renderer.note( + f"page {result.page_no}/{pages}, total {result.total}", + ) + + @jobs_app.command("cancel") + def jobs_cancel( + ctx: typer.Context, + task_id: str = typer.Argument(..., help="Video task id."), + yes: bool = typer.Option( + False, + "-y", + "--yes", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Cancel a pending video task.""" + confirm_or_die(f"cancel video task {task_id}?", yes=yes) + obj = ctx.obj + obj.renderer.print_object(obj.client.videos.jobs.cancel(task_id)) + + app.add_typer(jobs_app, name="jobs") + return app diff --git a/dashscope/cli/config_store.py b/dashscope/cli/config_store.py new file mode 100644 index 00000000..12990623 --- /dev/null +++ b/dashscope/cli/config_store.py @@ -0,0 +1,601 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Read/write access to ``~/.dashscope/config.toml``. + +The SDK already *reads* this file (``dashscope._config``) but nothing in +the repo ever wrote it -- ``save_api_key`` writes a separate +``~/.dashscope/api_key``. ``dashscope config set`` is that writer. + +Two constraints shaped the implementation: + +* **No new dependency.** ``requirements.txt`` carries a TOML *reader* + (``tomllib``/``tomli``) but no writer. Pulling ``tomlkit`` in would add + a runtime dependency to every dashscope install for one subcommand. +* **The file is the user's.** It may hold comments and keys this CLI has + never heard of. A regenerate-from-dict writer would destroy both, so + this is a line editor: it replaces one line in place or appends one, + and leaves every other byte alone. Because a line editor can still be + wrong about what a line means, every candidate edit is re-parsed and + the key read back before it is allowed to land. + +CLI-only defaults live in a ``[cli]`` table. The SDK's reader filters to +its own ``_FILE_KEYS`` allowlist, so it drops that table silently -- the +two readers share one file with no SDK change and no risk of the CLI's +keys leaking into connection config. +""" + +from __future__ import annotations + +import json +import os +import stat +import sys +import tempfile +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Tuple + +from dashscope.cli.errors import usage_error + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - exercised only on 3.9/3.10 + import tomli as tomllib # type: ignore[no-redef] + +CLI_SECTION = "cli" +API_KEY = "api_key" + +# CLI-owned defaults, stored under [cli]. Deliberately disjoint from the +# SDK's _FILE_KEYS so neither reader picks up the other's keys. +CLI_KEYS = frozenset({"output", "default_model"}) + + +def _sdk_keys() -> frozenset: + """Keys the SDK itself reads from config.toml. + + Taken from the SDK rather than restated here, so adding a config key + to ``dashscope._config`` automatically makes it settable. + """ + from dashscope._config import ( + _FILE_KEYS, + ) # pylint: disable=import-outside-toplevel + + return frozenset(_FILE_KEYS) + + +def config_path() -> Path: + """The active config file (honours ``DASHSCOPE_CONFIG``).""" + from dashscope._config import ( # pylint: disable=import-outside-toplevel + default_config_path, + ) + + return default_config_path() + + +def load_raw(path: Optional[Path] = None) -> Dict[str, Any]: + """Whole config file as a dict; missing file reads as empty.""" + target = path or config_path() + if not target.is_file(): + return {} + try: + with target.open("rb") as handle: + data = tomllib.load(handle) + except tomllib.TOMLDecodeError as exc: + raise usage_error(f"invalid TOML in {target}: {exc}") from exc + except OSError as exc: + raise usage_error(f"cannot read {target}: {exc}") from exc + return data if isinstance(data, dict) else {} + + +def load_cli_section(path: Optional[Path] = None) -> Dict[str, Any]: + section = load_raw(path).get(CLI_SECTION) + return section if isinstance(section, dict) else {} + + +def known_keys() -> Tuple[str, ...]: + """Every key ``config set`` accepts, in a stable display order.""" + return tuple(sorted(_sdk_keys())) + tuple(sorted(CLI_KEYS)) + + +def bare_key(key: str) -> str: + """The key's own name inside its table (``cli.output`` → ``output``). + + Also the validation point: an unknown key or section raises here, so + every command that names a config key gets the same error. + """ + return _section_for(key)[1] + + +def _section_for(key: str) -> Tuple[str, str]: + """Split a key into ``(section, bare_key)``. + + ``cli.output`` and ``output`` both address the [cli] table; SDK keys + are always top level. + """ + if "." in key: + section, _, bare = key.partition(".") + section = section.strip() + bare = bare.strip() + if not section or not bare: + raise usage_error(f"invalid config key {key!r}") + if section != CLI_SECTION: + raise usage_error( + f"unknown config section [{section}] in {key!r}", + ) + else: + bare = key.strip() + section = CLI_SECTION if bare in CLI_KEYS else "" + if section == "" and bare not in _sdk_keys(): + raise usage_error( + f"unknown config key {key!r}; known keys: {', '.join(known_keys())}", + ) + if section == CLI_SECTION and bare not in CLI_KEYS: + raise usage_error( + f"unknown [cli] key {bare!r}; known keys: " + f"{', '.join(sorted(CLI_KEYS))}", + ) + return section, bare + + +def parse_toml_value(raw: str) -> Any: + """Coerce a command-line string into a typed TOML value. + + Parsing ``v = `` with the real TOML grammar gets ints, floats, + bools, arrays and quoted strings exactly right for free. An unquoted + bare word is not valid TOML, and that is the common case + (``config set workspace ws-123``), so it falls back to a string. + """ + text = raw.strip() + try: + return tomllib.loads(f"v = {text}")["v"] + except tomllib.TOMLDecodeError: + return raw + + +def render_toml_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return repr(value) + if isinstance(value, str): + # JSON's string escaping is a subset of TOML basic strings, so + # this round-trips quotes, backslashes and newlines correctly. + return json.dumps(value, ensure_ascii=False) + return json.dumps(value, ensure_ascii=False) + + +def _enum_choices() -> Dict[str, Tuple[str, Tuple[str, ...]]]: + """``key -> (label, allowed values)`` for the keys with a fixed set. + + Taken from the SDK and the renderer rather than restated here, so it + cannot drift from what they actually accept. + """ + from dashscope._config import ( # pylint: disable=import-outside-toplevel + VALID_PROTOCOLS, + VALID_REGIONS, + ) + from dashscope.cli.output import ( # pylint: disable=import-outside-toplevel + OUTPUT_FORMATS, + ) + + return { + "protocol": ("protocol", tuple(VALID_PROTOCOLS)), + "region": ("region", tuple(VALID_REGIONS)), + "output": ("output format", tuple(OUTPUT_FORMATS)), + } + + +# Seconds-valued keys: a float that has to be strictly positive. +_POSITIVE_SECONDS = ("timeout", "http_keepalive_expiry") +# Count-valued keys: a non-negative integer. +_NON_NEGATIVE_INTS = ( + "max_retries", + "http_max_keepalive_connections", +) +# A pool with no connections cannot issue a request, and `Settings` +# rejects it -- writing 0 here would leave a config file that makes every +# later invocation fail at resolve time. +_POSITIVE_INTS = ("http_max_connections",) + + +def _validate_number( + key: str, + value: Any, + *, + integer: bool, + positive: bool, +) -> Any: + try: + number: Any = int(value) if integer else float(value) + except (TypeError, ValueError) as exc: + kind = "an integer" if integer else "a number" + raise usage_error(f"{key} must be {kind}, got {value!r}") from exc + if positive: + if number <= 0: + raise usage_error(f"{key} must be positive, got {number}") + elif number < 0: + raise usage_error(f"{key} must be >= 0, got {number}") + return number + + +def _validate(key: str, value: Any) -> Any: + """Reject values the SDK would refuse at resolve time.""" + enums = _enum_choices() + if key in enums: + label, allowed = enums[key] + if value not in allowed: + raise usage_error( + f"invalid {label} {value!r}, expected one of " + f"{', '.join(allowed)}", + ) + return value + if key in _POSITIVE_SECONDS: + return _validate_number(key, value, integer=False, positive=True) + if key in _NON_NEGATIVE_INTS: + return _validate_number(key, value, integer=True, positive=False) + if key in _POSITIVE_INTS: + return _validate_number(key, value, integer=True, positive=True) + if not isinstance(value, str): + raise usage_error(f"{key} must be a string, got {value!r}") + if not value.strip(): + raise usage_error(f"{key} must not be empty") + return value + + +_TRIPLE_QUOTES = ('"""', "'''") + + +def _scan(line: str, quote: Optional[str]) -> Tuple[str, Optional[str]]: + """Strip *line*'s comment; return the rest and the still-open delimiter.""" + out: List[str] = [] + index = 0 + while index < len(line): + character = line[index] + if quote is None: + if character == "#": + break + triple = line[index : index + 3] # noqa: E203 + if triple in _TRIPLE_QUOTES: + quote = triple + out.append(triple) + index += 3 + continue + if character in "\"'": + quote = character + out.append(character) + index += 1 + elif character == "\\": + out.append(line[index : index + 2]) # noqa: E203 + index += 2 + elif line.startswith(quote, index): + out.append(quote) + index += len(quote) + quote = None + else: + out.append(character) + index += 1 + if quote not in _TRIPLE_QUOTES: + # A one-line string cannot stay open across a newline; what looks + # like one is a syntax error for the parser to report, not state + # to carry into the next line. + quote = None + return "".join(out), quote + + +def _code_lines(lines: List[str]) -> Iterator[Tuple[int, str]]: + """``(index, line)`` for the lines that carry TOML syntax. + + Comments come back stripped, so ``[cli] # mine`` still reads as a + table header. Lines inside a multi-line string are dropped: their + ``[table]`` and ``key = value`` shaped text is data, and editing it + would corrupt the string. + """ + quote: Optional[str] = None + for index, line in enumerate(lines): + continuation = quote in _TRIPLE_QUOTES + text, quote = _scan(line, quote) + if not continuation: + yield index, text + + +def _header_name(code: str) -> Optional[str]: + """The table name if *code* is a ``[table]`` header, else None.""" + stripped = code.strip() + if not (stripped.startswith("[") and stripped.endswith("]")): + return None + return stripped[1:-1].strip().strip("\"'") + + +def _find_key_line( + lines: List[str], + section: str, + key: str, +) -> Optional[int]: + """Index of ``key =`` inside *section*, or None.""" + current = "" + for index, code in _code_lines(lines): + header = _header_name(code) + if header is not None: + current = header + continue + if current != section or "=" not in code: + continue + name = code.split("=", 1)[0].strip().strip("\"'") + if name == key: + return index + return None + + +def _section_bounds( + lines: List[str], + section: str, +) -> Optional[Tuple[int, int]]: + """``(header_index, insert_index)`` for *section*, or None if absent.""" + header: Optional[int] = None + for index, code in _code_lines(lines): + name = _header_name(code) + if name is None: + continue + if header is None: + if name == section: + header = index + elif name != section: + return header, index + if header is None: + return None + return header, len(lines) + + +def _drop_trailing_blanks(lines: List[str]) -> None: + while lines and not lines[-1].strip(): + lines.pop() + + +def _append_toplevel_key(lines: List[str], rendered: str) -> None: + """Insert ``key = value`` ahead of the first table header. + + A top-level key must precede every ``[table]``, or TOML would read it + as belonging to the last table. + """ + first_table = next( + ( + index + for index, code in _code_lines(lines) + if _header_name(code) is not None + ), + None, + ) + if first_table is None: + _drop_trailing_blanks(lines) + lines.append(rendered) + return + lines[first_table:first_table] = [rendered, ""] + + +def _insert_key(lines: List[str], section: str, rendered: str) -> None: + """Append *rendered* to *section*, creating the section when absent.""" + bounds = _section_bounds(lines, section) + if bounds is None: + if not section: + _append_toplevel_key(lines, rendered) + return + _drop_trailing_blanks(lines) + if lines: + lines.append("") + lines.append(f"[{section}]") + lines.append(rendered) + return + + _, insert_at = bounds + # Step back over trailing blanks so the key lands inside its section + # rather than after the whitespace that closes it. + while insert_at > 0 and not lines[insert_at - 1].strip(): + insert_at -= 1 + lines.insert(insert_at, rendered) + + +def set_value( + key: str, + raw_value: str, + *, + path: Optional[Path] = None, +) -> Tuple[str, Any]: + """Persist one key. Returns ``(canonical_key, typed_value)``. + + The edit is one line: an existing assignment is replaced in place, + otherwise the key is appended to its section (creating ``[cli]`` when + needed). Comments and unrelated keys are never touched. + """ + section, bare = _section_for(key) + value = _validate(bare, parse_toml_value(raw_value)) + target = path or config_path() + + original: Optional[str] = None + if target.is_file(): + try: + original = target.read_text(encoding="utf-8") + except OSError as exc: + raise usage_error(f"cannot read {target}: {exc}") from exc + lines = (original or "").splitlines() + rendered = f"{bare} = {render_toml_value(value)}" + + existing = _find_key_line(lines, section, bare) + if existing is not None: + lines[existing] = rendered + else: + _insert_key(lines, section, rendered) + + content = _rendered(lines) + data = _parsed_or_refuse(target, content) + _check_landed(target, data, section, bare, value) + _write(target, content) + return bare, value + + +def _owner_only_mode(target: Path) -> Optional[int]: + """The mode to give a rewritten *target*, or None to keep 0600. + + Keeps whatever owner bits the user chose but always drops group and + other access: this file can hold an API key, so a rewrite must never + widen it. + """ + try: + mode = stat.S_IMODE(target.stat().st_mode) + except OSError: + return None + return mode & 0o600 + + +def _rendered(lines: List[str]) -> str: + content = "\n".join(lines) + if content and not content.endswith("\n"): + content += "\n" + return content + + +def _parsed_or_refuse(target: Path, content: str) -> Dict[str, Any]: + try: + data = tomllib.loads(content) + except tomllib.TOMLDecodeError as exc: + # Would leave the user with a file the SDK cannot parse. Bail out + # with the original still on disk rather than corrupting it. + raise usage_error( + f"refusing to write {target}: the result would be invalid TOML " + f"({exc}). If the file uses inline tables or dotted keys, edit " + f"it by hand.", + ) from exc + return data if isinstance(data, dict) else {} + + +def _check_landed( + target: Path, + data: Dict[str, Any], + section: str, + key: str, + value: Any, +) -> None: + """Refuse to claim success for an edit the SDK would not read back. + + The edit is placed by a line scanner, not by a TOML parser, so + syntax the scanner does not model -- a dotted key, an inline table -- + can leave the real value somewhere else. Parsing the result and + reading the key back turns every such case into an error instead of + a silent no-op. + """ + table: Any = data.get(section) if section else data + found = table.get(key) if isinstance(table, dict) else None + if found == value: + return + where = f"[{section}] {key}" if section else key + raise usage_error( + f"refusing to write {target}: {where} = {value!r} did not take " + f"effect; the file still resolves it to {found!r}. It probably " + f"uses dotted keys or inline tables, so edit it by hand.", + solution=( + f"Open {target} and set {where} directly, then re-run " + f"`dashscope config list` to confirm." + ), + ) + + +def _write(target: Path, content: str) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + mode = _owner_only_mode(target) + # mkstemp is O_EXCL and 0600 whatever the umask, so the secret is + # unreadable to anyone else from the first byte and a symlink planted + # at a guessable name cannot redirect the write. + descriptor, name = tempfile.mkstemp( + dir=target.parent, + prefix=target.name + ".", + suffix=".tmp", + ) + temporary = Path(name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + if mode is not None and hasattr(os, "fchmod"): + os.fchmod(handle.fileno(), mode) + os.replace(temporary, target) + except OSError as exc: + temporary.unlink(missing_ok=True) + raise usage_error(f"cannot write {target}: {exc}") from exc + + +def restrict_permissions(path: Optional[Path] = None) -> None: + """Chmod the config file to owner-only after a secret is written.""" + target = path or config_path() + try: + if target.is_file(): + os.chmod(target, 0o600) + except OSError: + pass + + +def get_value(key: str, *, path: Optional[Path] = None) -> Any: + section, bare = _section_for(key) + data = load_raw(path) + if section: + table = data.get(section) + return table.get(bare) if isinstance(table, dict) else None + return data.get(bare) + + +def mask(value: Any) -> str: + """Render a secret so a screenshot or CI log cannot leak it whole.""" + text = str(value) + if len(text) <= 6: + return "***" + return f"{text[:4]}{'*' * (len(text) - 6)}{text[-2:]}" + + +def _env_var_for(key: str) -> Optional[str]: + """The environment variable the SDK consults for *key*. + + Derived from ``dashscope._config``'s own ``__ENV`` constants so + this display cannot drift from what the SDK actually reads. + """ + from dashscope import _config # pylint: disable=import-outside-toplevel + + return getattr(_config, f"_{key.upper()}_ENV", None) + + +def effective_values(*, path: Optional[Path] = None) -> List[Dict[str, Any]]: + """Resolved view for ``config list``: value plus where it came from. + + Read-only replay of the SDK's precedence (explicit > env > file > + default). Command-line ``-c`` overrides are not included: they only + exist for the duration of one process, and this is a persisted-state + report. + """ + data = load_raw(path) + rows: List[Dict[str, Any]] = [] + for key in sorted(_sdk_keys()): + file_value = data.get(key) + env_name = _env_var_for(key) + env_value = os.environ.get(env_name) if env_name else None + if env_value not in (None, ""): + source, value = f"env {env_name}", env_value + elif file_value is not None: + source, value = "config file", file_value + else: + source, value = "default", None + rows.append( + { + "key": key, + "value": mask(value) if key == API_KEY and value else value, + "source": source, + }, + ) + cli_section = data.get(CLI_SECTION) + cli_section = cli_section if isinstance(cli_section, dict) else {} + for key in sorted(CLI_KEYS): + value = cli_section.get(key) + rows.append( + { + "key": f"{CLI_SECTION}.{key}", + "value": mask(value) if key == API_KEY and value else value, + "source": "config file" if value is not None else "default", + }, + ) + return rows diff --git a/dashscope/cli/confirm.py b/dashscope/cli/confirm.py new file mode 100644 index 00000000..9f12789d --- /dev/null +++ b/dashscope/cli/confirm.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Confirmation gate for destructive commands. + +``cancel``/``delete``/``scale`` act on resources the user paid to create +and cannot be undone, so they ask first. The non-interactive case is +deliberately an *error* rather than an implicit yes or an implicit no: +a cron job or CI step that forgot ``--yes`` should fail loudly with +"you need --yes here", not silently skip the work or silently do it. +Both spellings of refusal therefore land on exit code 2. +""" + +from __future__ import annotations + +import sys + +import typer + +from dashscope.cli.errors import usage_error + + +def stdin_is_tty() -> bool: + try: + return bool(sys.stdin.isatty()) + except (AttributeError, ValueError, OSError): + return False + + +def confirm_or_die(prompt: str, *, yes: bool) -> None: + """Return once the action is authorized, or raise a usage error. + + * ``--yes`` short-circuits: the caller already answered. + * No TTY and no ``--yes``: refuse, naming the flag that would work. + * Interactive refusal: same category and exit code, so a script sees + one consistent meaning for "did not happen". + """ + if yes: + return + if not stdin_is_tty(): + raise usage_error( + f"refusing to run a destructive command without a terminal; " + f"pass --yes to confirm: {prompt}", + ) + # err=True: click writes the prompt to stdout by default, which would + # land ahead of the JSON a `-o json` caller is about to parse. + if not typer.confirm(prompt, default=False, err=True): + raise usage_error("aborted by user") diff --git a/dashscope/cli/context.py b/dashscope/cli/context.py new file mode 100644 index 00000000..b02fd51b --- /dev/null +++ b/dashscope/cli/context.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Per-invocation CLI state, hung on ``ctx.obj`` by the root callback. + +click propagates ``ctx.obj`` down the command tree, so a command only +has to declare ``ctx: typer.Context`` and then reach for +``ctx.obj.client`` / ``ctx.obj.renderer``. Both are built lazily: a +command that errors out on its own arguments never constructs an SDK +client, and ``--help`` never reads the config file. + +Precedence is the one the design doc asks for -- command line beats +environment beats config file -- and it is obtained for free rather than +reimplemented: everything the user spelled on the command line is handed +to ``Client(...)`` as an explicit keyword, which is the SDK's +highest-priority tier. The CLI never reads ``DASHSCOPE_*`` itself. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Optional, Tuple + +from dashscope.cli import config_store, sdk +from dashscope.cli.errors import usage_error +from dashscope.cli.output import TABLE, Renderer, resolve_no_color + +# Global flags that map straight onto Client(...) keywords. +_FLAG_TO_SDK = { + "api_key": "api_key", + "protocol": "protocol", + "base_url": "base_url", + "workspace": "workspace", + "region": "region", + "timeout": "timeout", +} + + +@dataclass(frozen=True) +class CliDefaults: + """CLI-owned defaults, read from the ``[cli]`` table of config.toml. + + The SDK's reader filters config keys through its own allowlist, which + does not include these, so the two readers share one file without + either seeing the other's keys. + """ + + default_model: Optional[str] = None + output: Optional[str] = None + + +@dataclass(frozen=True) +class GlobalOptions: + """Everything the root callback collected, before any resolution. + + Kept as a plain value object so tests can construct a context without + running a Typer app. + """ + + api_key: Optional[str] = None + protocol: Optional[str] = None + base_url: Optional[str] = None + workspace: Optional[str] = None + region: Optional[str] = None + timeout: Optional[float] = None + output: Optional[str] = None + # Raw ``-c KEY=VALUE`` strings, unparsed: resolution needs to know + # whether a key belongs to the SDK or to the CLI, and that check + # produces the error message. + overrides: Tuple[str, ...] = () + no_color: bool = False + verbose: bool = False + + +@dataclass +class CliContext: + """Lazily-resolved view of one invocation.""" + + options: GlobalOptions = field(default_factory=GlobalOptions) + + def __post_init__(self) -> None: + self.verbose = self.options.verbose + self._defaults: Optional[CliDefaults] = None + self._cli_overrides: Optional[Dict[str, Any]] = None + self._sdk_overrides: Optional[Dict[str, Any]] = None + self._client: Any = None + self._renderer: Optional[Renderer] = None + + # -- configuration --------------------------------------------------- + + def _resolve_overrides(self) -> None: + """Split ``-c KEY=VALUE`` into SDK settings and CLI defaults. + + Runs once, on first access to either side, so an invalid key is + reported before any network work. + """ + sdk_keys = config_store._sdk_keys() # pylint: disable=protected-access + sdk_kw: Dict[str, Any] = {} + cli_kw: Dict[str, Any] = {} + for raw in self.options.overrides: + key, sep, value = raw.partition("=") + key = key.strip() + if not sep: + raise usage_error( + f"-c expects KEY=VALUE, got {raw!r}", + ) + if not key: + raise usage_error(f"-c expects KEY=VALUE, got {raw!r}") + if key in sdk_keys: + sdk_kw[key] = config_store.parse_toml_value(value) + elif key in config_store.CLI_KEYS: + cli_kw[key] = config_store.parse_toml_value(value) + else: + known = sorted(sdk_keys | config_store.CLI_KEYS) + raise usage_error( + f"unknown configuration key {key!r}; " + f"known keys: {', '.join(known)}", + ) + # Validate and coerce through the same path `config set` uses, so + # `-c protocol=bogus` and `config set protocol bogus` fail alike, + # and `-c timeout=30` becomes a number rather than "30". + for key, value in sdk_kw.items(): + sdk_kw[ + key + ] = config_store._validate( # pylint: disable=protected-access + key, + value, + ) + for key, value in cli_kw.items(): + cli_kw[ + key + ] = config_store._validate( # pylint: disable=protected-access + key, + value, + ) + self._sdk_overrides = sdk_kw + self._cli_overrides = cli_kw + + def _cli_overrides_map(self) -> Dict[str, Any]: + if self._cli_overrides is None: + self._resolve_overrides() + return self._cli_overrides or {} + + @property + def defaults(self) -> CliDefaults: + """``[cli]`` values from the config file, overridden by ``-c``.""" + if self._defaults is None: + stored = config_store.load_cli_section() + merged = dict(stored) + merged.update(self._cli_overrides_map()) + self._defaults = CliDefaults( + default_model=merged.get("default_model"), + output=merged.get("output"), + ) + return self._defaults + + def sdk_overrides(self) -> Dict[str, Any]: + """Explicit keywords for ``Client(...)``. + + A dedicated flag wins over ``-c`` for the same setting: the flag + is the more specific spelling, and letting ``-c`` win would make + ``dashscope -k sk-a -c api_key=sk-b`` silently drop ``sk-a``. + """ + if self._sdk_overrides is None: + self._resolve_overrides() + kwargs = dict(self._sdk_overrides or {}) + for name, sdk_name in _FLAG_TO_SDK.items(): + value = getattr(self.options, name) + if value is not None: + kwargs[sdk_name] = value + return kwargs + + def resolve_model(self, model: Optional[str]) -> str: + """``-m/--model``, else the configured ``default_model``. + + Lives here rather than in a command module so every + generate-style command reports a missing model identically. + """ + resolved = model or self.defaults.default_model + if not resolved: + raise usage_error( + "no model given: pass -m/--model, or set a default with " + "`dashscope config set default_model qwen-plus`", + ) + return resolved + + # -- derived objects ------------------------------------------------- + + @property + def output_format(self) -> str: + """``--output`` flag, else ``-c output=``, else ``[cli]``, else table.""" + return self.options.output or self.defaults.output or TABLE + + @property + def renderer(self) -> Renderer: + if self._renderer is None: + self._renderer = Renderer( + self.output_format, + no_color=resolve_no_color(self.options.no_color), + verbose=self.options.verbose, + ) + return self._renderer + + @property + def client(self) -> Any: + """The SDK client, built on first use. + + Reached through the :mod:`dashscope.cli.sdk` module attribute + rather than a direct import so tests have one seam to patch. + """ + if self._client is None: + self._client = sdk.build_client(self.sdk_overrides()) + return self._client + + def close(self) -> None: + """Release the client, if one was ever built. + + Called by the error handler too, so a failing command does not + leak the httpx connection pool. + """ + client, self._client = self._client, None + if client is not None: + close = getattr(client, "close", None) + if callable(close): + close() diff --git a/dashscope/cli/errors.py b/dashscope/cli/errors.py new file mode 100644 index 00000000..e8210e34 --- /dev/null +++ b/dashscope/cli/errors.py @@ -0,0 +1,520 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Exit-code tiers and error rendering for the dashscope CLI. + +Every failure leaves the process with a code a script can branch on +without parsing stderr, and a one-line ``Error [category]: message`` on +stderr whose category is isomorphic to that code: + +==== ========== ========================================== +code category meaning +==== ========== ========================================== +0 -- success +1 server service-side failure, overload, or timeout +1 ratelimit throttled (retryable, but not the caller's bug) +1 network the request never completed +2 usage the command line was wrong (matches click) +3 auth bad credentials or insufficient permission +4 notfound the named resource does not exist +==== ========== ========================================== + +Tier 2 deliberately matches the exit code click/Typer already use for +their own usage errors, so ``--help``-level mistakes and CLI-detected +parameter mistakes are indistinguishable to a caller -- which is the +point: both mean "fix your command line". + +The server's own ``message`` is passed through verbatim. Re-wording it +would hide the detail a support ticket needs, so the CLI adds a category +label and nothing else. + +Which category an exception lands in is *derived* from +``dashscope/_error_registry.py``, not hand-listed here. The catalog is +the standard: every ``PUBLIC_ERRORS.error_code`` names exactly one class +in ``dashscope.errors`` and carries that class's ``status_code``, so +``_STATUS_CATEGORY`` below is the CLI's one and only projection of the +catalog onto these tiers -- a row per status the catalog declares. +Regenerating the catalog therefore re-points the table with no edit in +this file; a *new status* is the one thing that needs a human decision, +and ``tests/cli/test_errors.py`` fails until someone makes it. + +SDK-side failures (``SdkError``) have no status in the catalog by +design, so they are categorised by their catalog ``code`` instead. The +catalog also ships ``solution`` text for them, which the CLI renders on +a separate dim line -- separate because it is the registry's wording, +not the service's, and splicing it into the message would blur which +words came from the gateway. +""" + +from __future__ import annotations + +import os +import sys +import traceback +from typing import Any, Dict, NamedTuple, NoReturn, Optional, Tuple + +import typer +from rich.console import Console +from rich.markup import escape + +EXIT_OK = 0 +EXIT_SERVER = 1 +EXIT_USAGE = 2 +EXIT_AUTH = 3 +EXIT_NOT_FOUND = 4 + +USAGE = "usage" +AUTH = "auth" +RATELIMIT = "ratelimit" +SERVER = "server" +NETWORK = "network" +NOTFOUND = "notfound" + +CATEGORIES = (USAGE, AUTH, RATELIMIT, SERVER, NETWORK, NOTFOUND) + +_CATEGORY_EXIT = { + USAGE: EXIT_USAGE, + AUTH: EXIT_AUTH, + RATELIMIT: EXIT_SERVER, + SERVER: EXIT_SERVER, + NETWORK: EXIT_SERVER, + NOTFOUND: EXIT_NOT_FOUND, +} + +# The CLI's projection of the catalog onto the tiers above: one row per +# status ``PUBLIC_ERRORS`` declares, keyed by status because that is the +# only thing the catalog states about a failure's nature. 401 and 403 +# both mean "who are you / what may you do", 400 and 413 both mean "the +# request itself is wrong", and the three 5xx all mean "not your bug". +_STATUS_CATEGORY: Dict[int, str] = { + 400: USAGE, + 401: AUTH, + 403: AUTH, + 404: NOTFOUND, + 413: USAGE, + 429: RATELIMIT, + 500: SERVER, + 503: SERVER, + 504: SERVER, +} + +# ``SDK_ERRORS`` entries carry no status, so a catalog ``code`` is the +# only handle. Deliberately short: an entry absent here is the SDK +# reporting something the CLI cannot attribute to the command line or to +# credentials, which is the ``server`` tier's meaning. ``sdk.IoError`` is +# the exception worth naming -- its own catalog solution reads "verify +# the file path exists and is readable", i.e. the caller's mistake. +_SDK_CODE_CATEGORY: Dict[str, str] = { + "sdk.InvalidApiKey": AUTH, + "sdk.ConnectionError": NETWORK, + "sdk.Timeout": NETWORK, + "sdk.IoError": USAGE, +} + + +class CliError(Exception): + """A failure the CLI itself detected, before or after the SDK ran. + + Carries the same category vocabulary as SDK errors so one handler + renders and codes both. ``solution`` is rendered on its own dim line, + exactly as the catalog's is. ``exit_code`` overrides the category's + default tier when a command needs to say something more specific. + """ + + def __init__( + self, + category: str, + message: str, + *, + solution: Optional[str] = None, + exit_code: Optional[int] = None, + ) -> None: + super().__init__(message) + if category not in CATEGORIES: + raise ValueError(f"unknown error category {category!r}") + self.category = category + self.message = message + self.solution = solution + self.exit_code = exit_code + + +def usage_error( + message: str, + *, + solution: Optional[str] = None, +) -> CliError: + """Shorthand for the most common CLI-side failure.""" + return CliError(USAGE, message, solution=solution) + + +def _sdk_error_module() -> Any: + """Import ``dashscope.errors`` on first use, then cache the module. + + Lazy because every command module imports this one and the tables are + only needed by the handler -- which runs once something has already + failed, so the import is never on a ``--help`` path. + """ + global _SDK_ERRORS # pylint: disable=global-statement + if _SDK_ERRORS is None: + # pylint: disable=import-outside-toplevel + from dashscope import errors as module + + _SDK_ERRORS = module + return _SDK_ERRORS + + +def _sdk_entries() -> Dict[str, Any]: + """The catalog's ``SDK_ERRORS`` rows, keyed by their ``name``.""" + global _SDK_ENTRY_CACHE # pylint: disable=global-statement + if _SDK_ENTRY_CACHE is None: + # pylint: disable=import-outside-toplevel + from dashscope import _error_registry as registry + + _SDK_ENTRY_CACHE = {e.name: e for e in registry.SDK_ERRORS} + return _SDK_ENTRY_CACHE + + +def _public_table() -> Tuple[Tuple[type, str], ...]: + """``(exception class, category)`` for every class the catalog names. + + Read out of ``PUBLIC_ERRORS`` rather than spelled out here, so the + table cannot drift from a regenerated catalog. Rows sharing an + ``error_code`` (400, 401 and 429 each have several) collapse to one + entry: the catalog already guarantees they name the same class, and + the same status gives them the same category. + """ + global _PUBLIC_TABLE # pylint: disable=global-statement + if _PUBLIC_TABLE is None: + # pylint: disable=import-outside-toplevel + from dashscope import _error_registry as registry + + sdk_errors = _sdk_error_module() + rows: list = [] + seen = set() + for entry in registry.PUBLIC_ERRORS: + cls = getattr(sdk_errors, entry.error_code) + if cls in seen: + continue + seen.add(cls) + # An undeclared status would mean the catalog grew a tier + # the CLI has no opinion about yet; ``server`` is the honest + # "not your bug" default, and the coverage test makes sure + # this branch is never actually taken. + rows.append((cls, _STATUS_CATEGORY.get(entry.status_code, SERVER))) + # Base last: every row above subclasses it, so it only catches a + # status error the catalog left unclaimed. + rows.append((sdk_errors.APIStatusError, SERVER)) + _PUBLIC_TABLE = tuple(rows) + return _PUBLIC_TABLE + + +def _unique_public_messages() -> Dict[str, str]: + """``error_code`` -> catalog text, only where one row claims it. + + ``BadRequestError`` / ``AuthenticationError`` / ``RateLimitError`` + each back several catalog rows with different wording, so naming the + class does not identify the entry and no text is offered -- the same + rule ``dashscope.errors._catalog_message`` applies. + """ + global _PUBLIC_MESSAGES # pylint: disable=global-statement + if _PUBLIC_MESSAGES is None: + # pylint: disable=import-outside-toplevel + from collections import Counter + + from dashscope import _error_registry as registry + + counts = Counter(e.error_code for e in registry.PUBLIC_ERRORS) + _PUBLIC_MESSAGES = { + e.error_code: e.error_msg + for e in registry.PUBLIC_ERRORS + if counts[e.error_code] == 1 + } + return _PUBLIC_MESSAGES + + +_SDK_ERRORS: Any = None +_SDK_ENTRY_CACHE: Optional[Dict[str, Any]] = None +_PUBLIC_TABLE: Optional[Tuple[Tuple[type, str], ...]] = None +_PUBLIC_MESSAGES: Optional[Dict[str, str]] = None + + +class Diagnosis(NamedTuple): + """Everything the handler needs to render and code one failure. + + A ``NamedTuple`` so the positional contract stays: ``classify(exc)[0]`` + is still the category and ``[1]`` still the message, with the + catalog-sourced extras trailing. The catalog's ``allow_retry`` is + deliberately not carried: the category already encodes it + (``usage``/``auth``/``notfound`` mean "do not retry", + ``ratelimit``/``network``/``server`` mean "maybe later"), and a + second source of the same fact is a second thing to keep in sync. + """ + + category: str + message: str + solution: Optional[str] = None + request_id: Optional[str] = None + + +def _message(exc: BaseException) -> str: + """The exception's own text, undecorated. + + ``DashScopeError.__str__`` appends ``| code=... | status_code=...``, + which reads well in a traceback and is noise on a one-line CLI error; + ``.message`` is the service's wording and nothing else. ``str()`` is + only the fallback for exceptions outside the SDK family. + """ + message = getattr(exc, "message", None) + return str(exc) if message is None else message + + +def _catalog_message(exc: BaseException) -> str: + """Registry text for a gateway failure that arrived with no message. + + Only a last resort -- the service's wording always wins. Placeholders + stay verbatim for the same reason they do in ``dashscope.errors``: + nothing here knows the ``{resource}`` / ``{limit}`` values, and + stripping them would hide the field the catalog declares. + """ + return _unique_public_messages().get(type(exc).__name__, "") + + +def classify(exc: BaseException) -> Diagnosis: + """Return the :class:`Diagnosis` for *exc*. + + The message is the SDK/server text verbatim; nothing is re-worded. + """ + if isinstance(exc, CliError): + return Diagnosis(exc.category, exc.message, solution=exc.solution) + + sdk_errors = _sdk_error_module() + + # isinstance walk, not a dict lookup: ContentFilterError subclasses + # BadRequestError, and the catalog names only the parent -- which is + # exactly what makes the refinement legal, and why the subclass has + # to inherit the parent's tier rather than be listed on its own. + for klass, category in _public_table(): + if isinstance(exc, klass): + return Diagnosis( + category, + _message(exc) or _catalog_message(exc), + request_id=_request_id(exc), + ) + + if isinstance(exc, sdk_errors.SdkError): + code = getattr(exc, "code", None) or "" + entry = _sdk_entries().get(code) + return Diagnosis( + _SDK_CODE_CATEGORY.get(code, SERVER), + _message(exc) or (entry.message if entry is not None else ""), + solution=entry.solution if entry is not None else None, + request_id=_request_id(exc), + ) + + if isinstance(exc, sdk_errors.DashScopeError): + return Diagnosis( + SERVER, + _message(exc) or _catalog_message(exc), + request_id=_request_id(exc), + ) + + # resolve_config rejects an invalid protocol/region/timeout with a + # plain ValueError: that is a bad command line, not a broken service. + if isinstance(exc, ValueError): + return Diagnosis(USAGE, str(exc)) + return Diagnosis(SERVER, str(exc) or exc.__class__.__name__) + + +def exit_code_for(category: str) -> int: + return _CATEGORY_EXIT[category] + + +def _request_id(exc: BaseException) -> Optional[str]: + return getattr(exc, "request_id", None) + + +def print_error( + diagnosis: Diagnosis, + *, + console: Console, + verbose: bool = False, + exc: Optional[BaseException] = None, +) -> None: + """Render ``Error [category]: message`` on stderr. + + Markup is escaped because a server message may contain brackets, + which Rich would otherwise read as a style tag and drop from the + output. Wrapping and auto-highlighting are off for the same reason: + the message must arrive byte-for-byte, not re-flowed to the terminal + width or restyled around the numbers in it. Everything goes to stderr + so ``--output json`` on stdout stays machine-readable. + + The catalog's ``solution`` gets its own dim line rather than being + appended to the message: it is the registry's wording, not the + service's, and merging them would make it impossible to tell a + support ticket which half came from the gateway. + """ + console.print( + f"[red]Error[/red] [yellow]\\[{escape(diagnosis.category)}][/yellow]: " + f"{escape(diagnosis.message)}", + highlight=False, + soft_wrap=True, + ) + if diagnosis.request_id: + console.print( + f"[dim]request_id: {escape(str(diagnosis.request_id))}[/dim]", + highlight=False, + soft_wrap=True, + ) + if diagnosis.solution: + console.print( + f"[dim]solution: {escape(diagnosis.solution)}[/dim]", + highlight=False, + soft_wrap=True, + ) + if verbose and exc is not None: + console.print( + "[dim]" + + escape( + "".join( + traceback.format_exception( + type(exc), + exc, + exc.__traceback__, + ), + ), + ) + + "[/dim]", + highlight=False, + soft_wrap=True, + ) + + +def _verbose_from(ctx: typer.Context) -> bool: + """Best-effort verbose lookup that works before ``ctx.obj`` exists. + + The root callback runs inside the group's own ``invoke``, so an + invalid global option raises before ``ctx.obj`` was ever assigned. + """ + obj = getattr(ctx, "obj", None) + if obj is not None: + return bool(getattr(obj, "verbose", False)) + return bool(ctx.params.get("verbose")) + + +def _error_console() -> Console: + return Console( + stderr=True, + no_color=bool(os.environ.get("NO_COLOR")), + ) + + +def fail(exc: BaseException, *, verbose: bool = False) -> NoReturn: + """Render *exc* exactly as ``ErrorHandlingGroup`` would, then exit. + + For a failure the entry point detects *before* it builds the app -- + there the group's ``invoke`` override never runs, so without this the + only way to keep one error format would be to build the app anyway. + """ + diagnosis = classify(exc) + print_error( + diagnosis, + console=_error_console(), + verbose=verbose, + exc=exc, + ) + override = getattr(exc, "exit_code", None) + sys.exit( + override + if override is not None + else exit_code_for(diagnosis.category), + ) + + +try: + # ``typer.core.TyperGroup`` is an internal API and the dependency pin + # is only ``typer>=0.9``, so fall back to a plain click group rather + # than failing to import the whole CLI if a future typer moves it. + # Losing the subclass only costs Typer's rich-help formatting; the + # error mapping below is what actually matters and survives either way. + from typer.core import TyperGroup as _GroupBase +except ImportError: # pragma: no cover + from click import Group as _GroupBase + + +class ErrorHandlingGroup(_GroupBase): + """Root command group that turns every exception into a tier + line. + + A Typer callback cannot do this: click does not route command-body + exceptions back through it. Handling only in ``main()`` is not enough + either, because tests (and embedders) invoke the app object directly + and would see a traceback. Overriding the group's ``invoke`` covers + both paths at every nesting depth. + """ + + def invoke(self, ctx: typer.Context): + try: + return super().invoke(ctx) + except (SystemExit, typer.Exit, KeyboardInterrupt): + raise + except _CLICK_PASSTHROUGH: + # click already rendered a usage message and chose exit 2. + raise + except BaseException as exc: # pylint: disable=broad-except + _close_context(ctx) + fail(exc, verbose=_verbose_from(ctx)) + + +def _close_context(ctx: typer.Context) -> None: + """Release the SDK client a failing command may have opened. + + Best-effort and silent: the caller is already reporting a failure, + and a second error from teardown would mask the first. + """ + obj = getattr(ctx, "obj", None) + if obj is None: + return + close = getattr(obj, "close", None) + if callable(close): + try: + close() + except Exception: # pylint: disable=broad-except + pass + + +def _click_passthrough() -> Tuple[type[BaseException], ...]: + """Exception types click renders and exit-codes on its own. + + The handler re-raises these untouched so a command-line mistake keeps + click's usage message and exit 2 instead of being relabelled. The + classes are gathered from *both* click namespaces in play: the real + ``click`` and, on ``typer>=0.27``, the private ``typer._click`` + compatibility shim. The shim's exception classes are not subclasses of + the real click ones, so listing only ``click`` lets a missing argument + (``typer._click.exceptions.MissingParameter``) slip past the + ``except`` below into the generic branch and come out mislabelled + ``server``. + """ + import click # pylint: disable=import-outside-toplevel + + classes = [click.exceptions.Exit, click.UsageError, click.Abort] + # typer.Exit subclasses click.exceptions.Exit on modern typer; keep + # both spellings so an older typer still passes usage errors through. + typer_exit = getattr(typer, "Exit", None) + if typer_exit is not None and typer_exit not in classes: + classes.append(typer_exit) + # The same three names, from typer's vendored click shim when present. + # The shim ships UsageError but not Exit/Abort, so getattr skips those. + try: + # pylint: disable=import-outside-toplevel + from typer import _click as _typer_click + except ImportError: # older typer raises the real click classes only + _typer_click = None + shim = getattr(_typer_click, "exceptions", None) + for name in ("Exit", "UsageError", "Abort"): + klass = getattr(shim, name, None) + if klass is not None and klass not in classes: + classes.append(klass) + return tuple(classes) + + +_CLICK_PASSTHROUGH: Tuple[type[BaseException], ...] = _click_passthrough() diff --git a/dashscope/cli/input.py b/dashscope/cli/input.py new file mode 100644 index 00000000..7dbe07c8 --- /dev/null +++ b/dashscope/cli/input.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Command-line input parsing for the dashscope CLI. + +Three spellings are accepted wherever a payload option takes text: + +``value`` literal text +``@path`` read the file (``~`` expanded) +``-`` read stdin to EOF + +The ``@`` form is what keeps a long ``--messages`` payload or a tool +schema out of shell quoting hell, and ``-`` is what makes the CLI +composable:: + + cat messages.jsonl | dashscope chat create -m qwen-plus --messages - + +Message payloads additionally accept JSONL, because that is how people +already store conversation dumps: one message object per line, with no +surrounding array. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +from dashscope.cli.errors import CliError, usage_error + +STDIN_TOKEN = "-" + + +def stdin_is_piped() -> bool: + """True when stdin is a pipe or file rather than a terminal.""" + try: + return not sys.stdin.isatty() + except (AttributeError, ValueError, OSError): + return False + + +def read_stdin() -> str: + try: + return sys.stdin.read() + except OSError as exc: + raise usage_error(f"could not read stdin: {exc}") from exc + + +def read_text_arg(raw: Optional[str], *, option_name: str) -> Optional[str]: + """Resolve a payload option to its text. + + ``None`` passes through so an unset option stays unset; the caller + decides what a missing value means. + """ + if raw is None: + return None + if raw == STDIN_TOKEN: + text = read_stdin() + if not text.strip(): + raise usage_error( + f"{option_name}: no input received on stdin " + f"(pass a value, an @file, or pipe something in)", + ) + return text + if raw.startswith("@"): + path_text = raw[1:].strip() + if not path_text: + raise usage_error(f"{option_name}: '@' must be followed by a path") + path = Path(path_text).expanduser() + if not path.is_file(): + raise usage_error(f"{option_name}: file not found: {path}") + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise usage_error( + f"{option_name}: cannot read {path}: {exc}", + ) from exc + return raw + + +def parse_json_arg(raw: Optional[str], *, option_name: str) -> Any: + """``read_text_arg`` plus a JSON decode, with an actionable error.""" + text = read_text_arg(raw, option_name=option_name) + if text is None: + return None + try: + return json.loads(text) + except json.JSONDecodeError as exc: + source = ( + "stdin" + if raw == STDIN_TOKEN + else ( + raw[1:] if raw and raw.startswith("@") else "the given value" + ) + ) + raise usage_error( + f"{option_name}: invalid JSON in {source}: {exc.msg} " + f"(line {exc.lineno} column {exc.colno})", + ) from exc + + +def parse_json_object_arg( + raw: Optional[str], + *, + option_name: str, +) -> Optional[Dict[str, Any]]: + """Like :func:`parse_json_arg` but requires a JSON object.""" + value = parse_json_arg(raw, option_name=option_name) + if value is None: + return None + if not isinstance(value, dict): + raise usage_error( + f"{option_name}: expected a JSON object, got " + f"{type(value).__name__}", + ) + return value + + +def parse_tools_arg(raw: Optional[str]) -> Optional[List[Dict[str, Any]]]: + """``--tools``: a JSON array of tool definitions. + + Shared by the text and multimodal chat commands, which take the same + schema and should reject a non-array with the same words. + """ + tools = parse_json_arg(raw, option_name="--tools") + if tools is None: + return None + if not isinstance(tools, list): + raise usage_error( + f"--tools: expected a JSON array, got {type(tools).__name__}", + ) + return tools + + +def _looks_like_json(text: str) -> bool: + stripped = text.lstrip() + return stripped.startswith(("{", "[")) + + +def parse_messages_arg(raw: Optional[str]) -> List[Dict[str, Any]]: + """Parse a conversation payload into a list of message objects. + + Accepts a JSON array of messages, a JSON object carrying a + ``messages`` key (the OpenAI request shape people paste from docs), + or JSONL with one message object per line. + """ + text = read_text_arg(raw, option_name="--messages") + if text is None: + return [] + return messages_from_text(text) + + +def messages_from_text(text: str) -> List[Dict[str, Any]]: + """The payload half of :func:`parse_messages_arg`, for text in hand. + + The JSON and JSONL shapes are told apart by trying the stricter one + first: JSONL also opens with a brace, so the first character cannot + decide it, and a whole-text parse of JSONL fails with "Extra data". + """ + stripped = text.strip() + if not stripped: + raise usage_error("--messages: payload is empty") + + if _looks_like_json(stripped): + try: + value = json.loads(stripped) + except json.JSONDecodeError: + pass + else: + return _coerce_messages(value) + + messages: List[Dict[str, Any]] = [] + for lineno, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + try: + value = json.loads(line) + except json.JSONDecodeError as exc: + raise usage_error( + f"--messages: invalid JSON on line {lineno}: {exc.msg}", + ) from exc + messages.extend(_coerce_messages(value)) + if not messages: + raise usage_error("--messages: no messages found in payload") + return messages + + +def _coerce_messages(value: Any) -> List[Dict[str, Any]]: + if isinstance(value, dict): + inner = value.get("messages") + if isinstance(inner, list): + return _require_message_objects(inner) + return _require_message_objects([value]) + if isinstance(value, list): + return _require_message_objects(value) + raise usage_error( + "--messages: expected a message object, a list of them, or " + f'{{"messages": [...]}}, got {type(value).__name__}', + ) + + +def _require_message_objects(items: List[Any]) -> List[Dict[str, Any]]: + for index, item in enumerate(items): + if not isinstance(item, dict): + raise usage_error( + f"--messages: entry {index} is not a JSON object " + f"(got {type(item).__name__})", + ) + return list(items) + + +def messages_from_prompt(prompt: str, system: Optional[str] = None): + """Build a message list from ``--prompt`` (+ optional ``--system``).""" + messages: List[Dict[str, Any]] = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + return messages + + +def read_prompt_or_messages( + *, + prompt: Optional[str], + messages: Optional[str], + system: Optional[str] = None, + allow_stdin_prompt: bool = True, + explicit_stdin: bool = False, +) -> Any: + """Resolve the conversation for a generate-style command. + + Precedence: ``--messages`` > ``--prompt`` > stdin. Stdin is treated + as a message payload when it parses as JSON and as prompt text + otherwise, so both ``cat msgs.json | dashscope chat create -`` and + ``echo hi | dashscope chat create -m qwen-plus`` work. Explicit stdin + also reads from a terminal; implicit stdin requires a pipe. + """ + if messages is not None: + return parse_messages_arg(messages) + if prompt is not None: + text = read_text_arg(prompt, option_name="--prompt") + return messages_from_prompt(text or "", system) + if allow_stdin_prompt and (explicit_stdin or stdin_is_piped()): + text = read_stdin().strip() + if text: + if _looks_like_json(text): + try: + return messages_from_text(text) + except CliError: + # Looks like JSON but is not a message payload: most + # often a prompt that happens to start with a brace, + # so fall back to text rather than failing. + pass + return messages_from_prompt(text, system) + raise usage_error( + "no input given: pass --prompt, --messages, or pipe text on stdin", + ) diff --git a/dashscope/cli/main.py b/dashscope/cli/main.py new file mode 100644 index 00000000..8039e630 --- /dev/null +++ b/dashscope/cli/main.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Route ``dashscope`` to the SDK-shaped v2 tree or the bundled agent. + +The 1.x CLI has its own ``dashscope1`` entry point. Recognizing its command +names here only produces a migration hint; no legacy implementation is +imported or executed. +""" + +from __future__ import annotations + +import os +import sys +from typing import List, Optional, Sequence + +from rich.console import Console + +from dashscope.cli.errors import fail, usage_error + +NEW = "new" +AGENT = "agent" +LEGACY = "legacy" + +LEGACY_GROUPS = frozenset( + { + "generation", + "application", + "image-synthesis", + "image-generation", + "video-synthesis", + "multimodal-conversation", + "multimodal-embedding", + "transcription", + "speech-synthesis", + }, +) + +LEGACY_DOTTED_COMMANDS = frozenset( + { + "fine_tunes.call", + "fine_tunes.get", + "fine_tunes.list", + "fine_tunes.stream", + "fine_tunes.cancel", + "fine_tunes.delete", + "generation.call", + "files.upload", + "files.get", + "files.list", + "files.delete", + "deployments.call", + "deployments.get", + "deployments.list", + "deployments.scale", + "deployments.delete", + "oss.upload", + }, +) + +LEGACY_TOP_LEVEL_COMMANDS = frozenset({"login", "whoami", "logout"}) + +# Shared group names whose 1.x verbs have a different SDK-shaped v2 path. +LEGACY_SUBCOMMANDS = { + "ft": frozenset({"call"}), + "fine-tunes": frozenset({"call"}), + "fine_tunes": frozenset( + {"call", "create", "get", "list", "stream", "cancel", "delete"}, + ), + "deployments": frozenset({"call"}), + "tokenization": frozenset({"create"}), + "rl": frozenset( + { + "run", + "get", + "list", + "cancel", + "logs", + "register-functions", + "register_functions", + "test-functions", + "test_functions", + "upload-data", + "upload_data", + }, + ), +} +LEGACY_SUBCOMMANDS["agentic-rl"] = LEGACY_SUBCOMMANDS["rl"] + +# Names this entry point serves itself, pinned to the groups ``app.py`` +# actually registers by a test. +NEW_COMMANDS = frozenset( + { + "auth", + "chat", + "ft", + "fine-tunes", + "files", + "models", + "oss", + "images", + "videos", + "embeddings", + "multimodal", + "rerank", + "tokenization", + "apps", + "deployments", + "rl", + "agentic-rl", + "audio", + "tingwu", + "decision", + "agentstudio", + "config", + "version", + }, +) + +# The bundled agent's routing keyword, the flags that pick its interactive +# mode, and the help request it answers itself. +_AGENT_COMMAND = "expert" +_AGENT_MODE_FLAGS = ("--cli", "--tui") +_AGENT_HELP = ("-h", "--help") + +# Rich markup, so it is printed through a Console rather than click.echo. +_AGENT_USAGE = """[bold]DashScope SDK Expert[/bold] — the built-in AI assistant. + +[bold]Usage:[/bold] + dashscope interactive assistant (TUI by default) + dashscope expert the same, spelled explicitly + dashscope expert --cli interactive, plain REPL instead of the TUI + dashscope expert --tui interactive, force the TUI + dashscope expert "question" ask once and exit + +With no argument, piped stdin is read as a one-shot prompt. An unrecognized +command is reported as an error rather than treated as a question, so ask +one with [bold]dashscope expert "question"[/bold]. + +Requires [bold]pip install dashscope[acli][/bold]. +""" + +# Root options that consume the token after them. Without this the +# scanner would read the *value* as the command -- `dashscope -o json +# chat create` would look like an invocation of `json`. +# Mirrors app.py's callback; tests/cli/test_app.py pins the two together. +_VALUE_TAKING_OPTIONS = frozenset( + { + "-k", + "--api-key", + "--protocol", + "--base-url", + "--workspace", + "--region", + "--timeout", + "-o", + "--output", + "-c", + }, +) + +# The spellings that set the API key. Kept separate from the above because +# only these drive the bare-``dashscope`` decision, and because the agent +# wants the value itself rather than merely needing to step over it. +_VALUE_OPTIONS = ("-k", "--api-key") + + +def _command_index(argv: Sequence[str]) -> Optional[int]: + """Index of the first token that is not an option or an option's value.""" + index = 0 + while index < len(argv): + arg = argv[index] + if arg.startswith("-") and "=" in arg: + # Self-contained ``--api-key=sk-x`` form. + index += 1 + continue + if arg in _VALUE_TAKING_OPTIONS: + index += 2 + continue + if arg.startswith("-"): + # A boolean flag, or an option this router does not know; in + # the latter case click produces the usage error. + index += 1 + continue + return index + return None + + +def _without_api_key_options(argv: Sequence[str]) -> List[str]: + """*argv* minus every complete ``-k``/``--api-key`` pair. + + A dangling ``-k`` with no value is left in place: that is a + command-line mistake for the v2 parser to report, not an agent + invocation, and swallowing it here would open a chat instead. + """ + rest: List[str] = [] + index = 0 + while index < len(argv): + arg = argv[index] + if arg in _VALUE_OPTIONS and index + 1 < len(argv): + index += 2 + continue + if arg.startswith("--api-key="): + index += 1 + continue + rest.append(arg) + index += 1 + return rest + + +def _api_key_from(argv: Sequence[str]) -> Optional[str]: + """The key the invocation passed, if it passed one. + + The agent reads ``dashscope.api_key``. Nothing else in the v2 tree + consults that module global; commands resolve credentials through + ``ctx.obj``. + """ + index = 0 + while index < len(argv): + arg = argv[index] + if arg.startswith("--api-key="): + return arg.split("=", 1)[1] + if arg in _VALUE_OPTIONS and index + 1 < len(argv): + return argv[index + 1] + index += 1 + return None + + +def _classify_without_command(argv: Sequence[str]) -> str: + """Route an invocation that names no command.""" + if "-h" in argv or "--help" in argv: + # `dashscope --help` describes the CLI people will actually + # be writing against from here on. + return NEW + if any(arg in _AGENT_MODE_FLAGS for arg in argv): + return AGENT + if not _without_api_key_options(argv): + # A bare `dashscope` -- or one carrying nothing but its API key -- + # opens the agent interactively, as it always has. + return AGENT + # A stray or malformed option (``dashscope -k`` with no value) is + # a command-line mistake: let the v2 tree's click parser render + # the usage error and exit 2. + return NEW + + +def _legacy_command(argv: Sequence[str]) -> Optional[str]: + """The 1.x command path to reject, or ``None`` for a v2 invocation.""" + index = _command_index(argv) + if index is None: + return None + token = argv[index] + if token in LEGACY_TOP_LEVEL_COMMANDS: + return token + if token in LEGACY_GROUPS or token in LEGACY_DOTTED_COMMANDS: + return token + if index + 1 < len(argv): + subcommand = argv[index + 1] + if subcommand in LEGACY_SUBCOMMANDS.get(token, ()): + return f"{token} {subcommand}" + return None + + +def classify(argv: Sequence[str]) -> str: + """Route moved 1.x names to a hint and everything else to v2 or agent.""" + index = _command_index(argv) + if index is None: + return _classify_without_command(argv) + if argv[index] == _AGENT_COMMAND: + return AGENT + if _legacy_command(argv) is not None: + return LEGACY + return NEW + + +def _reject_legacy(argv: Sequence[str]) -> None: + command = _legacy_command(argv) + fail( + usage_error( + f"No such command {command!r}. CLI 1.x commands have moved to " + f"'dashscope1'. Run 'dashscope1 {command} ...' instead.", + ), + ) + + +def _run_agent(argv: Sequence[str]) -> None: + """Hand the invocation to the bundled agent, bypassing the 1.x tree.""" + tui = True + tokens: List[str] = [] + for arg in _without_api_key_options(argv): + if arg == "--cli": + tui = False + elif arg == "--tui": + tui = True + else: + tokens.append(arg) + if tokens and tokens[0] == _AGENT_COMMAND: + tokens = tokens[1:] + + if tokens and all(token in _AGENT_HELP for token in tokens): + Console().print(_AGENT_USAGE) + return + + api_key = _api_key_from(argv) + if api_key: + import dashscope # pylint: disable=import-outside-toplevel + + dashscope.api_key = api_key + + # Keep the agent and its optional dependencies off every other command. + # pylint: disable-next=import-outside-toplevel + from dashscope.acli.expert import _route_to_expert + + _route_to_expert(" ".join(tokens).strip() or None, tui=tui) + + +def main(args: Optional[Sequence[str]] = None) -> None: + """Entry point for the ``dashscope`` console script.""" + argv: List[str] = list(sys.argv[1:] if args is None else args) + + # Shell completion bypasses the agent and always uses the v2 command tree. + if os.environ.get("_DASHSCOPE_COMPLETE"): + from dashscope.cli.app import build_app + + build_app()(argv) + return + + target = classify(argv) + if target is LEGACY: + _reject_legacy(argv) + if target is AGENT: + _run_agent(argv) + return + + from dashscope.cli.app import build_app + + build_app()(argv) diff --git a/dashscope/cli/output.py b/dashscope/cli/output.py new file mode 100644 index 00000000..3bfbc29a --- /dev/null +++ b/dashscope/cli/output.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Output rendering for the dashscope CLI. + +One rule drives the whole module: **machine formats never pass through +Rich.** Rich wraps text at the terminal width, which silently corrupts +JSON for any consumer downstream -- a long ``--output json`` payload +would arrive with newlines inserted mid-string. So json/yaml/NDJSON go +out through ``click.echo`` untouched and only the human ``table`` format +gets Rich styling. + +That also means a piped ``dashscope ... --output json | jq`` needs no +unwrap workaround, and neither do the tests. +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Optional, Sequence + +import click +from rich.console import Console +from rich.table import Table + +JSON = "json" +YAML = "yaml" +TABLE = "table" +OUTPUT_FORMATS = (TABLE, JSON, YAML) + +# Transport metadata every v2 response carries. It is noise for a human +# and churn for a diff, so it is dropped unless --verbose asked for it. +_TRANSPORT_FIELDS = frozenset({"headers"}) + + +def is_machine_format(fmt: str) -> bool: + return fmt in (JSON, YAML) + + +def _strip_transport(value: Any) -> Any: + if isinstance(value, dict): + return { + key: _strip_transport(item) + for key, item in value.items() + if key not in _TRANSPORT_FIELDS + } + if isinstance(value, list): + return [_strip_transport(item) for item in value] + return value + + +def to_serializable(obj: Any, *, verbose: bool = False) -> Any: + """Flatten an SDK response into JSON-ready primitives. + + Accepts pydantic models (every v2 response), AgentStudio's typed + containers, plain containers, and scalars. ``model_dump(mode="json")`` + already converts datetimes and enums, so nothing downstream has to + know about SDK types. + """ + dump = getattr(obj, "model_dump", None) + if callable(dump): + value = dump(mode="json", exclude_none=True) + elif callable(getattr(obj, "to_dict", None)): + # AgentStudio ships its own lightweight containers instead of + # pydantic models. ``to_dict`` already recurses into nested + # containers and its enums are ``StrEnum``, so the result is + # JSON-ready as it stands. + value = obj.to_dict() + elif isinstance(obj, (str, int, float, bool)) or obj is None: + value = obj + elif isinstance(obj, dict): + value = { + key: to_serializable(item, verbose=verbose) + for key, item in obj.items() + } + elif isinstance(obj, (list, tuple)): + value = [to_serializable(item, verbose=verbose) for item in obj] + else: + value = str(obj) + return value if verbose else _strip_transport(value) + + +class Renderer: + """Formats one command's result according to ``--output``.""" + + def __init__( + self, + fmt: str = TABLE, + *, + no_color: Optional[bool] = None, + verbose: bool = False, + ) -> None: + if fmt not in OUTPUT_FORMATS: + raise ValueError( + f"invalid output format {fmt!r}, " + f"expected one of {OUTPUT_FORMATS}", + ) + self.fmt = fmt + self.verbose = verbose + if no_color is None: + no_color = bool(os.environ.get("NO_COLOR")) + self.no_color = no_color + self._out = Console(no_color=no_color) + self._err = Console(stderr=True, no_color=no_color) + + # -- raw channels ---------------------------------------------------- + + def echo(self, text: str = "") -> None: + """Plain stdout: no markup, no wrapping. Use for payload text.""" + click.echo(text) + + def write(self, text: str) -> None: + """Unbuffered stdout write for incremental streaming.""" + sys.stdout.write(text) + sys.stdout.flush() + + def note(self, text: str) -> None: + """Progress/diagnostic text on stderr, so stdout stays pipeable.""" + self._err.print(f"[dim]{text}[/dim]", highlight=False) + + def success(self, text: str) -> None: + if is_machine_format(self.fmt): + return + self._err.print(f"[green]\u2713[/green] {text}") + + def warn(self, text: str) -> None: + self._err.print(f"[yellow]{text}[/yellow]") + + @property + def table_mode(self) -> bool: + """True when human-facing Rich rendering is appropriate.""" + return self.fmt == TABLE + + @property + def err_console(self) -> Console: + """The stderr console, for callers that render progress themselves. + + Already carries the resolved ``no_color`` decision, so a spinner + or live status cannot leak colour into a redirected log. + """ + return self._err + + # -- structured channels --------------------------------------------- + + def print_json(self, obj: Any) -> None: + payload = to_serializable(obj, verbose=self.verbose) + click.echo(json.dumps(payload, ensure_ascii=False, indent=2)) + + def print_ndjson(self, obj: Any) -> None: + """One compact, valid JSON object per line (jq-consumable).""" + payload = to_serializable(obj, verbose=self.verbose) + click.echo( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + ) + + def print_yaml(self, obj: Any) -> None: + # Imported lazily: PyYAML is a declared dependency but --output + # json is the common path and should not pay for it. + import yaml # pylint: disable=import-outside-toplevel + + payload = to_serializable(obj, verbose=self.verbose) + click.echo( + yaml.safe_dump( + payload, + allow_unicode=True, + sort_keys=False, + ).rstrip(), + ) + + def print_object( + self, + obj: Any, + *, + columns: Optional[Sequence[str]] = None, + ): + """Render a single resource according to ``--output``.""" + if self.fmt == JSON: + self.print_json(obj) + elif self.fmt == YAML: + self.print_yaml(obj) + else: + self._print_fields(obj, columns=columns) + + def print_rows( + self, + rows: Sequence[Any], + *, + columns: Sequence[str], + envelope: Any = None, + ) -> None: + """Render a list endpoint. + + Machine formats get the whole envelope (pagination metadata + included) so a caller can page without a second request; the + human format gets a table of just the rows. + """ + if self.fmt == JSON: + self.print_json(envelope if envelope is not None else rows) + return + if self.fmt == YAML: + self.print_yaml(envelope if envelope is not None else rows) + return + self._print_table(rows, columns=columns) + + def print_stream_chunk(self, obj: Any) -> None: + """Emit one streaming frame in the active machine format. + + YAML streams as NDJSON too: a multi-document YAML stream is legal + but no consumer expects one, and the frames are independent + objects either way. + """ + self.print_ndjson(obj) + + # -- rich internals --------------------------------------------------- + + def _print_fields( + self, + obj: Any, + *, + columns: Optional[Sequence[str]] = None, + ) -> None: + data = to_serializable(obj, verbose=self.verbose) + if not isinstance(data, dict): + click.echo(data) + return + keys = list(columns) if columns else list(data) + table = Table(show_header=False, box=None, pad_edge=False) + table.add_column("field", style="cyan", no_wrap=True) + table.add_column("value", overflow="fold") + for key in keys: + if key not in data: + continue + table.add_row(key, _cell(data[key])) + self._out.print(table) + + def _print_table(self, rows: Sequence[Any], *, columns: Sequence[str]): + payload = [to_serializable(row, verbose=self.verbose) for row in rows] + table = Table(box=None, pad_edge=False) + for column in columns: + table.add_column(column, overflow="fold") + for row in payload: + if isinstance(row, dict): + table.add_row(*[_cell(row.get(column)) for column in columns]) + else: + table.add_row(_cell(row)) + self._out.print(table) + + +def render_completion(renderer: Renderer, result: Any) -> None: + """One chat completion: payload on stdout, diagnostics on stderr. + + Keeping reasoning and usage off stdout is what makes + ``dashscope chat create ... > answer.txt`` usable. + """ + if not renderer.table_mode: + renderer.print_object(result) + return + for choice in result.choices: + message = choice.message + if message.reasoning_content: + renderer.note(message.reasoning_content) + renderer.echo(message.content or "") + if result.usage is not None: + renderer.note(f"usage: {result.usage.model_dump(exclude_none=True)}") + + +def render_text_stream(renderer: Renderer, stream: Any) -> None: + """A chat-shaped stream (``text_stream`` plus whole-chunk iteration). + + Deliberately has no ``except``: a mid-stream failure must propagate to + the error handler and exit non-zero. The 1.x tree caught these, + printed a message, and returned -- which exits 0. + """ + if renderer.table_mode: + _write_stream_text(renderer, stream) + renderer.echo() + return + for chunk in stream: + renderer.print_stream_chunk(chunk) + + +def _write_stream_text(renderer: Renderer, stream: Any) -> None: + """Append the answer to the terminal, whatever the frame shape. + + A ``cumulative`` stream (``incremental_output=False``, or a protocol + the SDK merges client-side) yields the whole text so far on every + frame, so only the tail that has not been written yet may go out -- + appending snapshots verbatim repeats the answer. Read with a default + because not every chat-shaped stream carries the flag. + """ + if not getattr(stream, "cumulative", False): + for text in stream.text_stream: + renderer.write(text) + return + written = 0 + for text in stream.text_stream: + renderer.write(text[written:]) + written = max(written, len(text)) + + +def render_stream_event(renderer: Renderer, event: Any) -> None: + """One server-sent event, in whichever format is active. + + No ``except`` here or in the callers: ``stream_events`` raises on an + error frame (``raise_on_error`` is on by default), and that has to + reach the error handler. The 1.x tree caught it, printed, and returned + -- which exits 0. + """ + if not renderer.table_mode: + renderer.print_stream_chunk(event) + return + output = event.output + if isinstance(output, str): + renderer.echo(output) + elif output is None: + renderer.echo("") + else: + renderer.echo( + json.dumps(output, ensure_ascii=False, separators=(",", ":")), + ) + + +def _cell(value: Any) -> str: + if value is None: + return "-" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + return str(value) + + +def resolve_no_color(flag: bool) -> bool: + """``--no-color`` or a non-empty ``NO_COLOR`` environment variable.""" + return bool(flag) or bool(os.environ.get("NO_COLOR")) diff --git a/dashscope/cli/progress.py b/dashscope/cli/progress.py new file mode 100644 index 00000000..5fb11ab5 --- /dev/null +++ b/dashscope/cli/progress.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Waiting on long-running jobs without reimplementing the polling. + +Every job-shaped SDK resource (``fine_tunes``, ``rl.jobs``, +``images.jobs``, ``videos.jobs``, ``embeddings.jobs``, +``audio.asr.jobs``) waits the same way: exponential backoff, an enforced +timeout, and a raise on FAILED/CANCELED. The CLI's only addition is a +spinner, threaded in through ``wait``'s ``_sleep`` seam so the backoff +schedule stays the SDK's business. + +Two rules follow from that: + +* Progress is rendered only on a real terminal in the human format. + Under ``--output json`` the spinner would corrupt the machine-readable + stream, and under a pipe it would write ANSI escapes into a log file. +* A failed or timed-out job raises out of ``wait`` and is left alone + here. Catching it to print something friendlier is exactly how the 1.x + tree ended up exiting 0 after an error. + +A third applies to the commands that submit: ``--timeout`` is checked +with :func:`validate_wait_timeout` before the create call, so a bad +value costs an error message rather than a job nobody wanted. The +standalone ``wait`` commands check it before touching the client, so a +bad ``--timeout`` stays a usage error (exit 2) even with no API key +configured. +""" + +from __future__ import annotations + +import math +import sys +import time +from typing import Any, Callable, Optional + +from rich.status import Status + +from dashscope.cli.errors import usage_error +from dashscope.cli.output import Renderer + + +def _show_progress(renderer: Renderer) -> bool: + if not renderer.table_mode: + return False + try: + return bool(sys.stderr.isatty()) + except (AttributeError, ValueError, OSError): + return False + + +def validate_wait_timeout(timeout: Optional[float]) -> None: + """Reject a ``--timeout`` the waiter could never honour. + + Submit sites call this *before* creating a job, not only through + :func:`wait_job`: by the time a wait starts, the service has already + been asked for something the user is paying for. ``nan`` is the case + that matters most -- every comparison against it is false, so the + SDK's ``remaining <= 0`` deadline check never fires and the poll loop + spins forever instead of timing out. + """ + if timeout is None: + return + if not math.isfinite(timeout): + raise usage_error( + f"--timeout must be a finite number of seconds, got {timeout!r}", + ) + if timeout <= 0: + raise usage_error( + f"--timeout must be greater than 0 seconds, got {timeout!r}", + ) + + +def wait_job( + client: Any, + job: Any, + *, + timeout: Optional[float] = None, + renderer: Optional[Renderer] = None, + label: str = "fine-tune job", + waiter: Optional[Any] = None, +) -> Any: + """Block until *job* reaches a terminal status, then return it. + + *job* may be the object ``create`` returned or just its id. Raises + whatever ``wait`` raises -- the caller must not swallow it. + + *waiter* is the resource whose ``wait`` does the polling; it defaults + to ``client.fine_tunes``. Every job-shaped resource in the SDK + (``rl.jobs``, ``images.jobs``, ``videos.jobs``, ``audio.asr.jobs``) + exposes the same ``wait(job, timeout=, _sleep=)`` seam, so they pass + themselves here rather than each growing its own spinner. + """ + validate_wait_timeout(timeout) + if waiter is None: + waiter = client.fine_tunes + kwargs: dict = {} + if timeout is not None: + kwargs["timeout"] = timeout + + if renderer is None or not _show_progress(renderer): + return waiter.wait(job, **kwargs) + + job_id = getattr(job, "job_id", None) or job + text = f"waiting for {label} {job_id}" + started = time.monotonic() + with Status( + text, + console=renderer.err_console, + spinner="dots", + ) as status: + return waiter.wait( + job, + _sleep=_status_sleep(status, text, started), + **kwargs, + ) + + +def _status_sleep( + status: Status, + text: str, + started: float, +) -> Callable[[float], Any]: + """A ``time.sleep`` replacement that refreshes the spinner each poll. + + Rebuilt from *text* rather than from the current status, so repeated + polls do not stack suffixes on each other. + """ + + def sleep(seconds: float) -> None: + elapsed = int(time.monotonic() - started) + status.update(f"{text} ({elapsed}s elapsed)") + time.sleep(seconds) + + return sleep diff --git a/dashscope/cli/sdk.py b/dashscope/cli/sdk.py new file mode 100644 index 00000000..9f3f5b3e --- /dev/null +++ b/dashscope/cli/sdk.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The single place the CLI imports the v2 SDK's :class:`Client`. + +``import dashscope`` pulls in the 1.x resource tree and the pydantic +models -- hundreds of milliseconds that ``dashscope chat create --help`` +should not pay. Every command therefore reaches the SDK through +:func:`build_client`, which runs only when a command actually calls the +service, and which tests monkeypatch in one place. +""" + +from __future__ import annotations + +from typing import Any, Dict, FrozenSet, Mapping, Optional + +from dashscope.cli.errors import usage_error + +# Marks CLI traffic on the User-Agent so the service can tell a request +# came from the command line rather than from user SDK code. This is a +# different mechanism from 1.x's `set_sdk_client()`, which only sets the +# `x-dashscope-sdk-client` header. +USER_AGENT_SUFFIX = "dashscope-cli" + +_CLIENT_PARAMS: Optional[FrozenSet[str]] = None + + +def _client_params() -> FrozenSet[str]: + """The keyword names ``Client.__init__`` actually accepts. + + Read out of the signature rather than listed here, because the set of + ``config.toml`` keys is *not* the set of constructor arguments: the + connection-pool trio is resolved from the file and the environment + only. A hand-written whitelist would drift the day ``Client`` grows a + parameter, and the drift would surface as a TypeError at exit 1. + """ + global _CLIENT_PARAMS # pylint: disable=global-statement + if _CLIENT_PARAMS is None: + # pylint: disable=import-outside-toplevel + import inspect + + from dashscope import Client + + params = inspect.signature(Client.__init__).parameters + _CLIENT_PARAMS = frozenset(params) - {"self"} + return _CLIENT_PARAMS + + +def build_client(overrides: Mapping[str, Any]) -> Any: + """Construct a :class:`dashscope.Client` from resolved CLI settings. + + *overrides* carries only the settings the user actually specified; + the SDK's own resolution chain (explicit > env > config file) fills + the rest. + + A key ``Client(...)`` does not take is applied to + ``dashscope._config.settings`` instead, which is the tier + ``resolve_config`` reads just below an explicit argument -- so ``-c`` + still outranks the environment and the config file, and the setting + reaches the connection pool rather than blowing up the constructor. + """ + # pylint: disable=import-outside-toplevel + from dashscope import Client, _config + + accepted = _client_params() + kwargs: Dict[str, Any] = {} + for key, value in dict(overrides).items(): + if key in accepted: + kwargs[key] = value + elif hasattr(_config.settings, key): + # The setter validates, so a bad value raises here rather than + # being written into the pool. + setattr(_config.settings, key, value) + else: + raise usage_error( + f"configuration key {key!r} is neither a Client argument " + f"nor a runtime setting; it cannot be overridden with -c", + ) + return Client(user_agent_suffix=USER_AGENT_SUFFIX, **kwargs) diff --git a/dashscope/client.py b/dashscope/client.py index d000f3f9..b4bcaf35 100644 --- a/dashscope/client.py +++ b/dashscope/client.py @@ -36,7 +36,7 @@ SyncTransport, default_user_agent, ) -from dashscope.transport.ws import Connector +from dashscope.transport.ws import Connector, with_client_user_agent from dashscope.transport.ws_connection import WsConnection # Historical dashscope.client.* children resolve through the 1.x alias finder. @@ -95,6 +95,7 @@ def __init__( http_client: Optional[httpx.Client] = None, raise_on_error: bool = True, protocol_options: Optional[Dict[str, Any]] = None, + user_agent_suffix: Optional[str] = None, ) -> None: self._config = resolve_config( api_key=api_key, @@ -108,6 +109,7 @@ def __init__( ) self._raise_on_error = raise_on_error self._protocol_options = protocol_options + self._user_agent_suffix = user_agent_suffix self._protocol = get_protocol( self._config.protocol, protocol_options=protocol_options, @@ -116,7 +118,7 @@ def __init__( base_url=self._config.base_url, api_key=self._config.api_key, workspace=self._config.workspace, - user_agent=default_user_agent(), + user_agent=default_user_agent(user_agent_suffix), timeout=self._config.timeout, max_retries=self._config.max_retries, http_client=http_client, @@ -202,6 +204,11 @@ def transport(self) -> SyncTransport: def raise_on_error(self) -> bool: return self._raise_on_error + @property + def user_agent_suffix(self) -> Optional[str]: + """The surface tag appended to this client's User-Agent, if any.""" + return self._user_agent_suffix + def with_options( self, *, @@ -213,6 +220,7 @@ def with_options( timeout: Optional[float] = None, max_retries: Optional[int] = None, raise_on_error: Optional[bool] = None, + user_agent_suffix: Optional[str] = None, ) -> "Client": """Derive a client with the given options overridden. @@ -223,6 +231,12 @@ def with_options( own pool. """ config = self._config + # ``user_agent_suffix`` deliberately does not participate in the + # pool-sharing decision. The User-Agent is re-applied per request + # from the transport's own defaults (see ``_request_headers``), + # so a derived client sharing this one's httpx pool still sends + # its own agent on the wire. Adding it here would split pools for + # no behavioural gain. share_pool = ( api_key is None and base_url is None @@ -249,6 +263,11 @@ def with_options( else raise_on_error ), protocol_options=self._protocol_options, + user_agent_suffix=( + self._user_agent_suffix + if user_agent_suffix is None + else user_agent_suffix + ), ) def close(self) -> None: @@ -285,7 +304,9 @@ def ws_connection( api_key=config.api_key, workspace=config.workspace, region=config.region, - headers=headers, + # Websocket sessions bypass this client's HTTP transport, so the + # agent (and its surface suffix) is carried across explicitly. + headers=with_client_user_agent(self, headers), connector=connector, timeout=config.timeout if timeout is None else timeout, module=module, @@ -328,6 +349,7 @@ def __init__( http_client: Optional[httpx.AsyncClient] = None, raise_on_error: bool = True, protocol_options: Optional[Dict[str, Any]] = None, + user_agent_suffix: Optional[str] = None, ) -> None: self._config = resolve_config( api_key=api_key, @@ -341,6 +363,7 @@ def __init__( ) self._raise_on_error = raise_on_error self._protocol_options = protocol_options + self._user_agent_suffix = user_agent_suffix self._protocol = get_protocol( self._config.protocol, protocol_options=protocol_options, @@ -349,7 +372,7 @@ def __init__( base_url=self._config.base_url, api_key=self._config.api_key, workspace=self._config.workspace, - user_agent=default_user_agent(), + user_agent=default_user_agent(user_agent_suffix), timeout=self._config.timeout, max_retries=self._config.max_retries, http_client=http_client, @@ -431,6 +454,11 @@ def transport(self) -> AsyncTransport: def raise_on_error(self) -> bool: return self._raise_on_error + @property + def user_agent_suffix(self) -> Optional[str]: + """The surface tag appended to this client's User-Agent, if any.""" + return self._user_agent_suffix + def with_options( self, *, @@ -442,6 +470,7 @@ def with_options( timeout: Optional[float] = None, max_retries: Optional[int] = None, raise_on_error: Optional[bool] = None, + user_agent_suffix: Optional[str] = None, ) -> "AsyncClient": """Derive a client with the given options overridden. @@ -452,6 +481,8 @@ def with_options( own pool. """ config = self._config + # See Client.with_options: the User-Agent is applied per request, + # so the suffix must not force a separate pool. share_pool = ( api_key is None and base_url is None @@ -478,6 +509,11 @@ def with_options( else raise_on_error ), protocol_options=self._protocol_options, + user_agent_suffix=( + self._user_agent_suffix + if user_agent_suffix is None + else user_agent_suffix + ), ) async def aclose(self) -> None: diff --git a/dashscope/legacy_1x/_shim.py b/dashscope/legacy_1x/_shim.py index d43a1429..94624cd8 100644 --- a/dashscope/legacy_1x/_shim.py +++ b/dashscope/legacy_1x/_shim.py @@ -20,7 +20,6 @@ "app", "assistants", "audio", - "cli", "common", "embeddings", "io", diff --git a/dashscope/legacy_1x/cli/__init__.py b/dashscope/legacy_1x/cli/__init__.py index b72c1dde..a8f92504 100644 --- a/dashscope/legacy_1x/cli/__init__.py +++ b/dashscope/legacy_1x/cli/__init__.py @@ -7,7 +7,6 @@ """ import sys import warnings -from pathlib import Path from typing import Optional # Suppress urllib3 NotOpenSSLWarning on systems with LibreSSL @@ -125,16 +124,16 @@ _EXPERT_USAGE = """[bold]DashScope SDK Expert[/bold] — the built-in AI assistant. [bold]Usage:[/bold] - dashscope interactive assistant (TUI by default) - dashscope expert the same, spelled explicitly - dashscope expert --cli interactive, plain REPL instead of the TUI - dashscope expert --tui interactive, force the TUI - dashscope expert "question" ask once and exit + dashscope1 interactive assistant (TUI by default) + dashscope1 expert the same, spelled explicitly + dashscope1 expert --cli interactive, plain REPL instead of the TUI + dashscope1 expert --tui interactive, force the TUI + dashscope1 expert "question" ask once and exit With no argument, piped stdin is read as a one-shot prompt. SDK subcommands (generation, ft, files, models, ...) dispatch as usual; an unrecognized command is reported as an error rather than treated as a -question, so ask one with [bold]dashscope expert "question"[/bold]. +question, so ask one with [bold]dashscope1 expert "question"[/bold]. Requires [bold]pip install dashscope[acli][/bold].""" @@ -268,137 +267,10 @@ def _translate_help_shortcut(argv): return [arg if arg != "-h" else "--help" for arg in argv] -_MD_MARKER = "" -_PY_MARKER = "# managed-by: dashscope-expert" -_EXPERT_EXAMPLE = "dashscope-sdk-expert" - - -def _cleanup_legacy_expert_sync(): - """Remove files the deleted expert bundling synced into ~/.acli. - - Only files carrying the managed marker are deleted — without it the file - is user-owned and left untouched. Needed so stale global copies don't - shadow the workspace ./.acli/ configs installed via example download. - """ - try: - from dashscope.acli.config import CONFIG_DIR - except ImportError: - return - md_targets = [] - for subdir in ("skills", "references"): - target_dir = CONFIG_DIR / subdir - if target_dir.is_dir(): - md_targets.extend(target_dir.glob("*.md")) - for path in md_targets: - try: - if _MD_MARKER in path.read_text(encoding="utf-8"): - path.unlink() - except OSError: - pass - build = CONFIG_DIR / "build_sdk_index.py" - try: - if build.is_file() and _PY_MARKER in build.read_text(encoding="utf-8"): - build.unlink() - except OSError: - pass - - -def _maybe_offer_example_download(): - """First interactive run without ./.acli: offer the SDK-expert example. - - Accepting merges the example into ./.acli/ via the same code path as - `example download` (copied from the packaged install dir when present); - declining writes a marker into ./.acli/ so we never ask again here. - """ - if not sys.stdin.isatty() or (Path.cwd() / ".acli").is_dir(): - return - try: - from dashscope.acli.cli.examples import _handle_example_command - from dashscope.acli.cli.handlers_key import _GUIDE_DOC, _doc_locale - except ImportError: - return - err_console.print( - "[cyan]First run:[/cyan] download the dashscope-sdk-expert " - "example config into ./.acli/\n" - "(SDK Q&A expert persona + skill templates + SDK knowledge " - "index; pure config, editable anytime).", - ) - err_console.print( - f"[dim]Guide: {_GUIDE_DOC.format(_doc_locale())}[/dim]", - ) - try: - answer = input("Download the example config? [Y/n] ").strip().lower() - except (EOFError, KeyboardInterrupt): - err_console.print() - return - if answer in ("n", "no"): - try: - marker = Path.cwd() / ".acli" / ".dashscope-example-declined" - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text("declined\n", encoding="utf-8") - except OSError: - pass - err_console.print( - "[dim]Skipped. To customize later, run: " - "python -m dashscope.acli example download " - "dashscope-sdk-expert[/dim]", - ) - return - _handle_example_command(["download", _EXPERT_EXAMPLE]) - - def _route_to_expert(command, tui=None): - """Run the vendored acli agent (bare ``dashscope`` or ``dashscope expert``). - - ``command`` is a one-shot prompt, or None for the interactive REPL. - """ - try: - from dashscope.acli.cli.handlers_key import _GUIDE_DOC, _doc_locale - from dashscope.acli.ui.embedded import run - except ImportError as exception: - err_console.print( - "[yellow]Tip:[/yellow] Install dashscope[acli] to enable " - "the agent " - f"({exception}).", - ) - return + from dashscope.acli.expert import _route_to_expert as route - if command is None and not sys.stdin.isatty(): - # The interactive REPL needs a real terminal; prompt_toolkit crashes - # on non-tty stdin (e.g. kqueue rejects /dev/null with EINVAL). - # Treat piped text as a one-shot prompt instead. - try: - piped = sys.stdin.read().strip() - except OSError: - piped = "" - if not piped: - err_console.print( - "[red]Error:[/red] Interactive mode requires a terminal. " - 'Pass a prompt instead, e.g. dashscope expert "your question".', - ) - sys.exit(2) - command = piped - - _cleanup_legacy_expert_sync() - if command is None: - _maybe_offer_example_download() - - try: - run( - app_name="DashScope SDK Expert", - prompt_symbol="dashscope> ", - api_key=dashscope.api_key or None, - command=command, - tui=tui, - module="expert", - guide_url=_GUIDE_DOC.format(_doc_locale()), - ) - except SystemExit: - pass - except Exception as exception: # pylint: disable=broad-except - err_console.print( - f"[yellow]Tip:[/yellow] Agent failed to start ({exception}).", - ) + route(command, tui=tui) # --------------------------------------------------------------------------- @@ -406,8 +278,8 @@ def _route_to_expert(command, tui=None): # --------------------------------------------------------------------------- app = typer.Typer( - name="dashscope", - help="DashScope command line tools.", + name="dashscope1", + help="DashScope 1.x command line tools.", add_completion=True, no_args_is_help=True, rich_markup_mode="rich", @@ -517,8 +389,8 @@ def _first_non_option(argv): return None -def main(): - """Entry point for the ``dashscope`` console script.""" +def main(prog_name: Optional[str] = None): + """Entry point for the ``dashscope1`` console script.""" # Translate legacy command format first so legacy --api_key can be treated # as the global --api-key option. argv = _translate_legacy_args(sys.argv) @@ -573,7 +445,10 @@ def main(): if "--help" in argv or "-h" in argv: argv = _translate_help_shortcut(argv) sys.argv = argv - app() + if prog_name is None: + app() + else: + app(prog_name=prog_name) return # Anything that is not bare `dashscope` and not `dashscope expert ...` @@ -585,7 +460,10 @@ def main(): sys.argv = argv try: - app() + if prog_name is None: + app() + else: + app(prog_name=prog_name) except AuthenticationError as exception: err_console.print(f"[red]Error:[/red] {exception}") sys.exit(1) diff --git a/dashscope/resources/_oss_upload.py b/dashscope/resources/_oss_upload.py index e1e92d88..a463730e 100644 --- a/dashscope/resources/_oss_upload.py +++ b/dashscope/resources/_oss_upload.py @@ -116,16 +116,19 @@ def _post_to_oss( file_name: str, file_path: str, transport: Optional[httpx.BaseTransport] = None, + user_agent: Optional[str] = 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``. + for tests to inject an ``httpx.MockTransport``. ``user_agent`` is the + client's configured identity (including any CLI suffix); absent, the SDK + default is used. """ headers = { "Accept": "application/json", - "User-Agent": default_user_agent(), + "User-Agent": user_agent or default_user_agent(), } with httpx.Client( timeout=_OSS_POST_TIMEOUT, @@ -179,6 +182,7 @@ def upload_local_file( form, os.path.basename(file_path), file_path, + user_agent=getattr(client.transport, "user_agent", None), ) if not response.is_success: logger.error( diff --git a/dashscope/resources/audio/asr.py b/dashscope/resources/audio/asr.py index 81f00822..6565c3dc 100644 --- a/dashscope/resources/audio/asr.py +++ b/dashscope/resources/audio/asr.py @@ -39,7 +39,12 @@ Job, Jobs, ) -from dashscope.transport.ws import Connector, EVENT_GENERATED, WsSession +from dashscope.transport.ws import ( + Connector, + EVENT_GENERATED, + WsSession, + with_client_user_agent, +) from dashscope.transport.ws_connection import WsConnection, WsTask __all__ = [ @@ -372,7 +377,7 @@ def __init__( "connector": connector, "module": MODULE, "timeout": config.timeout if timeout is None else timeout, - "headers": headers, + "headers": with_client_user_agent(client, headers), } self._session: Union[WsSession, WsTask, None] = None self.last_response: Optional[Dict[str, Any]] = None diff --git a/dashscope/resources/audio/realtime.py b/dashscope/resources/audio/realtime.py index 576a3987..133e30a0 100644 --- a/dashscope/resources/audio/realtime.py +++ b/dashscope/resources/audio/realtime.py @@ -105,8 +105,20 @@ def __init__( if not any(key == "model" for key, _ in query): query.append(("model", model)) url = urlunsplit(parts._replace(query=urlencode(query))) + # Inherit the client's agent (and any surface suffix on it) so the + # realtime socket reports the same identity as its HTTP calls; the + # dedup below still lets an explicit per-call header win. The + # lookup is defensive for the same reason as + # ``transport.ws.with_client_user_agent``. all_headers = { - "user-agent": default_user_agent(), + "user-agent": ( + getattr( + getattr(client, "transport", None), + "user_agent", + None, + ) + or default_user_agent() + ), **sdk_headers(MODULE), } key = api_key or config.api_key diff --git a/dashscope/resources/audio/tts.py b/dashscope/resources/audio/tts.py index 383585f0..0fb26e23 100644 --- a/dashscope/resources/audio/tts.py +++ b/dashscope/resources/audio/tts.py @@ -29,7 +29,11 @@ ) from dashscope.resources.audio.http_tts import HttpTts -from dashscope.transport.ws import Connector, WsSession +from dashscope.transport.ws import ( + Connector, + WsSession, + with_client_user_agent, +) from dashscope.transport.ws_connection import WsConnection, WsTask __all__ = [ @@ -125,7 +129,7 @@ def __init__( "api_key": api_key or config.api_key, "workspace": workspace or config.workspace, "region": config.region, - "headers": dict(headers or {}), + "headers": with_client_user_agent(client, headers), "connector": connector, "module": MODULE, "timeout": config.timeout if timeout is None else timeout, diff --git a/dashscope/resources/chat/completions.py b/dashscope/resources/chat/completions.py index ac195e79..6c15873b 100644 --- a/dashscope/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -245,8 +245,19 @@ def tool_call_stream(self) -> Iterator[List[ToolCall]]: if delta is not None and delta.tool_calls: yield delta.tool_calls + @property + def cumulative(self) -> bool: + """True when each frame carries the whole text so far, not a delta. + + Callers that append frames to a terminal have to check this: with + ``incremental_output=False`` (or a protocol merged client-side for + 1.x compatibility) writing every frame in sequence repeats the + answer -- "Hi" then "Hi!" comes out as "HiHi!". + """ + return self._merge or self._server_cumulative + def get_final_message(self) -> ChatCompletion: - if self._merge or self._server_cumulative: + if self.cumulative: final = _final_from_cumulative(list(self)) else: final = _aggregate(self) @@ -314,9 +325,18 @@ async def _tool_call_stream(self): if delta is not None and delta.tool_calls: yield delta.tool_calls + @property + def cumulative(self) -> bool: + """True when each frame carries the whole text so far, not a delta. + + Async twin of :attr:`ChatStream.cumulative`; see that docstring for + why a caller appending frames to a terminal has to check it. + """ + return self._merge or self._server_cumulative + async def get_final_message(self) -> ChatCompletion: chunks = [chunk async for chunk in self] - if self._merge or self._server_cumulative: + if self.cumulative: final = _final_from_cumulative(chunks) else: final = _aggregate(chunks) diff --git a/dashscope/resources/tingwu.py b/dashscope/resources/tingwu.py index 763354dc..c8f8bff3 100644 --- a/dashscope/resources/tingwu.py +++ b/dashscope/resources/tingwu.py @@ -22,7 +22,11 @@ error_from_business_code, response_json, ) -from dashscope.transport.ws import Connector, WsSession +from dashscope.transport.ws import ( + Connector, + WsSession, + with_client_user_agent, +) from dashscope.transport.ws_connection import WsConnection, WsTask from dashscope.types.common import ( ResponseBase, @@ -138,6 +142,7 @@ def __init__( "connector": connector, "module": MODULE, "timeout": timeout, + "headers": with_client_user_agent(client, None), } self._session: Optional[Union[WsSession, WsTask]] = None diff --git a/dashscope/transport/http.py b/dashscope/transport/http.py index 2c9164d2..49f67ad2 100644 --- a/dashscope/transport/http.py +++ b/dashscope/transport/http.py @@ -141,12 +141,20 @@ def _abs_url(base_url: Optional[str], path: str) -> str: return f"{base_url.rstrip('/')}/{path.lstrip('/')}" -def default_user_agent() -> str: +def default_user_agent(suffix: Optional[str] = None) -> str: + """Base User-Agent, optionally tagged with the calling surface. + + ``suffix`` lets an embedder (the bundled CLI, a notebook kernel) mark + its traffic so the service can attribute requests to a surface without + a separate header. It is appended as a space-separated token, the + conventional place for a product comment in a User-Agent. + """ try: from dashscope.version import __version__ except Exception: __version__ = "0.0.0" - return f"dashscope-v2-python/{__version__}" + agent = f"dashscope-sdk-python/{__version__}" + return f"{agent} {suffix}" if suffix else agent class SyncTransport: @@ -185,6 +193,15 @@ def http_client(self) -> httpx.Client: """The underlying httpx client (shared by ``with_options``).""" return self._client + @property + def user_agent(self) -> Optional[str]: + """This transport's User-Agent, for seeding non-HTTP channels. + + Websocket sessions do not ride this transport, so ``Client`` + reads the value back here to keep one identity across both. + """ + return self._default_headers.get("User-Agent") + def request( self, method: str, @@ -422,6 +439,11 @@ def http_client(self) -> httpx.AsyncClient: """The underlying httpx client (shared by ``with_options``).""" return self._client + @property + def user_agent(self) -> Optional[str]: + """This transport's User-Agent; see ``SyncTransport.user_agent``.""" + return self._default_headers.get("User-Agent") + async def request( self, method: str, diff --git a/dashscope/transport/ws.py b/dashscope/transport/ws.py index 3a2201a7..6724cfc9 100644 --- a/dashscope/transport/ws.py +++ b/dashscope/transport/ws.py @@ -33,6 +33,7 @@ "WsEventError", "WsSession", "WsStateError", + "with_client_user_agent", ] EVENT_STARTED = "task-started" @@ -68,6 +69,25 @@ def _close_websocket(ws: Any) -> None: pass +def with_client_user_agent( + client: Any, + headers: Optional[Mapping[str, str]], +) -> Dict[str, str]: + """Carry the client's User-Agent onto a websocket session's headers. + + Websocket sessions open their own connection instead of riding the + client's HTTP transport, so the agent (and any surface suffix on it) + has to be handed across explicitly. A caller-supplied user-agent wins + under whatever casing it used -- the check is case-insensitive so the + session never sends the header twice. + """ + merged = dict(headers or {}) + agent = getattr(getattr(client, "transport", None), "user_agent", None) + if agent and "user-agent" not in {k.lower() for k in merged}: + merged["user-agent"] = agent + return merged + + def _assemble_headers( api_key: Optional[str], workspace: Optional[str], diff --git a/dashscope/version.py b/dashscope/version.py index a7866f56..fd051aad 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.3" +__version__ = "2.0.4" diff --git a/examples/03_agent_recipes/mcp_server_demo/README.md b/examples/03_agent_recipes/mcp_server_demo/README.md index 71ebac50..003d6405 100644 --- a/examples/03_agent_recipes/mcp_server_demo/README.md +++ b/examples/03_agent_recipes/mcp_server_demo/README.md @@ -26,7 +26,7 @@ see `dashscope/acli/platforms/bailian/mcp.py` (read-only reference). Drive the whole conversation automatically (recommended): ```bash -python3 examples/03_agent_recipes/mcp_server_demo/client_demo.py +python examples/03_agent_recipes/mcp_server_demo/client_demo.py ``` Expected output: @@ -47,7 +47,7 @@ printf '%s\n' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add","arguments":{"a":2,"b":3}}}' \ - | python3 examples/03_agent_recipes/mcp_server_demo/server.py + | python examples/03_agent_recipes/mcp_server_demo/server.py ``` ## Sample session @@ -84,7 +84,7 @@ this server. Use the `command` + `args` form and an absolute path: { "mcpServers": { "dashscope-demo": { - "command": "python3", + "command": "python", "args": [ "/ABSOLUTE/PATH/TO/examples/03_agent_recipes/mcp_server_demo/server.py" ] diff --git a/examples/03_agent_recipes/mcp_server_demo/client_demo.py b/examples/03_agent_recipes/mcp_server_demo/client_demo.py index 4e3a8d49..968df56c 100644 --- a/examples/03_agent_recipes/mcp_server_demo/client_demo.py +++ b/examples/03_agent_recipes/mcp_server_demo/client_demo.py @@ -12,7 +12,7 @@ That makes the MCP demo runnable and testable on its own -- no external agent, no API key, no network. Run it from anywhere:: - python3 .../mcp_server_demo/client_demo.py + python .../mcp_server_demo/client_demo.py """ import json diff --git a/examples/04_real_world_scenarios/README.md b/examples/04_real_world_scenarios/README.md index 45c942fe..9a849dc1 100644 --- a/examples/04_real_world_scenarios/README.md +++ b/examples/04_real_world_scenarios/README.md @@ -52,7 +52,7 @@ A tool-call turn must **read the complete `message.tool_calls`** before local ex ```bash export DASHSCOPE_API_KEY="sk-xxx" -python3 examples/04_real_world_scenarios/customer_service_assistant.py +python examples/04_real_world_scenarios/customer_service_assistant.py ``` The demo runs a three-turn dialogue whose second turn — "can this order be refunded now?" — never repeats the order id: it only resolves because `ConversationMemory` carries turn one's `SO2024001` into the context. That is exactly what the memory layer is for. @@ -89,10 +89,10 @@ Likewise, a stream is a **one-shot iterator**: consuming `text_stream` and then ```bash # Offline (default, no key needed, deterministic output, demos scenarios A/B/C/D in turn) -python3 examples/04_real_world_scenarios/production_ready_rag_harness.py +python examples/04_real_world_scenarios/production_ready_rag_harness.py # Hit the real gateway (needs DASHSCOPE_API_KEY; runs scenario A only, no fault injection) -RAG_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/production_ready_rag_harness.py +RAG_HARNESS_LIVE=1 python examples/04_real_world_scenarios/production_ready_rag_harness.py ``` The offline demo runs in order: @@ -134,10 +134,10 @@ Likewise, when a socket drops mid-stream, `WsSession.recv` raises `APIConnection ```bash # Offline (default, no key needed, deterministic output, 4 rooms concurrent + reconnect + single-room degradation) -python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +python examples/04_real_world_scenarios/realtime_audio_meeting_harness.py # Hit the real gateway (needs DASHSCOPE_API_KEY + DASHSCOPE_TINGWU_APP_ID, runs one real-audio room) -MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +MEETING_HARNESS_LIVE=1 python examples/04_real_world_scenarios/realtime_audio_meeting_harness.py ``` The offline demo runs 4 rooms concurrently, reports `completed` / `degraded` separately, and shows each room's attempts / reconnects / segments plus a transcript preview — `room-delta` is degraded by a moderation block while the other 3 complete, visibly proving "one room's failure does not spread". @@ -176,10 +176,10 @@ And a routing note: the System One endpoint lives under the **compatible-mode pa ```bash # Offline (default, no key needed, deterministic: 5 tickets, one low-confidence, one injected outage) -python3 examples/04_real_world_scenarios/ticket_triage_harness.py +python examples/04_real_world_scenarios/ticket_triage_harness.py # Hit the real gateway (needs DASHSCOPE_API_KEY; the same harness, no fault injection) -TRIAGE_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/ticket_triage_harness.py +TRIAGE_HARNESS_LIVE=1 python examples/04_real_world_scenarios/ticket_triage_harness.py ``` The offline demo triages 5 tickets: T-2001 / T-2002 route to their teams, T-2003 ("你们这个系统到底怎么回事") falls below the confidence floor and goes to human review, T-2004 pages the on-call engineer as P1, and T-2005 suffers an injected backend outage and degrades to the human queue — then the summary prints the state distribution and the exact input-token ledger. diff --git a/examples/04_real_world_scenarios/README_zh.md b/examples/04_real_world_scenarios/README_zh.md index f6ccc078..ce4c0368 100644 --- a/examples/04_real_world_scenarios/README_zh.md +++ b/examples/04_real_world_scenarios/README_zh.md @@ -52,7 +52,7 @@ ConversationMemory ──(system + 最近若干轮)──► client.chat.complet ```bash export DASHSCOPE_API_KEY="sk-xxx" -python3 examples/04_real_world_scenarios/customer_service_assistant.py +python examples/04_real_world_scenarios/customer_service_assistant.py ``` 演示会跑一段三轮对话,其中第二轮——"这个订单现在能退款吗?"——不再重复订单号:它能被正确解答,完全是因为 `ConversationMemory` 把第一轮的 `SO2024001` 带进了上下文。这正是记忆层存在的意义。 @@ -89,10 +89,10 @@ python3 examples/04_real_world_scenarios/customer_service_assistant.py ```bash # 离线(默认,无需密钥,输出确定,依次演示场景 A/B/C/D) -python3 examples/04_real_world_scenarios/production_ready_rag_harness.py +python examples/04_real_world_scenarios/production_ready_rag_harness.py # 打真实网关(需要 DASHSCOPE_API_KEY;只跑场景 A,无故障注入) -RAG_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/production_ready_rag_harness.py +RAG_HARNESS_LIVE=1 python examples/04_real_world_scenarios/production_ready_rag_harness.py ``` 离线演示依次进行: @@ -134,10 +134,10 @@ RAG_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/production_ready_rag ```bash # 离线(默认,无需密钥,输出确定,4 个房间并发 + 重连 + 单房间降级) -python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +python examples/04_real_world_scenarios/realtime_audio_meeting_harness.py # 打真实网关(需要 DASHSCOPE_API_KEY + DASHSCOPE_TINGWU_APP_ID,跑一个真实音频房间) -MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_meeting_harness.py +MEETING_HARNESS_LIVE=1 python examples/04_real_world_scenarios/realtime_audio_meeting_harness.py ``` 离线演示并发跑 4 个房间,分别汇报 `completed` / `degraded`,展示每个房间的尝试次数 / 重连次数 / 句段数和转写预览——`room-delta` 被审核拦截降级,其余 3 个完成,直观地证明"一个房间的失败不外溢"。 @@ -176,10 +176,10 @@ MEETING_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/realtime_audio_m ```bash # 离线(默认,无需密钥,输出确定:5 张工单,一张低置信度,一次注入故障) -python3 examples/04_real_world_scenarios/ticket_triage_harness.py +python examples/04_real_world_scenarios/ticket_triage_harness.py # 打真实网关(需要 DASHSCOPE_API_KEY;同一个 harness,无故障注入) -TRIAGE_HARNESS_LIVE=1 python3 examples/04_real_world_scenarios/ticket_triage_harness.py +TRIAGE_HARNESS_LIVE=1 python examples/04_real_world_scenarios/ticket_triage_harness.py ``` 离线演示分诊 5 张工单:T-2001 / T-2002 路由到各自团队,T-2003("你们这个系统到底怎么回事")低于置信度下限进入人工复核,T-2004 作为 P1 呼叫值班工程师,T-2005 遭遇注入的后端故障并降级到人工队列——最后汇总打印状态分布和精确的 input-token 账本。 diff --git a/examples/05_cli/cli_ci_harness.py b/examples/05_cli/cli_ci_harness.py new file mode 100644 index 00000000..15acc15d --- /dev/null +++ b/examples/05_cli/cli_ci_harness.py @@ -0,0 +1,777 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 CLI example: driving the CLI from a script or a CI job. + +The command line is not a demo toy -- it is the interface your pipeline +talks to. That comes with a set of unglamorous requirements: parse +stdout instead of scraping a table, branch on the exit code instead of +grepping for the word "error", retry only the tiers that can succeed on +a second attempt, keep a job's configuration away from the developer's +``~/.dashscope``, and never print a secret into a build log. + +This file writes all of that once, and proves it works by running the +**real** CLI as a subprocess against a **local mock gateway** built from +the standard library: real transport, real protocol, real exit codes, +zero network and zero tokens. + +Scenarios: + +1. Machine-readable output: parse ``--output json``, and why the table + is for humans only (its diagnostics go to stderr). +2. Exit-code branching: 1 retryable vs 2/3/4 terminal, plus a retry + wrapper that backs off and gives up. +3. Streaming consumed as NDJSON, line by line, as frames arrive. +4. Configuration isolation: a per-job ``DASHSCOPE_CONFIG``, the + ``default_model`` fallback, api_key masking and the 0600 file mode. +5. ``-c KEY=VALUE`` overrides, including the fact that a misspelled key + fails even on a command that never touches the service. +6. Long-running jobs: ``ft list`` pagination, ``ft stream`` events, and + the destructive commands that refuse to run without a terminal. +7. CI hygiene: colour, and the environment worth setting in a job. + +Run it offline (the default):: + + python examples/05_cli/cli_ci_harness.py + +Or point the read-only scenarios at the real gateway:: + + CLI_HARNESS_LIVE=1 DASHSCOPE_API_KEY=sk-xxx \ + python examples/05_cli/cli_ci_harness.py + +Live mode skips every scenario that injects a fault, writes a +configuration file, or deletes a job. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qs, urlsplit + +# ---- configuration (override via env) ---- # +# Offline by default: the whole point is that a pipeline can rehearse +# its error handling without spending a token. +LIVE = os.environ.get("CLI_HARNESS_LIVE") == "1" +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen-plus") +# A real job id, for the read-only `ft get`/`ft stream` in live mode. +DEFAULT_JOB_ID = "ft-20260101-demo" +JOB_ID = os.environ.get("DASHSCOPE_JOB_ID", DEFAULT_JOB_ID) +# Live mode only touches a job the caller named explicitly: guessing an +# id against a real account is how a demo deletes something important. +OWN_JOB = os.environ.get("DASHSCOPE_JOB_ID") is not None +RETRY_ATTEMPTS = 3 + +REPO_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), os.pardir, os.pardir), +) + +PROMPT = "请用一句话介绍杭州" +ANSWER = "杭州是浙江省省会,以西湖与龙井茶闻名。" +REQUEST_ID = "req-mock-0001" +USAGE = {"input_tokens": 18, "output_tokens": 22, "total_tokens": 40} + +# The four tiers, mirroring dashscope/cli/errors.py. +EXIT_OK = 0 +EXIT_SERVER = 1 # server, ratelimit, network: worth retrying +EXIT_USAGE = 2 # the command line was wrong: retrying cannot help +EXIT_AUTH = 3 # bad credentials: retrying cannot help +EXIT_NOT_FOUND = 4 # the id does not exist: retrying cannot help + + +# ===================== the offline mock gateway ===================== # +# Fault injection is driven by a marker inside the request body, the +# way 02_advanced_engineering/local_mock_debugging.py does it, so one +# server can serve every scenario and the marker is visible in the +# command line that triggered it. +_FAULTS: Dict[str, Tuple[int, str, str]] = { + "trigger-ratelimit": (429, "Throttling.RateQuota", "Rate limit exceeded."), + "trigger-auth": (401, "InvalidApiKey", "Invalid API-key provided."), + "trigger-server": (500, "InternalError", "Internal server error."), +} +_ONCE_MARKER = "trigger-ratelimit-once" + + +def _envelope(content: str, model: str) -> Dict[str, Any]: + """The native chat-completion envelope the real gateway returns.""" + return { + "model": model, + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": content}, + }, + ], + }, + "usage": dict(USAGE), + "request_id": REQUEST_ID, + } + + +def _requested_model(body: str) -> str: + """The model the request asked for, echoed back in the response. + + Echoing it is what lets the configuration demos prove something: the + model in the response is the one that actually reached the wire, so + ``default_model`` and ``-c`` can be seen to take effect rather than + merely asserted to. + """ + try: + return str(json.loads(body).get("model") or MODEL) + except ValueError: + return MODEL + + +def _sse(events: List[Dict[str, Any]]) -> bytes: + """Server-sent events: ``id:`` + ``data:`` per frame, blank-line ended.""" + out = [] + for position, event in enumerate(events, 1): + payload = json.dumps(event, ensure_ascii=False) + out.append(f"id: {position}\ndata: {payload}\n\n") + return "".join(out).encode("utf-8") + + +def _chat_frames(content: str, model: str) -> List[Dict[str, Any]]: + """Incremental chat frames, then a final frame carrying the usage.""" + pieces = [content[i : i + 6] for i in range(0, len(content), 6)] + frames = [ + { + "model": model, + "output": { + "choices": [ + {"finish_reason": None, "message": {"content": piece}}, + ], + }, + "request_id": "req-mock-0002", + } + for piece in pieces + ] + frames.append( + { + "model": model, + "output": { + "choices": [ + {"finish_reason": "stop", "message": {"content": ""}}, + ], + }, + "usage": dict(USAGE), + "request_id": "req-mock-0002", + }, + ) + return frames + + +def _job(job_id: str, status: str) -> Dict[str, Any]: + return { + "job_id": job_id, + "status": status, + "model": MODEL, + "finetuned_output": f"{MODEL}-ft-demo", + "created_at": "2026-01-01T10:00:00Z", + "usage": dict(USAGE), + } + + +class _Gateway(BaseHTTPRequestHandler): + """Routes by path, serves canned envelopes, injects faults on demand.""" + + protocol_version = "HTTP/1.1" + # Shared across handler instances: how many times the "once" marker + # has already been served. A dict keeps it mutable at class level. + counters: Dict[str, int] = {} + + def log_message(self, *args) -> None: + """Drop the access log; the example prints its own narration.""" + + # -- plumbing ---------------------------------------------------- + def _body(self) -> str: + length = int(self.headers.get("content-length") or 0) + raw = self.rfile.read(length) if length else b"" + return raw.decode("utf-8", "replace") + + def _send( + self, + status: int, + payload: Any, + content_type: str = "application/json", + headers: Optional[Dict[str, str]] = None, + ) -> None: + raw = ( + payload + if isinstance(payload, bytes) + else json.dumps(payload, ensure_ascii=False).encode("utf-8") + ) + self.send_response(status) + self.send_header("content-type", content_type) + self.send_header("content-length", str(len(raw))) + for key, value in (headers or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(raw) + + def _fault(self, body: str) -> bool: + """Answer with a scripted failure if the body asked for one.""" + if _ONCE_MARKER in body: + seen = type(self).counters.get(_ONCE_MARKER, 0) + type(self).counters[_ONCE_MARKER] = seen + 1 + if seen: + return False + self._send( + 429, + {"code": "Throttling.RateQuota", "message": "Slow down."}, + headers={"Retry-After": "0"}, + ) + return True + for marker, (status, code, message) in _FAULTS.items(): + if marker in body: + self._send( + status, + {"code": code, "message": message}, + headers={"Retry-After": "0"} if status == 429 else None, + ) + return True + return False + + # -- routes ------------------------------------------------------ + def do_POST(self) -> None: + body = self._body() + if self._fault(body): + return + if "text-generation/generation" in self.path: + model = _requested_model(body) + if self.headers.get("X-DashScope-SSE") == "enable": + self._send( + 200, + _sse(_chat_frames(ANSWER, model)), + content_type="text/event-stream", + ) + return + self._send(200, _envelope(ANSWER, model)) + return + if self.path.startswith("/fine-tunes"): + self._send( + 200, + { + "output": _job(JOB_ID, "RUNNING"), + "request_id": "req-mock-0003", + }, + ) + return + self._send(404, {"code": "NotFound", "message": self.path}) + + def do_GET(self) -> None: + if not self.path.startswith("/fine-tunes"): + self._send(404, {"code": "NotFound", "message": self.path}) + return + route = urlsplit(self.path) + if route.path == "/fine-tunes": + query = parse_qs(route.query) + size = int(query.get("page_size", ["10"])[0]) + jobs = [_job("ft-aaa", "SUCCEEDED"), _job("ft-bbb", "RUNNING")] + self._send( + 200, + { + "output": { + "jobs": jobs[:size], + "page_no": 1, + "page_size": size, + "total": 2, + }, + "request_id": "req-mock-0004", + }, + ) + return + if route.path.endswith("/stream"): + events = [ + {"status": "RUNNING", "progress": 40}, + {"status": "SUCCEEDED", "progress": 100}, + ] + self._send( + 200, + _sse(events), + content_type="text/event-stream", + ) + return + job_id = route.path.rsplit("/", 1)[-1] + if job_id == "ft-missing": + self._send( + 404, + {"code": "NotFound", "message": "The job does not exist."}, + ) + return + self._send( + 200, + { + "output": _job(job_id, "SUCCEEDED"), + "request_id": "req-mock-0005", + }, + ) + + def do_DELETE(self) -> None: + if self.path.startswith("/fine-tunes"): + self._send( + 200, + { + "output": {"status": "DELETED"}, + "request_id": "req-mock-0006", + }, + ) + return + self._send(404, {"code": "NotFound", "message": self.path}) + + +def _start_gateway() -> Tuple[ThreadingHTTPServer, str]: + """Serve on an ephemeral port; a fixed one collides on shared CI.""" + server = ThreadingHTTPServer(("127.0.0.1", 0), _Gateway) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, f"http://127.0.0.1:{server.server_port}" + + +# ===================== running the real CLI ===================== # +def _cli(*args: str) -> List[str]: + """The command prefix that runs the real CLI in a subprocess. + + ``python -m dashscope.cli`` rather than the ``dashscope`` console + script: the script only exists once the package is installed, and an + example has to run from a source checkout too. + """ + return [sys.executable, "-m", "dashscope.cli", *args] + + +def _child_env(config_path: str) -> Dict[str, str]: + """The environment for every subprocess. + + ``DASHSCOPE_CONFIG`` points at a throwaway file so the run neither + reads nor writes the developer's real ``~/.dashscope/config.toml``, + and ``PYTHONPATH`` keeps the checkout importable when the package is + not installed. + """ + env = dict(os.environ) + env["PYTHONPATH"] = REPO_ROOT + os.pathsep + env.get("PYTHONPATH", "") + env["NO_COLOR"] = "1" + env["DASHSCOPE_CONFIG"] = config_path + if not LIVE: + env["DASHSCOPE_API_KEY"] = "sk-local-demo" + # The endpoint travels as `--base-url` on every command line below, so + # one inherited from the developer's shell must not leak in and take + # over silently. + env.pop("DASHSCOPE_BASE_URL", None) + return env + + +def _global(base_url: Optional[str], *options: str) -> List[str]: + """Global options, which must precede the subcommand.""" + prefix: List[str] = [] + if base_url: + prefix += ["--base-url", base_url] + # In-process retries would hide the tiers the caller has to handle, + # so the offline runs turn them off and do their own. + if not LIVE: + prefix += ["-c", "max_retries=0"] + return [*prefix, *options] + + +def _chat_argv( + base_url: Optional[str], + output: Optional[str], + *extra: str, +) -> List[str]: + """Full argv for one ``chat create``, with the global options first. + + ``output`` is the ``-o`` format, or None to leave the default table. + Every scenario goes through here, which keeps the "global options + must precede the subcommand" rule visible in one place. + """ + options = ["-o", output] if output else [] + return _cli(*_global(base_url, *options), "chat", "create", *extra) + + +def _show(channel: str, text: str, limit: int = 12) -> None: + """Print one captured channel, prefixed so the two stay separable.""" + body = (text or "").rstrip("\n") + if not body: + print(f" {channel}: (empty)") + return + lines = body.splitlines() + for line in lines[:limit]: + print(f" {channel}| {line}") + if len(lines) > limit: + print(f" {channel}| ... {len(lines) - limit} more line(s)") + + +FAILURES: List[str] = [] + + +def _run( + label: str, + args: List[str], + expect: int, + env: Dict[str, str], + stdin_text: Optional[str] = None, +): + """Run the real CLI, echo all three channels, check the exit code.""" + print(f"\n$ dashscope {' '.join(args[3:])}") + completed = subprocess.run( + args, + input=stdin_text, + capture_output=True, + text=True, + encoding="utf-8", + check=False, + env=env, + timeout=120, + ) + _show("out", completed.stdout) + _show("err", completed.stderr) + verdict = "ok" if completed.returncode == expect else "FAIL" + print(f" exit={completed.returncode} expected={expect} -> {verdict}") + if verdict == "FAIL": + FAILURES.append(label) + return completed + + +def _retry_cli(args: List[str], env: Dict[str, str]): + """Run the CLI, retrying only the tier that can succeed later. + + Exit 1 covers server, rate-limit and network failures -- all of them + transient. Exits 2 (your command line), 3 (your credentials) and 4 + (your id) will not fix themselves, so retrying them only burns the + quota window and delays the red build. + """ + completed = None + delay = 0.5 + for attempt in range(1, RETRY_ATTEMPTS + 1): + completed = subprocess.run( + args, + capture_output=True, + text=True, + encoding="utf-8", + check=False, + env=env, + timeout=120, + ) + if completed.returncode != EXIT_SERVER: + print(f" attempt {attempt}: exit={completed.returncode}") + return completed + print( + f" attempt {attempt}: exit=1 (transient), " + f"backing off {delay:.1f}s", + ) + time.sleep(delay) + delay *= 2 + print(f" gave up after {RETRY_ATTEMPTS} attempts") + return completed + + +# ===================== scenarios ===================== # +def _scenario_machine_output(base_url, env) -> None: + print("\n===== 1. parse the machine formats, never the table =====") + chat = ["-m", MODEL, "--prompt", PROMPT] + result = _run("json", _chat_argv(base_url, "json", *chat), EXIT_OK, env) + payload = json.loads(result.stdout) + print( + " stdout is one JSON document:", + payload["choices"][0]["message"]["content"], + ) + print(f" usage: {payload['usage']} | request_id: {payload['request_id']}") + print( + " The same call in table mode: stdout carries the answer and", + "nothing else, while reasoning and usage go to stderr. That is", + "what makes `> answer.txt` safe -- and what makes scraping the", + "table unnecessary.", + ) + _run("table", _chat_argv(base_url, None, *chat), EXIT_OK, env) + + +def _scenario_exit_codes(base_url, env) -> None: + print("\n===== 2. branch on the exit code, not on the text =====") + chat = ["-m", MODEL] + throttled = "trigger-ratelimit 请介绍杭州" + bad_key = "trigger-auth 请介绍杭州" + broken = "trigger-server 请介绍杭州" + + argv = _chat_argv(base_url, "json", *chat, "--prompt", throttled) + _run("ratelimit", argv, EXIT_SERVER, env) + print(" exit 1 with category ratelimit: transient, retry it.") + + argv = _chat_argv(base_url, None, *chat, "--prompt", bad_key) + _run("auth", argv, EXIT_AUTH, env) + print(" exit 3: the key is wrong. Retrying is pointless -- fail the job.") + + argv = _chat_argv(base_url, None, *chat, "--prompt", broken) + _run("server", argv, EXIT_SERVER, env) + + argv = _cli(*_global(base_url), "ft", "get", "ft-missing") + _run("notfound", argv, EXIT_NOT_FOUND, env) + print(" exit 4: the id does not exist. Fix the input, do not retry.") + + argv = _chat_argv(base_url, "csv", *chat, "--prompt", PROMPT) + _run("usage", argv, EXIT_USAGE, env) + print(" exit 2: the command line itself is wrong.") + print( + "\n A wrapper that retries only exit 1. The marker makes the", + "gateway throttle the first attempt and serve the second:", + ) + retry_prompt = f"{_ONCE_MARKER} 请介绍杭州" + argv = _chat_argv(base_url, "json", *chat, "--prompt", retry_prompt) + retried = _retry_cli(argv, env) + if retried is not None and retried.returncode == EXIT_OK: + answer = json.loads(retried.stdout)["choices"][0]["message"]["content"] + print(f" recovered after a throttle: {answer}") + + +def _scenario_streaming(base_url, env) -> None: + print("\n===== 3. consume a stream as NDJSON, line by line =====") + tail = ["-m", MODEL, "--prompt", PROMPT, "--stream"] + args = _chat_argv(base_url, "json", *tail) + print(f"\n$ dashscope {' '.join(args[3:])}") + parts: List[str] = [] + usage: Optional[Dict[str, Any]] = None + # Popen rather than run: a stream is worth reading as it arrives, + # which is the whole reason to ask for one. Frames land here in a + # burst because the mock answers at once; a real gateway sends them + # as the model produces them. + with subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + env=env, + ) as proc: + for line in proc.stdout: + if not line.strip(): + continue + frame = json.loads(line) + for choice in frame.get("choices", []): + parts.append(choice.get("delta", {}).get("content") or "") + if frame.get("usage"): + usage = frame["usage"] + proc.wait(timeout=60) + print(f" exit={proc.returncode}") + print(f" reassembled from NDJSON: {''.join(parts)}") + print(f" usage from the final frame: {usage}") + print( + " `-o yaml` streams NDJSON too -- one JSON object per line, not", + "one YAML document.", + ) + + +def _scenario_config(config_path: str, base_url, env) -> None: + print("\n===== 4. give the job its own configuration =====") + print(f" DASHSCOPE_CONFIG={config_path}") + _run( + "config-set-model", + _cli("config", "set", "default_model", MODEL), + EXIT_OK, + env, + ) + _run( + "config-set-key", + _cli("config", "set", "api_key", "sk-local-demo-1234567890"), + EXIT_OK, + env, + ) + print( + " `config set` never echoes the value: a terminal recording or", + "a build log should not capture a key that was just typed in.", + ) + _run("config-get-masked", _cli("config", "get", "api_key"), EXIT_OK, env) + argv = _cli("config", "get", "api_key", "--reveal") + _run("config-get-revealed", argv, EXIT_OK, env) + _run("config-list", _cli("config", "list"), EXIT_OK, env) + print( + " `config get` reads the file alone; `config list` reports the", + "effective value plus the layer that won. That is why api_key shows", + "the file's masked value above but `env DASHSCOPE_API_KEY` below:", + "this harness exports a key for the child process, and the", + "environment outranks the file.", + ) + mode = os.stat(config_path).st_mode & 0o777 + print(f" the file mode after a secret was written: {oct(mode)}") + print( + " With default_model configured, `-m` becomes optional -- note", + "the model in the response is the configured one:", + ) + argv = _chat_argv(base_url, "json", "--prompt", PROMPT) + result = _run("default-model", argv, EXIT_OK, env) + print( + f" model resolved from config: {json.loads(result.stdout)['model']}", + ) + + +def _scenario_overrides(base_url, env) -> None: + print("\n===== 5. -c overrides one key for one invocation =====") + print( + " Precedence is flag > -c > environment > config file > default.", + "Here -c supplies the model for a single call, without writing", + "anything down:", + ) + argv = _cli( + *_global(base_url, "-o", "json", "-c", "default_model=qwen-max"), + "chat", + "create", + "--prompt", + PROMPT, + ) + result = _run("c-default-model", argv, EXIT_OK, env) + print(f" model: {json.loads(result.stdout)['model']}") + print( + " A misspelled key fails immediately -- even on `version`, which", + "never touches the service. That is deliberate: a typo in a", + "pipeline should surface on the cheapest command there is.", + ) + _run( + "c-unknown-key", + _cli(*_global(base_url), "-c", "defualt_model=qwen-max", "version"), + EXIT_USAGE, + env, + ) + + +def _scenario_jobs(base_url, env) -> None: + print("\n===== 6. long-running jobs and destructive commands =====") + _run( + "ft-list-table", + _cli(*_global(base_url), "ft", "list", "--size", "1"), + EXIT_OK, + env, + ) + print( + " The `page x/y, total n` line is on stderr, so the table stays", + "the only thing on stdout. In json mode the pagination is part of", + "the document instead:", + ) + result = _run( + "ft-list-json", + _cli(*_global(base_url, "-o", "json"), "ft", "list", "--size", "1"), + EXIT_OK, + env, + ) + listed = json.loads(result.stdout) + print(f" total={listed['total_count']} page={listed['page_no']}") + if LIVE: + if not OWN_JOB: + print( + " live mode: skipping `ft stream` and `ft delete` -- set", + "DASHSCOPE_JOB_ID to a job you own to run the stream.", + ) + return + _run( + "ft-stream", + _cli(*_global(base_url), "ft", "stream", JOB_ID), + EXIT_OK, + env, + ) + print(" live mode never runs the destructive commands.") + return + _run( + "ft-stream", + _cli(*_global(base_url), "ft", "stream", JOB_ID), + EXIT_OK, + env, + ) + print( + " Cancel and delete are destructive. With no terminal attached --", + "which is what a CI job looks like -- they refuse at exit 2", + "instead of guessing:", + ) + _run( + "ft-delete-no-tty", + _cli(*_global(base_url), "ft", "delete", JOB_ID), + EXIT_USAGE, + env, + ) + _run( + "ft-delete-yes", + _cli(*_global(base_url), "ft", "delete", JOB_ID, "--yes"), + EXIT_OK, + env, + ) + + +def _scenario_ci_hygiene(base_url, env) -> None: + print("\n===== 7. what else to set in a CI job =====") + result = _run( + "no-color", + _cli(*_global(base_url), "ft", "list", "--size", "1"), + EXIT_OK, + env, + ) + escaped = "\x1b" in result.stdout or "\x1b" in result.stderr + print( + f" ANSI escapes in the captured output: {escaped}. Rich already", + "detects the pipe, so NO_COLOR=1 matters mainly for runners that", + "allocate a pseudo-terminal and then dump it into a log.", + ) + print( + " Also worth setting: DASHSCOPE_CONFIG to a job-local path so", + "parallel jobs never share one file.", + ) + + +# ===================== entry point ===================== # +def main() -> int: + """Run every scenario; return non-zero if the CLI's contract moved.""" + server: Optional[ThreadingHTTPServer] = None + base_url: Optional[str] = None + with tempfile.TemporaryDirectory(prefix="dashscope-cli-harness-") as tmp: + config_path = os.path.join(tmp, "config.toml") + try: + if LIVE: + print( + "== CLI_HARNESS_LIVE=1: read-only scenarios against the", + "real gateway. Fault injection, config writes and", + "destructive commands are skipped. ==", + ) + if not os.environ.get("DASHSCOPE_API_KEY"): + print("FAIL: DASHSCOPE_API_KEY is not set.") + return 1 + else: + server, base_url = _start_gateway() + print(f"== offline: mock gateway on {base_url} ==") + env = _child_env(config_path) + + _scenario_machine_output(base_url, env) + if not LIVE: + _scenario_exit_codes(base_url, env) + _scenario_streaming(base_url, env) + if not LIVE: + _scenario_config(config_path, base_url, env) + _scenario_overrides(base_url, env) + _scenario_jobs(base_url, env) + _scenario_ci_hygiene(base_url, env) + finally: + if server is not None: + server.shutdown() + server.server_close() + + print("\n===== summary =====") + if FAILURES: + print(f"FAIL: {len(FAILURES)} invocation(s) missed their exit code:") + for label in FAILURES: + print(f" - {label}") + return 1 + print("every scenario produced the exit code the CLI promises.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + +# --- Production springboard (Best Practices & Patterns) ------- +# CLI-side scripting above; the SDK-side equivalents are: +# Offline mock debugging: 02_advanced_engineering/local_mock_debugging.py +# Backoff + breaker DR: 02_advanced_engineering/circuit_breaker_retry.py +# CLI contract walkthrough: 05_cli/cli_output_and_errors.py +# Recipes and reference: README.md (05_cli section) +# ------------------------------------------------------------- diff --git a/examples/05_cli/cli_output_and_errors.py b/examples/05_cli/cli_output_and_errors.py new file mode 100644 index 00000000..50c09116 --- /dev/null +++ b/examples/05_cli/cli_output_and_errors.py @@ -0,0 +1,607 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""dashscope 2.0 CLI example: output formats, streaming frames and the +exit-code tiers -- fully offline, with the SDK client swapped for a fake. + +The command line is a contract surface of its own, separate from the +Python API: three output formats, a tiered exit code, and a rule about +which channel carries what. This file walks that contract end to end +without a network call, without an API key and without touching your +``~/.dashscope/config.toml``, by replacing the CLI's single SDK seam +(``dashscope.cli.sdk.build_client``) with a fake client that returns +canned -- but genuinely typed -- SDK responses. + +Five demonstrations: + +1. **Output formats**: one completion rendered as table / json / yaml, + showing that table mode keeps stdout clean (the answer only) and + moves ``reasoning_content`` and ``usage`` to stderr, while the + machine formats put the whole envelope on stdout and stay parseable. +2. **Streaming**: ``--stream`` writes incremental text in table mode + and NDJSON -- one JSON object per line -- in *both* json and yaml + mode, so ``--stream --output json | jq`` works. +3. **Input conventions**: ``@file`` and ``-`` (stdin) for ``--prompt``, + and JSON array / JSONL / ``{"messages": [...]}`` for ``--messages``. +4. **Exit-code tiers**: usage 2, auth 3, not-found 4, and 1 for the + rate-limit / server / network failures -- each raised as the real SDK + error type, so the mapping in ``dashscope/cli/errors.py`` is what + actually runs rather than a description of it. +5. **Destructive commands**: with no terminal attached, ``ft delete`` + refuses at exit 2 unless ``--yes`` is given -- the classic CI + failure, reproduced here on purpose. + +Run it:: + + python examples/05_cli/cli_output_and_errors.py + +Every invocation is checked against the exit code it is supposed to +produce, so a silent contract change in the CLI turns this example red +instead of quietly teaching the wrong thing. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from types import SimpleNamespace +from typing import Any, Dict, List, Optional + +from typer.testing import CliRunner + +from dashscope import errors as sdk_errors +from dashscope.cli import sdk as cli_sdk +from dashscope.cli.app import build_app +from dashscope.resources.chat.completions import ChatStream +from dashscope.resources.fine_tunes import FineTune +from dashscope.types.common import JobStatus, Usage +from dashscope.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + Choice, + Delta, + StreamChoice, +) + +# ---- configuration (override via env) ---- # +MODEL = os.environ.get("DASHSCOPE_MODEL", "qwen-plus") + +PROMPT = "请用一句话介绍杭州" +ANSWER = "杭州是浙江省省会,以西湖与龙井茶闻名。" +REASONING = "这是一个地理常识问题,直接给一句话结论即可。" +USAGE = {"input_tokens": 18, "output_tokens": 22, "total_tokens": 40} +JOB_ID = "ft-20260101-demo" + +# The four tiers, mirroring dashscope/cli/errors.py. +EXIT_OK = 0 +EXIT_SERVER = 1 # server, ratelimit, network: "not your bug, try again" +EXIT_USAGE = 2 # the command line was wrong +EXIT_AUTH = 3 # bad credentials or insufficient permission +EXIT_NOT_FOUND = 4 # the named resource does not exist + +# `chat create` without a prompt: the --messages demos fill it in. +CHAT = ["chat", "create", "-m", MODEL, "--prompt", PROMPT] +CHAT_BARE = ["chat", "create", "-m", MODEL] + + +# ===================== canned, typed SDK responses ===================== # +def _completion() -> ChatCompletion: + """A real ``ChatCompletion``, not a duck-typed stand-in. + + ``--output json`` serialises through pydantic's ``model_dump``; a + ``SimpleNamespace`` would degrade to its own repr and teach the + wrong lesson about what the machine formats emit. + """ + return ChatCompletion( + model=MODEL, + choices=[ + Choice( + message=ChatCompletionMessage( + role="assistant", + content=ANSWER, + reasoning_content=REASONING, + ), + finish_reason="stop", + ), + ], + usage=Usage(**USAGE), + request_id="req-demo-0001", + # Transport metadata: stripped from machine output unless -v. + headers={"x-request-id": "req-demo-0001"}, + ) + + +def _chunk(text: str, *, final: bool = False) -> ChatCompletionChunk: + return ChatCompletionChunk( + model=MODEL, + choices=[ + StreamChoice( + delta=Delta(role="assistant", content=text), + finish_reason="stop" if final else None, + ), + ], + usage=Usage(**USAGE) if final else None, + request_id="req-demo-0002", + ) + + +def _chunks() -> List[ChatCompletionChunk]: + """Incremental frames, the way the native wire sends them.""" + pieces = ["杭州", "是浙江省", "省会,", "以西湖", "闻名。"] + frames = [_chunk(piece) for piece in pieces] + frames.append(_chunk("", final=True)) + return frames + + +class _FakeInner: + """Stand-in for the transport's SSE stream: iterable and closable.""" + + def __init__(self, frames: List[ChatCompletionChunk]) -> None: + self._frames = frames + self.headers: Dict[str, str] = {} + self.closed = False + + def __iter__(self): + return iter(self._frames) + + def close(self) -> None: + self.closed = True + + +class _FakeCompletions: + """``client.chat.completions``: records calls, returns canned data. + + Setting ``error`` makes the next call raise instead, which is how the + exit-code demos drive the CLI's error handler with real SDK types. + """ + + def __init__(self) -> None: + self.calls: List[Dict[str, Any]] = [] + self.error: Optional[Exception] = None + + def create(self, **kwargs): + self.calls.append(kwargs) + if self.error is not None: + raise self.error + if kwargs.get("stream"): + # The real stream wrapper: --stream must see .text_stream, + # iteration, and .close(), exactly as the SDK provides them. + return ChatStream(_FakeInner(_chunks())) + return _completion() + + +class _FakeFineTunes: + """``client.fine_tunes``: the two calls the demos below need.""" + + def __init__(self) -> None: + self.calls: List[str] = [] + self.error: Optional[Exception] = None + + def _enter(self, name: str, job_id: str) -> None: + """Record the call, then raise whatever the demo injected.""" + self.calls.append(f"{name} {job_id}") + if self.error is not None: + raise self.error + + def get(self, job_id, **_): + self._enter("get", job_id) + return FineTune( + job_id=job_id, + status="SUCCEEDED", + model=MODEL, + finetuned_output=f"{MODEL}-ft-demo", + created_at="2026-01-01T10:00:00Z", + usage=Usage(**USAGE), + ) + + def delete(self, job_id, **_): + self._enter("delete", job_id) + return JobStatus(status="DELETED", request_id="req-demo-0003") + + +_COMPLETIONS = _FakeCompletions() +_FINE_TUNES = _FakeFineTunes() +_CLIENT_KWARGS: List[Dict[str, Any]] = [] + + +def _fake_build_client(overrides): + """Replacement for ``dashscope.cli.sdk.build_client``. + + ``CliContext.client`` calls the seam through the module attribute, + so swapping it needs no HTTP mock, no server and no key. The + resolved global flags arrive as ``overrides`` -- recording them shows + exactly what ``-k`` / ``--timeout`` / ``-c`` turned into. + """ + _CLIENT_KWARGS.append(dict(overrides)) + return SimpleNamespace( + chat=SimpleNamespace(completions=_COMPLETIONS), + fine_tunes=_FINE_TUNES, + close=lambda: None, + ) + + +# ===================== invocation helpers ===================== # +def _make_runner() -> CliRunner: + """A runner that captures stdout and stderr separately. + + ``mix_stderr`` was removed in click 8.2, where the two streams are + already separate, and the SDK pins only ``typer>=0.9`` -- so the flag + is passed where it still exists and skipped where it does not. + """ + try: + return CliRunner(mix_stderr=False) + except TypeError: # click >= 8.2 + return CliRunner() + + +def _stderr_of(result) -> str: + try: + return result.stderr + except ValueError: # click < 8.2 constructed with mixed stderr + return "(this click version mixes stderr into stdout)" + + +def _show(channel: str, text: str) -> None: + """Print one captured channel, prefixed so the two stay separable.""" + body = (text or "").rstrip("\n") + if not body: + print(f" {channel}: (empty)") + return + for line in body.splitlines(): + print(f" {channel}| {line}") + + +FAILURES: List[str] = [] + + +def _run(label, args, expect, input_text=None, error=None): + """One invocation: echo the command, show both channels, check exit.""" + print(f"\n$ dashscope {' '.join(args)}") + if error is not None: + # The command line is fine in these cases; the failure is + # injected, so name the SDK error type the CLI is being handed. + print(f"# injected failure: {type(error).__name__}") + _COMPLETIONS.error = error + _FINE_TUNES.error = error + try: + result = RUNNER.invoke(APP, list(args), input=input_text) + finally: + _COMPLETIONS.error = None + _FINE_TUNES.error = None + _show("out", result.stdout) + _show("err", _stderr_of(result)) + verdict = "ok" if result.exit_code == expect else "FAIL" + print(f" exit={result.exit_code} expected={expect} -> {verdict}") + if verdict == "FAIL": + FAILURES.append(label) + return result + + +def _global(*options) -> List[str]: + """Global options, which must precede the subcommand.""" + return ["-k", "sk-demo", *options] + + +def _last_conversation() -> str: + return json.dumps( + _COMPLETIONS.calls[-1]["messages"], + ensure_ascii=False, + ) + + +# ===================== A. the three output formats ===================== # +def _demo_output_formats() -> None: + print("\n===== A. one completion, three output formats =====") + print( + "Table mode writes the answer to stdout and moves reasoning and\n" + "usage to stderr, so `dashscope chat create ... > answer.txt`\n" + "captures nothing but the answer. The machine formats put the\n" + "whole envelope on stdout and never render Rich markup.", + ) + _run("table", _global() + CHAT, EXIT_OK) + result = _run("json", _global("-o", "json") + CHAT, EXIT_OK) + payload = json.loads(result.stdout) + print( + " stdout parsed with json.loads ->", + payload["choices"][0]["message"]["content"], + ) + print( + " (reasoning is in the same document:", + bool(payload["choices"][0]["message"].get("reasoning_content")), + "| headers stripped:", + "headers" not in payload, + ")", + ) + _run("yaml", _global("-o", "yaml") + CHAT, EXIT_OK) + print( + " -v keeps the transport headers that are otherwise dropped,", + "so a support ticket can carry them:", + ) + _run("json-verbose", _global("-o", "json", "-v") + CHAT, EXIT_OK) + print(" the resolved global flags became Client(...) kwargs:") + print(f" {_CLIENT_KWARGS[-1]}") + + +# ===================== B. streaming ===================== # +def _demo_streaming() -> None: + print("\n===== B. streaming: incremental text vs NDJSON =====") + _run("stream-table", _global() + CHAT + ["--stream"], EXIT_OK) + result = _run( + "stream-json", + _global("-o", "json") + CHAT + ["--stream"], + EXIT_OK, + ) + frames = [ + json.loads(line) for line in result.stdout.splitlines() if line.strip() + ] + text = "".join( + frame["choices"][0]["delta"].get("content", "") for frame in frames + ) + usage = next( + (frame["usage"] for frame in frames if frame.get("usage")), + None, + ) + print(f" {len(frames)} NDJSON frames read back from stdout") + print(f" reassembled: {text}") + print(f" usage from the final frame: {usage}") + print( + " yaml streams as NDJSON too -- a stream is not one YAML", + "document, it is one JSON object per line:", + ) + _run( + "stream-yaml", + _global("-o", "yaml") + CHAT + ["--stream"], + EXIT_OK, + ) + + +# ===================== C. input conventions ===================== # +def _demo_input_conventions() -> None: + print("\n===== C. input: @file, stdin, and three --messages shapes =====") + with tempfile.NamedTemporaryFile( + "w", + suffix=".txt", + delete=False, + encoding="utf-8", + ) as handle: + handle.write("用一句话解释什么是熔断器\n") + path = handle.name + try: + from_file = ["chat", "create", "-m", MODEL, "--prompt", f"@{path}"] + _run("prompt-at-file", _global() + from_file, EXIT_OK) + finally: + os.unlink(path) + print(f" the CLI read the prompt from the file: {_last_conversation()}") + + _run( + "prompt-stdin", + _global() + ["chat", "create", "-m", MODEL, "--prompt", "-"], + EXIT_OK, + input_text="从管道读进来的问题\n", + ) + print(f" `--prompt -` took stdin: {_last_conversation()}") + + _run( + "piped-stdin", + _global() + CHAT_BARE, + EXIT_OK, + input_text="没有 --prompt,直接管道进来\n", + ) + print(f" bare piped stdin became the user turn: {_last_conversation()}") + + multi = [ + {"role": "system", "content": "你是一个简洁的助手。"}, + {"role": "user", "content": PROMPT}, + ] + as_json = json.dumps(multi, ensure_ascii=False) + as_jsonl = "\n".join(json.dumps(m, ensure_ascii=False) for m in multi) + as_object = json.dumps({"messages": multi}, ensure_ascii=False) + + _run( + "messages-json", + _global() + CHAT_BARE + ["--messages", as_json], + EXIT_OK, + ) + _run( + "messages-jsonl", + _global() + CHAT_BARE + ["--messages", as_jsonl], + EXIT_OK, + ) + print(" JSONL -- one message object per line -- built the same thing:") + print(f" {_last_conversation()}") + _run( + "messages-object", + _global() + CHAT_BARE + ["--messages", as_object], + EXIT_OK, + ) + print( + " --messages wins over --prompt, and --system is prepended to", + "--prompt only:", + ) + with_system = [ + "chat", + "create", + "-m", + MODEL, + "--system", + "你是一个简洁的助手。", + "--prompt", + PROMPT, + ] + _run("system-plus-prompt", _global() + with_system, EXIT_OK) + print(f" {_last_conversation()}") + + +# ===================== D. the exit-code tiers ===================== # +def _demo_usage_tier() -> None: + print("\n----- D1. exit 2: the command line was wrong -----") + _run( + "bad-output-format", + ["-k", "sk-demo", "-o", "csv"] + CHAT, + EXIT_USAGE, + ) + print( + " no model and no configured default_model (this example points", + "DASHSCOPE_CONFIG at an empty temp file, so nothing leaks in):", + ) + _run( + "no-model", + _global() + ["chat", "create", "--prompt", PROMPT], + EXIT_USAGE, + ) + print(" a global option placed AFTER the subcommand is not global:") + _run( + "global-option-after-subcommand", + CHAT + ["-o", "json"], + EXIT_USAGE, + ) + _run( + "tools-not-an-array", + _global() + CHAT + ["--tools", '{"type": "function"}'], + EXIT_USAGE, + ) + + +def _demo_service_tiers() -> None: + print("\n----- D2. exits 1 / 3 / 4: what the service said -----") + _run( + "auth-401", + _global() + CHAT, + EXIT_AUTH, + error=sdk_errors.AuthenticationError( + "Invalid API-key provided.", + request_id="req-demo-0401", + ), + ) + _run( + "notfound-404", + _global() + ["ft", "get", "ft-does-not-exist"], + EXIT_NOT_FOUND, + error=sdk_errors.NotFoundError( + "The fine-tune job does not exist.", + request_id="req-demo-0404", + ), + ) + _run( + "ratelimit-429", + _global() + CHAT, + EXIT_SERVER, + error=sdk_errors.RateLimitError( + "Requests rate limit exceeded, please try again later.", + ), + ) + _run( + "server-500", + _global() + CHAT, + EXIT_SERVER, + error=sdk_errors.InternalServerError("Internal server error."), + ) + print( + " network failures share exit 1 with server errors, but the", + "category on stderr tells them apart -- and only the network and", + "rate-limit tiers are worth an automatic retry:", + ) + _run( + "network", + _global() + CHAT, + EXIT_SERVER, + error=sdk_errors.APIConnectionError( + "Connection refused by the gateway.", + ), + ) + print( + "\n tier summary: 0 ok | 1 server/ratelimit/network (retry)", + "| 2 usage (fix the command) | 3 auth (fix the key)", + "| 4 not found (fix the id)", + ) + + +# ===================== E. destructive commands ===================== # +def _demo_destructive() -> None: + print("\n===== E. destructive commands need --yes in CI =====") + print( + "There is no terminal here, which is exactly the situation a CI", + "job is in: cancel/delete refuse rather than guess.", + ) + _run("delete-no-tty", _global() + ["ft", "delete", JOB_ID], EXIT_USAGE) + _run( + "delete-yes", + _global() + ["ft", "delete", JOB_ID, "--yes"], + EXIT_OK, + ) + print(f" calls that reached the service: {_FINE_TUNES.calls}") + + +# ===================== entry point ===================== # +def _isolated_env(): + """Point the config file at an empty temp path, and disable colour. + + Without this the demos that depend on "no default_model configured" + or on machine-readable output would vary with whatever the developer + happens to have in ``~/.dashscope/config.toml``. The real file is + never read and never written. + """ + directory = tempfile.mkdtemp(prefix="dashscope-cli-example-") + saved = { + key: os.environ.get(key) for key in ("DASHSCOPE_CONFIG", "NO_COLOR") + } + os.environ["DASHSCOPE_CONFIG"] = os.path.join(directory, "config.toml") + os.environ["NO_COLOR"] = "1" + return directory, saved + + +def _restore_env(directory: str, saved: Dict[str, Optional[str]]) -> None: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + config = os.path.join(directory, "config.toml") + if os.path.exists(config): + os.unlink(config) + os.rmdir(directory) + + +APP = build_app() +RUNNER = _make_runner() + + +def main() -> int: + """Run every demonstration; return non-zero if a contract drifted.""" + directory, saved_env = _isolated_env() + original_build_client = cli_sdk.build_client + cli_sdk.build_client = _fake_build_client + try: + _demo_output_formats() + _demo_streaming() + _demo_input_conventions() + _demo_usage_tier() + _demo_service_tiers() + _demo_destructive() + finally: + cli_sdk.build_client = original_build_client + _restore_env(directory, saved_env) + + print("\n===== summary =====") + if FAILURES: + print(f"FAIL: {len(FAILURES)} invocation(s) missed their exit code:") + for label in FAILURES: + print(f" - {label}") + return 1 + print("every invocation produced the exit code its tier promises.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) + + +# --- Production springboard (Best Practices & Patterns) ------- +# Contract-level walkthrough above; to drive the CLI for real, see: +# Shell/CI scripting: 05_cli/cli_ci_harness.py (local mock gateway + +# subprocess, retry by exit code, config isolation, secret masking) +# Recipes and reference: README.md (05_cli section) +# SDK-side offline debugging: 02_advanced_engineering/local_mock_debugging.py +# ------------------------------------------------------------- diff --git a/examples/README.md b/examples/README.md index 8e1f1141..5a456d34 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,6 +28,7 @@ examples/ ├── 02_advanced_engineering/ Engineering patterns: local mock debugging, semantic cache, circuit breaker + multi-region DR ├── 03_agent_recipes/ Agent recipes: tool-call loop, RAG, MCP server ├── 04_real_world_scenarios/ Flagship best practices: stitch the three tiers above into end-to-end production harnesses +├── 05_cli/ The `dashscope` command line: output formats, exit codes, configuration and CI recipes └── legacy_1x/ 1.x module-level API examples (dashscope.Generation etc.), for reference only ``` @@ -37,7 +38,8 @@ What the tiers mean (an advancement path from "quickref" to "production blueprin - **02 Advanced engineering**: wraps the SDK in the patterns real production cares about (saving money, resisting pressure, offline debugging). - **03 Agent recipes**: multi-step, composable end-to-end agent flows. - **04 Real-world scenarios**: not "how to call some capability", but a whole real business built end to end into a production-grade harness (with guardrails, context budgets, fault tolerance, disaster recovery, state machines and other production tradeoffs); each case ships its own `README.md` explaining "why it is written this way". -- **legacy_1x**: the old 1.x module-level style, subdirectory layout kept exactly as-is, for reference only; new projects should prefer the four tiers above. +- **05 Command line**: the same capabilities behind a shell/CI contract rather than a Python one — machine-readable output, layered exit codes, a fixed stderr error line and a documented configuration precedence; both files run offline. +- **legacy_1x**: the old 1.x module-level style, subdirectory layout kept exactly as-is, for reference only; new projects should prefer the five tiers above. ## Getting ready @@ -56,10 +58,10 @@ What the tiers mean (an advancement path from "quickref" to "production blueprin 3. Run any example file directly (from the repository root): ```bash - python3 examples/01_atomic_capabilities/text_generation/chat.py + python examples/01_atomic_capabilities/text_generation/chat.py ``` -> **Runs offline (no API key needed)**: `02_advanced_engineering/local_mock_debugging.py`, `02_advanced_engineering/circuit_breaker_retry.py`, `03_agent_recipes/mcp_server_demo/client_demo.py`, `04_real_world_scenarios/production_ready_rag_harness.py`, `04_real_world_scenarios/realtime_audio_meeting_harness.py` and `04_real_world_scenarios/ticket_triage_harness.py` use mock transports / scripted seams / subprocesses — fully offline and deterministic, ideal for a first hands-on look at the call shapes. +> **Runs offline (no API key needed)**: `02_advanced_engineering/local_mock_debugging.py`, `02_advanced_engineering/circuit_breaker_retry.py`, `03_agent_recipes/mcp_server_demo/client_demo.py`, `04_real_world_scenarios/production_ready_rag_harness.py`, `04_real_world_scenarios/realtime_audio_meeting_harness.py`, `04_real_world_scenarios/ticket_triage_harness.py`, `05_cli/cli_output_and_errors.py` and `05_cli/cli_ci_harness.py` use mock transports / a local mock gateway / scripted seams / subprocesses — fully offline and deterministic, ideal for a first hands-on look at the call shapes. ## Common environment variables @@ -81,6 +83,7 @@ What the tiers mean (an advancement path from "quickref" to "production blueprin | `RAG_HARNESS_LIVE` | Set to `1` for `production_ready_rag_harness.py` to hit the real gateway (otherwise offline mock) | unset (offline) | | `MEETING_HARNESS_LIVE` | Set to `1` for `realtime_audio_meeting_harness.py` to hit the real gateway (otherwise offline mock) | unset (offline) | | `TRIAGE_HARNESS_LIVE` | Set to `1` for `ticket_triage_harness.py` to hit the real gateway (otherwise scripted decision backend) | unset (offline) | +| `CLI_HARNESS_LIVE` | Set to `1` for `cli_ci_harness.py` to hit the real gateway, read-only scenarios only (otherwise a local mock gateway) | unset (offline) | | `DASHSCOPE_TINGWU_MODEL` | TingWu realtime model (`realtime_audio_meeting_harness.py`) | `tingwu-turbo` | | `MEETING_MAX_CONCURRENT` | Realtime meeting concurrency cap (i.e. the active-socket cap) | `4` | @@ -137,10 +140,514 @@ Composes `01`–`03` into complete, directly comparable business cases with prod See [`04_real_world_scenarios/README.md`](./04_real_world_scenarios/README.md). +## 05_cli — Command line + +The same capabilities behind a **shell** contract rather than a Python one: three output formats with a documented stdout/stderr split, layered exit codes (0 ok / 1 server / 2 usage / 3 auth / 4 not found), one fixed `Error [category]: message` line on stderr, and four configuration layers with a stated precedence. Both files run offline and deterministically — no key, no network. + +| Path | Contents | Network | +| --- | --- | --- | +| `cli_output_and_errors.py` | The contract from the inside: one fake client driven in-process through `typer.testing.CliRunner`, printing stdout / stderr / exit code side by side for table vs json vs yaml, NDJSON streaming, the `@file` / stdin / `--messages` input shapes, and every failure tier including the destructive-command refusal | No | +| `cli_ci_harness.py` | The contract from the outside: a stdlib mock gateway on an ephemeral port driving the real CLI in real subprocesses — parse `-o json`, branch and retry on exit codes, consume an NDJSON stream line by line, isolate configuration with `DASHSCOPE_CONFIG`, and pass `--yes` where CI has no terminal | No | + +Read the two examples in table order. The first shows *what* comes out of each channel; the second shows *how a pipeline should consume it*. + +### Install and first call + +The package registers two console scripts, so after `pip install -U dashscope` there are independent `dashscope` (2.0) and `dashscope1` (1.x) commands on your `PATH`: + +```bash +dashscope --help +dashscope version +dashscope1 --help +``` + +From a source checkout where the scripts are not installed, the module entry points are equivalent: + +```bash +python -m dashscope.cli --help +python -m dashscope.cli version +python -m dashscope.legacy_1x.cli --help +``` + +> Existing 1.x command forms use `dashscope1`, which calls the frozen 1.x CLI and SDK. The 2.0 `dashscope` entry point neither dispatches to that implementation nor accepts its old option spellings; a 1.x-only command exits 2 with a migration hint. See [`legacy_1x/`](./legacy_1x/) for the 1.x module-level API examples. + +Set the key once, either way: + +```bash +export DASHSCOPE_API_KEY="sk-xxx" +dashscope chat create -m qwen-plus --prompt "用一句话介绍杭州" +``` + +**Global options must come before the subcommand.** They belong to the app, not to `chat create`: + +```bash +dashscope -k sk-xxx --output json chat create -m qwen-plus --prompt "hi" # correct +dashscope chat create -m qwen-plus --prompt "hi" --output json # exit 2, usage +``` + +That last one is not a quirk — it is the single most common way to get a confusing failure out of this CLI, and `cli_output_and_errors.py` demonstrates it explicitly. + +### Command map + +| Command | Purpose | +| --- | --- | +| `dashscope version` | Print the installed SDK version. Touches nothing, so it is the cheapest smoke test you have | +| `dashscope auth login` / `whoami` / `logout` | Save, verify, or remove the API key in `~/.dashscope/api_key` | +| `dashscope chat create` | One chat completion: `--prompt` / `--messages`, `--stream`, `--tools`, sampling flags, `--parameters` for anything else | +| `dashscope ft create` | Start a fine-tune job; `--wait` blocks with a spinner until a terminal status, `--timeout` bounds the wait | +| `dashscope ft get` | Show one job | +| `dashscope ft wait` | Block on a job this process did not create — the second half of `create --wait`, for a pipeline that submits in one step and collects in the next. A failed or cancelled job exits 1 | +| `dashscope ft list` | Page through jobs: `--page`, `--size` | +| `dashscope ft stream` | Follow a job's server-side event feed until the server closes it | +| `dashscope ft logs` | Read a job's training logs: `--offset`, `--lines`. Table mode prints the log text verbatim — no table — so `> train.log` captures exactly what the trainer printed | +| `dashscope ft cancel` | Cancel a running job. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope ft delete` | Delete a job and its output model. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope files upload` | Upload a file for fine-tuning; returns the `file_id` that `ft create -t` consumes | +| `dashscope files list` | Page through uploaded files: `--page`, `--size` | +| `dashscope files get` | Show one file by id | +| `dashscope files delete` | Delete a file. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope models list` | Page through the model catalog: `--page`, `--size`, `--provider` | +| `dashscope models get` | Look up one model by name; exits 4 if it does not exist | +| `dashscope embeddings create` | Generate embeddings synchronously: `--input` (text or JSON array), `--text-type`, `--dimension` | +| `dashscope embeddings jobs create` | Submit an async batch embedding task over a file URL; `--wait` blocks until done | +| `dashscope embeddings jobs get` | Check the state of a batch task | +| `dashscope embeddings jobs wait` | Block on a batch task id from an earlier step instead of polling `jobs get` in a loop | +| `dashscope embeddings jobs list` | Page through batch tasks: `--page`, `--size`, `--status` | +| `dashscope embeddings jobs cancel` | Cancel a pending batch task. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope multimodal chat create` | `chat create` against the multimodal endpoint, for a conversation that has to hit it even without media parts | +| `dashscope multimodal embeddings create` | Embed text, image, audio and video into one vector space: repeatable `--text`/`--image`/`--audio`/`--video`, or `--input` JSON | +| `dashscope rerank create` | Rerank documents by relevance: `--query`, `--documents` (JSON array) or repeatable `--document` | +| `dashscope tokenization count` | Count tokens for a prompt or message list | +| `dashscope apps completions create` | Call a Bailian **application** by `--app-id`: `--prompt`/`--messages`, repeatable `--image`/`--file`, `--session-id`/`--memory-id`, `--rag-options`, `--biz-params`. `--stream` also asks for incremental frames, because the service otherwise resends the whole answer every time | +| `dashscope images generate` | Draw an image and wait for it; prints one URL per line so `$(...)` and `xargs wget` work | +| `dashscope images jobs create` | Submit an image task and return its id now; `--wait` blocks, `jobs get`/`jobs list` collect later | +| `dashscope images jobs get` / `list` / `wait` | Collect a task from an earlier step: read one back, page through them, or block until it settles and print its URLs the way `generate` does | +| `dashscope images jobs cancel` | Cancel a pending image task. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope videos generate` | Generate a video and wait for it; prints the video URL, so `$(...)` and `xargs wget` work. `--timeout` defaults to an hour | +| `dashscope videos jobs create` | Submit a video task and return its id now; `--wait` blocks, `jobs get`/`jobs list` collect later | +| `dashscope videos jobs get` / `list` / `wait` | Collect a task from an earlier step: read one back, page through them, or block until it settles and print the video URL the way `generate` does | +| `dashscope videos jobs cancel` | Cancel a pending video task. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope deployments create` | Provision capacity for a model: `--capacity`, plus `--deploy-spec`/`--billing-method` for MU deployments | +| `dashscope deployments get` / `list` | Inspect one deployment, or page through them | +| `dashscope deployments stream` | Follow a deployment's event feed until the server closes it | +| `dashscope deployments scale` | Resize a deployment. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope deployments delete` | Tear a deployment down. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope rl jobs create` | Run the whole agentic-RL workflow (register, upload, submit); `--set key=value` overrides one config key, `--wait` blocks | +| `dashscope rl jobs get` / `list` / `logs` | Inspect one RL job, page through them, or read its training logs | +| `dashscope rl jobs wait` | Block on an RL job id from an earlier step; a failed or cancelled job exits 1 | +| `dashscope rl jobs cancel` / `delete` | Stop or remove an RL job. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope rl functions register` | Upload rollout/reward code; returns the entity and instance ids | +| `dashscope rl functions test` | Run one registered instance against a sample `--input` payload | +| `dashscope rl functions upload-data` | Upload datasets; returns file ids for `rl jobs create` | +| `dashscope rl functions logs` | Read a function instance's logs; `--all` aggregates every page | +| `dashscope rl functions delete` | Delete a function instance. **Destructive** — prompts, needs `--yes` in CI | +| `dashscope audio tts synthesize` | Speak `TEXT` and write the audio: `--out speech.mp3`, or stdout when `--out` is omitted so `dashscope audio tts synthesize hi > hi.mp3` works. `--voice`, `--format`, `--sample-rate`, `--volume`, `--rate`, `--pitch` | +| `dashscope audio tts http` | The same request over the non-streaming endpoint, printing the `audio_url` instead of the bytes. `-m` and `--voice` are **required** here — the streaming endpoint's cosyvoice defaults do not apply | +| `dashscope audio asr transcribe` | Transcribe one or more audio/video URLs and **wait** for the result; `--no-wait` submits and returns the task id | +| `dashscope audio asr get` / `wait` | Read a transcription task now, or block on it with a spinner until it settles | +| `dashscope audio asr recognize` | Transcribe a **local file** through the realtime recognizer; table mode prints sentences, `-o json` emits one raw event per line | +| `dashscope audio realtime run` | One turn over the duplex Omni socket: feed a whole `--audio` file (path, `@path` or piped stdin), print the answer text and the input transcript, write the spoken reply to `--out`. `-o json` is an NDJSON trace of every event in the turn | +| `dashscope audio vocabularies create` / `list` / `get` / `update` / `delete` | Hot-word tables, consumed by `asr recognize --vocabulary-id`. `--vocabulary` is a JSON **array** of entries. `delete` is **destructive** | +| `dashscope audio phrases create` / `list` / `get` / `update` / `delete` | Compiled phrase sets, consumed by `asr transcribe --phrase-id`. `--phrases` is a JSON **object** of phrase lists. `delete` is **destructive** | +| `dashscope audio voices create` / `list` / `get` / `update` / `delete` | Voices cloned from a sample recording at `--url`. `delete` is **destructive** | +| `dashscope audio voice-design create` / `list` / `get` / `delete` | Voices described in words (`--voice-prompt` + `--preview-text`) instead of audio — the same registry, id namespace and quota as `voices`. There is no `update` because the service has none. `delete` is **destructive** | +| `dashscope tingwu call` | Run one offline TingWu task: `-m`, plus a business-defined payload either as `--input @task.json` or through the `--file-url` / `--app-id` pair, which win over the JSON. The realtime websocket is SDK-only | +| `dashscope decision create` | Answer **typed** questions about a `--state` (free text, a dialogue, or JSON): `--questions` declares `choice` / `noul` / `score` per id, and one row comes back per answer with its confidence. The model generates no text | +| `dashscope agentstudio agents create` / `get` / `list` / `versions` / `update` / `archive` | Manage a **managed agent**: `--name`, `-m`, `--system-prompt`, and `--tools` / `--mcp-servers` / `--skills` as JSON arrays. `versions` lists the numbers that `get --version` and `update --version` ask for. `update` requires `--version` — a mismatch is a 409. `archive` is **destructive** | +| `dashscope agentstudio sessions create` / `get` / `list` / `update` / `archive` / `delete` | Open a session on an agent (`--agent`, `--environment-id`, `--vault-ids`, `--env`, `--mcp-configs`) and inspect it. `update` patches one and keeps every field you omit; its `--agent-config` is a session-level agent override (`system` / `tools` / `mcp_servers` / `skills`), **not** an agent id. `archive` and `delete` are **destructive** | +| `dashscope agentstudio sessions events send` | Post one turn: `--message` is wrapped into a user message for you, `--events` sends a raw JSON array as-is and wins, `--thread-id` picks the thread | +| `dashscope agentstudio sessions events list` | Read a session's persisted events back: `--type` (repeatable), `--order`, `--limit`, `--page` | +| `dashscope agentstudio sessions events stream` | Follow the run until it settles: table mode prints the answer text, `--deltas message` opts into incremental frames, `-o json` is one event per line | +| `dashscope config set` | Write one key to `~/.dashscope/config.toml` | +| `dashscope config get` | Read one key back (api_key masked unless `--reveal`) | +| `dashscope config list` | Every key, its effective value, and the layer that supplied it | + +The two generations have independent entry points and implementations. Use `dashscope1` for every 1.x command, including legacy-only groups (`generation`, `application`, `image-synthesis`, `multimodal-conversation`, `speech-synthesis`, `transcription`, `video-synthesis`, `image-generation`, `multimodal-embedding`), pre-Typer dotted spellings, and shared group names with 1.x arguments. Use `dashscope` for the 2.0 commands above, which follow the SDK resource tree; application invocation is `apps completions create`, not a shortened `apps call`. A 1.x-only command under `dashscope` is rejected as an unknown command with a `dashscope1` migration hint. A bare `dashscope` with no subcommand opens the bundled `expert` agent interactively. + +What is *not* here is a decision rather than a backlog, and the gaps all have the same shape: a long-lived bidirectional socket, or a long tail of CRUD that a shell adds nothing to. `audio realtime run` drives **one turn** of the duplex Omni socket and hangs up; a live microphone — which stays open across turns and expects audio at realtime pace — is `client.audio.realtime.connect()` in Python, and `tingwu realtime` is the same story. `agentstudio` covers the loop you actually script (agents, sessions, session events); its other nine top-level resources (deployments and their runs, environments, files, skills, vaults, memory stores, webhook endpoints, security) plus session threads and session resources stay SDK-only. Everything else the SDK exposes has a command. + +### Output formats + +`-o table` (default) is for a human; `-o json` and `-o yaml` are for a machine. The split is deliberate and it is enforced in three ways: + +| Behaviour | table | json | yaml | +| --- | --- | --- | --- | +| Renderer | Rich, wrapped and coloured | `json.dumps(indent=2, ensure_ascii=False)` on stdout | `yaml.safe_dump(allow_unicode=True, sort_keys=False)` on stdout | +| Answer text | stdout | inside the document | inside the document | +| `reasoning_content`, `usage:`, pagination | **stderr only** | part of the document | part of the document | +| Streaming | incremental text on stdout | **NDJSON** | **NDJSON** (not a YAML document) | +| Transport `headers` | never | stripped, unless `-v` | stripped, unless `-v` | + +Two consequences worth internalising: + +1. In table mode `dashscope chat create ... > answer.txt` captures the answer and nothing else. Reasoning and token counts go to stderr, so a redirect never picks up decoration. +2. `--stream -o yaml` is a trap for the reader: it emits one **JSON** object per line, exactly like `--stream -o json`. Streaming output is line-oriented by nature, and a multi-document YAML stream would be hostile to `while read`. + +Pipe recipes: + +```bash +dashscope -o json chat create -m qwen-plus --prompt "hi" | jq -r '.choices[0].message.content' +dashscope -o json chat create -m qwen-plus --prompt "hi" | jq -r '.usage.total_tokens' +dashscope -o json ft list --size 50 | jq -r '.jobs[] | "\(.job_id)\t\(.status)"' +dashscope -o json chat create -m qwen-plus --prompt "hi" --stream \ + | jq -j '.choices[0].delta.content // empty' +``` + +Add `-v` / `--verbose` when you need the raw HTTP response headers (for a support ticket) and tracebacks on failure. Leave it off in a pipeline: `headers` is transport metadata, not part of the result. + +### Exit codes and error format + +The exit code is the contract. Do not grep the message. + +| Exit | Meaning | Retryable? | +| --- | --- | --- | +| `0` | Success | — | +| `1` | `server`, `ratelimit`, `network` — the service or the link failed | Yes, with backoff. For `ratelimit`, honour `Retry-After` | +| `2` | `usage` — your command line, your input file, or a missing `--yes` | No. Fix the invocation | +| `3` | `auth` — credentials rejected | No. Fix the key | +| `4` | `notfound` — the id does not exist | No. Fix the id | + +Every failure prints one line on stderr in a fixed shape, then optional dim context: + +```text +Error [ratelimit]: Rate limit exceeded. + request_id: 7574ee8f-38a3-4b1e-9280-11c33ab46e51 + solution: Reduce your request frequency. +``` + +`Error [category]: message` is stable; the two dim lines appear only when there is something to say. `request_id` is what a support ticket needs, and `solution` is the registry's own guidance — kept on a separate line so you can always tell which words came from the gateway. + +How an HTTP status becomes a category (`cli/errors.py`, `_STATUS_CATEGORY`): + +| Status | Category | Exit | +| --- | --- | --- | +| 400, 413 | `usage` | 2 | +| 401, 403 | `auth` | 3 | +| 404 | `notfound` | 4 | +| 429 | `ratelimit` | 1 | +| 500, 503, 504 | `server` | 1 | + +SDK-side failures have no HTTP status, so they are categorised by their catalog code instead: `sdk.ConnectionError` and `sdk.Timeout` → `network` (1), `sdk.InvalidApiKey` → `auth` (3), `sdk.IoError` → `usage` (2, because "the file you named is not readable" is your mistake). Anything unattributable lands in `server`. + +The retry wrapper in a shell: + +```bash +attempt=1 +until dashscope -o json chat create -m qwen-plus --prompt "hi" > out.json; do + code=$? + case $code in + 1) [ $attempt -ge 5 ] && exit 1 + sleep $(( attempt * 2 )); attempt=$(( attempt + 1 )) ;; + *) exit $code ;; # 2 / 3 / 4 will not fix themselves + esac +done +``` + +`cli_ci_harness.py` scenario 2 is the same logic in Python, with a mock gateway that throttles the first attempt and serves the second so you can watch it recover. + +### Configuration + +Precedence, highest first: + +```text +command-line flag > -c KEY=VALUE > environment variable > config file > built-in default +``` + +So `-k sk-a -c api_key=sk-b` uses `sk-a`: the dedicated flag is the more specific spelling and always wins. + +The file is `~/.dashscope/config.toml`. Redirect it with `DASHSCOPE_CONFIG`, which is how you give one CI job its own configuration without touching anyone's home directory: + +```bash +DASHSCOPE_CONFIG=/tmp/job-42/config.toml dashscope config set default_model qwen-plus +``` + +Top-level keys are SDK keys and are passed straight to `Client(...)`: `api_key`, `protocol`, `base_url`, `workspace`, `region`, `timeout`, `max_retries`, `http_max_connections`, `http_max_keepalive_connections`, `http_keepalive_expiry`. The `[cli]` table holds exactly two CLI-only defaults: `output` and `default_model`. Write them as `cli.output` / `cli.default_model`, or plainly — `config set output json` knows where they go. + +```toml +api_key = "sk-xxx" +protocol = "native" + +[cli] +output = "json" +default_model = "qwen-plus" +``` + +Three details that matter in practice: + +- `config set api_key` **never echoes the value**, and the file is chmod `0600` the moment a secret lands in it. A terminal recording or a build log cannot capture what was just typed. +- `config get api_key` masks: first four characters, stars, last two (`sk-l*******mo`). `--reveal` prints it whole, for the one time you genuinely need to check. +- `config get` reads **the file alone**; `config list` reports the **effective** value plus the layer that won. They can disagree — that disagreement *is* the precedence lesson, and `cli_ci_harness.py` scenario 4 narrates it rather than letting it look like a bug. + +`-c` is validated immediately, on every command, including ones that never touch the network: + +```bash +dashscope -c defualt_model=qwen-max version +# Error [usage]: unknown configuration key 'defualt_model'; known keys: api_key, base_url, ... +# exit 2 +``` + +That is deliberate. A typo in a pipeline should surface on the cheapest command there is, not three stages later as a mysteriously wrong model. + +### Input conventions + +Every text option accepts three forms: + +| Form | Meaning | +| --- | --- | +| `--prompt "text"` | Literal value | +| `--prompt @path/to/file.txt` | Read the file (`~` expanded). The `@` form is what keeps a long payload or a tool schema out of your shell history and your `ps` output | +| `--prompt -` | Read stdin to EOF | + +With neither flag nor `@file`, a **piped** stdin is picked up automatically, so this works: + +```bash +cat question.txt | dashscope chat create -m qwen-plus +``` + +Source precedence for the conversation: `--messages` > `--prompt` (+ `--system`) > piped stdin. If none of the three yields anything, you get `exit 2` with `no input given: pass --prompt, --messages, or pipe text on stdin`. + +`--messages` accepts three shapes and figures out which one you meant: + +```bash +# a JSON array of message objects +dashscope chat create -m qwen-plus \ + --messages '[{"role":"user","content":"你好"},{"role":"assistant","content":"你好!"},{"role":"user","content":"继续"}]' + +# JSONL -- one message object per line, easiest to build with a loop +dashscope chat create -m qwen-plus --messages @turns.jsonl + +# an object with a messages key, i.e. a captured request body replayed as-is +dashscope chat create -m qwen-plus --messages @last_request.json +``` + +Bad JSON is reported with a position, not a shrug: `--messages: invalid JSON (line 1 column 23)`. + +`--tools` and `--parameters` are JSON too and accept `@file`. `--tools` must be a **JSON array**; handing it an object is a `usage` error (exit 2). Local image and video paths inside a message are passed through to the SDK unchanged, which uploads them itself — you do not need to pre-upload. + +### Recipes + +Copy-pasteable. Everything marked 🔑 needs a real `DASHSCOPE_API_KEY`; the rest runs against nothing at all. + +**One-shot answer into a variable** 🔑 + +```bash +answer=$(dashscope -o json chat create -m qwen-plus --prompt "用一句话介绍杭州" \ + | jq -r '.choices[0].message.content') +``` + +**Stream to the terminal as it arrives** 🔑 + +```bash +dashscope chat create -m qwen-plus --prompt "写一首关于春天的短诗" --stream +``` + +**A long prompt from a file, never from argv** 🔑 + +```bash +dashscope chat create -m qwen-plus --prompt @brief.md --system "你是资深编辑" +``` + +**Tools from a file** 🔑 + +```bash +dashscope chat create -m qwen-plus --prompt @question.txt --tools @tools.json -o json +``` + +**Multi-turn from JSONL built in the loop** 🔑 + +```bash +: > turns.jsonl +while read -r line; do + printf '{"role":"user","content":"%s"}\n' "$line" >> turns.jsonl +done < questions.txt +dashscope chat create -m qwen-plus --messages @turns.jsonl +``` + +**Retry only what can succeed later** 🔑 — see the shell wrapper in the exit-code section above. + +**Fine-tune and block until it settles** 🔑 + +```bash +dashscope ft create -m qwen-plus -t "$FILE_ID" --n-epochs 2 --wait --timeout 1800 +``` + +`ft create -m` **does not** fall back to `default_model`. That is intentional: a chat default is not a fine-tune base, and silently fine-tuning the wrong model is worse than an error. + +**Follow a job's events** 🔑 + +```bash +dashscope ft stream "$JOB_ID" +``` + +**Capture a job's training log** 🔑 + +```bash +dashscope ft logs "$JOB_ID" --offset 1 --lines 500 > train.log +``` + +**Collect a generated video, then download it** 🔑 + +```bash +url=$(dashscope videos generate -m wan2.2-t2v-plus --prompt "海浪拍打礁石" --timeout 1800) +curl -o clip.mp4 "$url" +``` + +Submit-only, for a batch you collect later: + +```bash +dashscope -o json videos jobs create -m wan2.2-t2v-plus --prompt @brief.md \ + | jq -r '.task_id' >> task_ids.txt +while read -r id; do dashscope videos jobs get "$id"; done < task_ids.txt +``` + +**Submit in one CI step, block in the next** 🔑 + +```bash +# step 1 -- submit and keep the id +dashscope -o json images jobs create -m wan2.2-t2i-flash --prompt @brief.md \ + | jq -r '.task_id' > task_id.txt + +# step 2 -- possibly another runner, possibly an hour later +dashscope images jobs wait "$(cat task_id.txt)" --timeout 1800 +``` + +`wait` takes an **id**, not a job object, so nothing has to survive between the two steps but that string. It exits 1 on a task that failed or was cancelled, and 2 on a `--timeout` it could never honour — `0` and `nan` included, because `nan` is false against every comparison and the poll would spin forever. The same command exists for `ft wait`, `videos jobs wait`, `embeddings jobs wait`, `rl jobs wait` and `audio asr wait`. + +**Speech to a file, or straight into a pipe** 🔑 + +```bash +dashscope audio tts synthesize "你好,世界" --out hello.mp3 +dashscope audio tts synthesize @script.txt --voice longxiaochun | ffplay -i - -autoexit -nodisp +``` + +Omit `--out` and the raw bytes go to stdout. With `--out`, the file gets the audio and a `wrote N bytes to ...` note goes to stderr — so neither form pollutes a redirect. + +**Transcribe a recording and block for the result** 🔑 + +```bash +dashscope audio asr transcribe "https://example.com/meeting.mp3" --timeout 1800 +``` + +**One turn over the realtime socket** 🔑 + +```bash +dashscope audio realtime run -m qwen3.8-omni-flash-realtime \ + --audio question.pcm --out reply.wav +``` + +The answer text goes to stdout, the input transcript and a `wrote N bytes` note go to stderr, the spoken reply goes to `--out`. Omit `--out` and the reply audio is **dropped** — the command will not spray binary into your terminal just because you forgot a flag. `-o json` turns the whole thing into an NDJSON trace of every event in the turn, which is what you want when the turn does not behave. + +**Hot words, then recognize with them** 🔑 + +```bash +vid=$(dashscope -o json audio vocabularies create \ + --prefix demo --target-model paraformer-realtime-v2 \ + --vocabulary @hotwords.json \ + | jq -r '.vocabulary_id') +dashscope audio asr recognize meeting.pcm -m paraformer-realtime-v2 --vocabulary-id "$vid" +``` + +Note where `-o json` sits: before `audio`, because it is a global option. Inside this group the short form is already taken — `tts synthesize` and `realtime run` both spell `--out` as `-o`. + +**Call a Bailian application, keeping the session** 🔑 + +```bash +dashscope -o json apps completions create --app-id "$APP_ID" --prompt "帮我查一下订单" \ + | jq -r '.session_id' > session.txt +dashscope apps completions create --app-id "$APP_ID" \ + --prompt "那第二个呢" --session-id "$(cat session.txt)" +``` + +**Drive a managed agent from a shell** 🔑 + +```bash +sid=$(dashscope -o json agentstudio sessions create --agent "$AGENT_ID" \ + | jq -r '.id') +dashscope agentstudio sessions events send "$sid" --message "帮我查一下订单" +dashscope agentstudio sessions events stream "$sid" --deltas message +``` + +`events send` posts the turn, `events stream` follows the run until it settles. They are separate commands, not one, because a pipeline usually wants to post N turns and then collect — and because a stream that broke mid-run should exit non-zero without having swallowed the send. + +**Answer typed questions about a ticket** 🔑 + +```bash +dashscope decision create --state @ticket.txt --questions @questions.json +``` + +`--questions` declares a `type` per id — `choice` names an option, `noul` is a yes/no probability, `score` is a weighted level index. One row comes back per answer with its confidence, and `-o json` gives you `.answers..choice` to branch on. The model generates no prose, so there is nothing to parse out of a sentence. `-m` defaults to `decision-model-preview` and does **not** read `default_model`: this is a decision model, not a chat model. + +**CI: destructive commands need `--yes`** 🔑 + +```bash +dashscope ft delete "$JOB_ID" --yes +``` + +Without `--yes` and without a terminal, it refuses at exit 2 rather than guessing. + +**Per-job configuration isolation** 🔑 + +```bash +export DASHSCOPE_CONFIG="$CI_JOB_DIR/config.toml" +dashscope config set default_model qwen-plus +dashscope config set api_key "$DASHSCOPE_API_KEY" +dashscope chat create --prompt "hi" # -m omitted: comes from default_model +``` + +**Clean logs** — no colour: + +```bash +export NO_COLOR=1 +``` + +`NO_COLOR=1` and `--no-color` mean the same thing. Rich already detects a pipe, so this matters mainly for runners that allocate a pseudo-terminal and then dump it into a log. + +### Run the examples + +Both run offline and deterministically; neither needs a key. From the repository root, with the checkout importable: + +```bash +PYTHONPATH=. python examples/05_cli/cli_output_and_errors.py +PYTHONPATH=. python examples/05_cli/cli_ci_harness.py +``` + +(`PYTHONPATH=.` is only needed when the package is not pip-installed. If it is, `python examples/05_cli/cli_output_and_errors.py` is enough. The harness sets it for its own child processes, so it works either way.) + +Both print a summary at the end and exit `0` only if every check passed — `cli_output_and_errors.py` asserts the exit code of every command it runs, and `cli_ci_harness.py` asserts all seven scenarios. Neither one reads or writes your real `~/.dashscope/config.toml`: the harness points `DASHSCOPE_CONFIG` at a temporary directory that is removed on exit. + +To run the harness's read-only scenarios against the real gateway: + +```bash +CLI_HARNESS_LIVE=1 DASHSCOPE_API_KEY=sk-xxx python examples/05_cli/cli_ci_harness.py +``` + +Live mode skips every fault-injection scenario and **never** runs `ft cancel` or `ft delete`. It will run `ft stream` only if you also set `DASHSCOPE_JOB_ID` to a job you own — guessing an id against a real account is how a demo deletes something important. + +### Common pitfalls + +1. **Global options cannot go after the subcommand.** `dashscope chat create --prompt "hi" -o json` is exit 2, not a formatted answer. Everything in the `--help` block at the top level is positional-relative to the app. +2. **`--stream -o yaml` is NDJSON, not YAML.** One JSON object per line, same as `-o json`. A YAML parser will not read it. +3. **Destructive commands refuse in CI.** No terminal + no `--yes` = exit 2. Your pipeline needs `--yes` on every `cancel`, `delete`, `scale` and `archive` — `ft`, `files`, `embeddings jobs`, `images jobs`, `videos jobs`, `deployments`, `rl`, `audio vocabularies`/`phrases`/`voices`/`voice-design`, `agentstudio agents`/`sessions` — and it needs it explicitly, because the whole point is that the command will not decide for you. +4. **`ft create -m` never falls back to `default_model`.** Only `chat create` and `multimodal chat create` do. The source comment says why: a chat default is not a fine-tune base. +5. **`config get` and `config list` can disagree.** `get` reads the file; `list` resolves the layers. When they differ, the `source` column in `list` is telling you which layer won — usually an environment variable you forgot you exported. +6. **In table mode the useful metadata is on stderr.** `usage:`, reasoning text and `page x/y, total n` are deliberately not on stdout, so a redirect stays clean. If you want them as data, ask for `-o json` instead of capturing stderr. +7. **`audio tts synthesize` writes raw bytes to stdout unless you pass `--out`.** That is what makes `> hello.mp3` work, and it is also what makes a forgotten `--out` spray binary into your terminal. There is no table for this command; `-o json` changes nothing about where the audio goes. +8. **`apps completions create --stream` also switches the service to incremental frames.** Without that, every event carries the whole answer so far and the terminal repeats the same growing sentence. Pass `--no-incremental-output` if you genuinely want the cumulative form. +9. **`multimodal chat create` exists even though `chat create` already routes media.** Reach for it when the conversation has to hit the multimodal endpoint without any media parts in it — the two commands take the same flags and render the same way. +10. **Inside `audio`, `-o` means `--out`, not `--output`.** `dashscope audio tts synthesize hi -o json` writes a file called `json` and exits 0. The format flag is global, so it goes before the group: `dashscope -o json audio ...`. This is the one place the CLI reuses a short option with a different meaning, and it is deliberate — `--out` is the option a shell recipe actually reaches for in this group. +11. **The `audio` registries do not paginate the same way.** `vocabularies`, `voices` and `voice-design` take a **0-based** `--page-index` plus `--page-size`; `phrases` is fine-tune-shaped and takes a **1-based** `--page` plus `--size`. That is not an oversight in the CLI — the two services behind them disagree, and the commands spell each one the way its own service does rather than inventing a translation layer that would silently drop a page. +12. **`agentstudio agents update` will not run without `--version`.** The service does optimistic concurrency on the agent's current version; exit 2 before any call if you left it out. A stale version is a 409, which the CLI has no specific tier for and therefore reports as `Error [server]` at exit 1 — retryable, but the retry that works is "re-read the agent, take its new `version`, update again", not "sleep and resend the same thing". +13. **`agentstudio sessions events stream` prints whole messages unless you ask for deltas.** Table mode reads the stream's `text_stream`, which a reconnect can rebuild; `--deltas message` (repeatable, also `reasoning`) opts into incremental frames and switches the command to `text_deltas`. `-o json` is unaffected — it is one raw event per line either way. +14. **`--timeout 0` is an error, not "check once and return".** Every `wait` command and every `--wait` flag rejects a timeout that is not a finite positive number of seconds — exit 2, before any API call, so a pipeline with no key configured still gets the usage error rather than an auth one. `0`, `-1`, `nan` and `inf` are all refused. `nan` is the one that matters: it is false against every comparison, so the SDK's deadline check never fires and the poll spins forever instead of timing out. If you want the current state without blocking, that is `get`. + +For the SDK-side equivalent of the mock gateway, see [`02_advanced_engineering/local_mock_debugging.py`](./02_advanced_engineering/local_mock_debugging.py). + ## legacy_1x — 1.x module-level API (reference only) The following use the old 1.x style (`dashscope.Generation`, `dashscope.MultiModalConversation`, etc.). The internal subdirectory layout is kept exactly as-is and the content is untouched. New projects should use the 2.0 tiered examples above. +**1.x CLI:** existing 1.x command forms remain available through the same `dashscope` entry point as the 2.0 commands documented in [`05_cli/`](./05_cli/). Legacy-only names run against the frozen 1.x implementation; shared names are served by 2.0 with compatible option spellings. + | Path | Contents | | --- | --- | | `generation/` | Generation text generation: basics, deep thinking, tool calls, deep research, custom session | diff --git a/examples/README_zh.md b/examples/README_zh.md index 44ddb9fd..57f93fab 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -28,6 +28,7 @@ examples/ ├── 02_advanced_engineering/ 工程模式:本地 mock 调试、语义缓存、熔断 + 多地域容灾 ├── 03_agent_recipes/ Agent 配方:工具调用循环、RAG、MCP server ├── 04_real_world_scenarios/ 旗舰最佳实践:把上面三层组装成端到端生产 harness +├── 05_cli/ `dashscope` 命令行:输出格式、退出码、配置与 CI 配方 └── legacy_1x/ 1.x 模块级 API 示例(dashscope.Generation 等),仅供参考 ``` @@ -37,7 +38,8 @@ examples/ - **02 进阶工程**:把 SDK 包进真实生产关心的模式里(省钱、抗压、离线调试)。 - **03 Agent 配方**:多步骤、可组合的端到端 agent 流程。 - **04 真实场景**:不是"怎么调某个能力",而是把一个完整业务端到端建成生产级 harness(护栏、上下文预算、容错、容灾、状态机等生产权衡);每个案例配有自己的 `README.md`,讲清"为什么这样写"。 -- **legacy_1x**:旧的 1.x 模块级风格,子目录布局原样保留,仅供参考;新项目请优先使用上面四层。 +- **05 命令行**:同一批能力的 shell/CI 契约面,而不是 Python 契约面——机器可读输出、分层退出码、固定的一行 stderr 错误,以及写明的配置优先级;两个文件都离线可跑。 +- **legacy_1x**:旧的 1.x 模块级风格,子目录布局原样保留,仅供参考;新项目请优先使用上面五层。 ## 准备工作 @@ -56,10 +58,10 @@ examples/ 3. 直接运行任意示例文件(从仓库根目录): ```bash - python3 examples/01_atomic_capabilities/text_generation/chat.py + python examples/01_atomic_capabilities/text_generation/chat.py ``` -> **离线运行(无需 API key)**:`02_advanced_engineering/local_mock_debugging.py`、`02_advanced_engineering/circuit_breaker_retry.py`、`03_agent_recipes/mcp_server_demo/client_demo.py`、`04_real_world_scenarios/production_ready_rag_harness.py`、`04_real_world_scenarios/realtime_audio_meeting_harness.py` 和 `04_real_world_scenarios/ticket_triage_harness.py` 使用 mock transport / 脚本化接缝 / 子进程——完全离线且输出确定,适合先动手感受调用形态。 +> **离线运行(无需 API key)**:`02_advanced_engineering/local_mock_debugging.py`、`02_advanced_engineering/circuit_breaker_retry.py`、`03_agent_recipes/mcp_server_demo/client_demo.py`、`04_real_world_scenarios/production_ready_rag_harness.py`、`04_real_world_scenarios/realtime_audio_meeting_harness.py`、`04_real_world_scenarios/ticket_triage_harness.py`、`05_cli/cli_output_and_errors.py` 和 `05_cli/cli_ci_harness.py` 使用 mock transport / 本地 mock 网关 / 脚本化接缝 / 子进程——完全离线且输出确定,适合先动手感受调用形态。 ## 常用环境变量 @@ -81,6 +83,7 @@ examples/ | `RAG_HARNESS_LIVE` | 置 `1` 时 `production_ready_rag_harness.py` 打真实网关(否则离线 mock) | 未设置(离线) | | `MEETING_HARNESS_LIVE` | 置 `1` 时 `realtime_audio_meeting_harness.py` 打真实网关(否则离线 mock) | 未设置(离线) | | `TRIAGE_HARNESS_LIVE` | 置 `1` 时 `ticket_triage_harness.py` 打真实网关(否则脚本化决策后端) | 未设置(离线) | +| `CLI_HARNESS_LIVE` | 置 `1` 时 `cli_ci_harness.py` 打真实网关,仅只读场景(否则本地 mock 网关) | 未设置(离线) | | `DASHSCOPE_TINGWU_MODEL` | 听悟 realtime 模型(`realtime_audio_meeting_harness.py`) | `tingwu-turbo` | | `MEETING_MAX_CONCURRENT` | 实时会议并发上限(即活跃 socket 上限) | `4` | @@ -137,10 +140,514 @@ examples/ 详见 [`04_real_world_scenarios/README.md`](./04_real_world_scenarios/README.md)。 +## 05_cli —— 命令行 + +同一批能力,只是契约面从 Python 换成了 **shell**:三档输出格式并有明确的 stdout/stderr 分工、分层退出码(0 成功 / 1 服务端 / 2 用法 / 3 鉴权 / 4 不存在)、stderr 上一行固定的 `Error [category]: message`,以及四层配置和写明的优先级。两个文件都离线、确定性运行——不需要密钥,不联网。 + +| 路径 | 内容 | 联网 | +| --- | --- | --- | +| `cli_output_and_errors.py` | 从内部看契约:一个假 client 通过 `typer.testing.CliRunner` 在进程内驱动,把 table / json / yaml 三档、NDJSON 流式、`@file` / stdin / `--messages` 输入形态,以及包括破坏性命令拒绝在内的每一档失败的 stdout / stderr / 退出码并排打印 | 否 | +| `cli_ci_harness.py` | 从外部看契约:在临时端口用标准库起一个 mock 网关,在真实子进程里驱动真实 CLI——解析 `-o json`、按退出码分支与重试、逐行消费 NDJSON 流、用 `DASHSCOPE_CONFIG` 隔离配置,并在 CI 没有终端时传 `--yes` | 否 | + +建议按表格顺序读两个示例。第一个演示每个通道**吐出什么**,第二个演示**流水线该怎么消费它**。 + +### 安装与第一次调用 + +包里注册了两个 console script,所以 `pip install -U dashscope` 之后 `PATH` 上会有相互独立的 `dashscope`(2.0)和 `dashscope1`(1.x)命令: + +```bash +dashscope --help +dashscope version +dashscope1 --help +``` + +在没装 console script 的源码检出里,模块入口完全等价: + +```bash +python -m dashscope.cli --help +python -m dashscope.cli version +python -m dashscope.legacy_1x.cli --help +``` + +> 现有 1.x 命令形式使用 `dashscope1`,由冻结的 1.x CLI 调用 1.x SDK。2.0 的 `dashscope` 入口不会向该实现分发命令,也不接受旧参数拼写;1.x 专有命令会以退出码 2 返回迁移提示。1.x 模块级 API 示例见 [`legacy_1x/`](./legacy_1x/)。 + +密钥配一次,两种方式都行: + +```bash +export DASHSCOPE_API_KEY="sk-xxx" +dashscope chat create -m qwen-plus --prompt "用一句话介绍杭州" +``` + +**全局选项必须放在子命令前面。**它们属于 app,不属于 `chat create`: + +```bash +dashscope -k sk-xxx --output json chat create -m qwen-plus --prompt "hi" # 正确 +dashscope chat create -m qwen-plus --prompt "hi" --output json # exit 2,usage +``` + +最后这条不是小怪癖——它是这个 CLI 最容易让人一头雾水的失败方式,`cli_output_and_errors.py` 专门演示了它。 + +### 命令地图 + +| 命令 | 用途 | +| --- | --- | +| `dashscope version` | 打印已安装的 SDK 版本。什么都不碰,所以是你手上最便宜的冒烟测试 | +| `dashscope auth login` / `whoami` / `logout` | 保存、验证或删除 `~/.dashscope/api_key` 中的 API Key | +| `dashscope chat create` | 一次 chat 补全:`--prompt` / `--messages`、`--stream`、`--tools`、采样参数,其余走 `--parameters` | +| `dashscope ft create` | 起一个微调作业;`--wait` 带 spinner 阻塞到终态,`--timeout` 限定等待时长 | +| `dashscope ft get` | 查看单个作业 | +| `dashscope ft wait` | 阻塞等待一个不是本进程创建的作业——`create --wait` 的后半段,适用于「一步提交、另一步收结果」的流水线。作业失败或被取消时退出码为 1 | +| `dashscope ft list` | 分页列出作业:`--page`、`--size` | +| `dashscope ft stream` | 跟随服务端事件流,直到服务端关闭它 | +| `dashscope ft logs` | 读作业的训练日志:`--offset`、`--lines`。table 模式原样打印日志文本——不画表——所以 `> train.log` 拿到的就是训练器打出来的东西 | +| `dashscope ft cancel` | 取消运行中的作业。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope ft delete` | 删除作业及其产出模型。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope files upload` | 上传微调用的文件;返回 `ft create -t` 需要的 `file_id` | +| `dashscope files list` | 分页列出已上传文件:`--page`、`--size` | +| `dashscope files get` | 按 id 查看单个文件 | +| `dashscope files delete` | 删除文件。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope models list` | 分页浏览模型目录:`--page`、`--size`、`--provider` | +| `dashscope models get` | 按名称查找模型;不存在时 exit 4 | +| `dashscope embeddings create` | 同步生成向量:`--input`(文本或 JSON 数组)、`--text-type`、`--dimension` | +| `dashscope embeddings jobs create` | 提交异步批量向量任务(文件 URL);`--wait` 阻塞到完成 | +| `dashscope embeddings jobs get` | 查看批量任务状态 | +| `dashscope embeddings jobs wait` | 阻塞等待上一步拿到的批量任务 id,而不是循环轮询 `jobs get` | +| `dashscope embeddings jobs list` | 分页列出批量任务:`--page`、`--size`、`--status` | +| `dashscope embeddings jobs cancel` | 取消 pending 的批量任务。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope multimodal chat create` | 打到多模态端点的 `chat create`,用于那些即使不带媒体分片也必须走这个端点的会话 | +| `dashscope multimodal embeddings create` | 把文本、图片、音频、视频嵌到同一个向量空间:可重复的 `--text`/`--image`/`--audio`/`--video`,或 `--input` JSON | +| `dashscope rerank create` | 文档重排:`--query`、`--documents`(JSON 数组)或可重复的 `--document` | +| `dashscope tokenization count` | 计算 prompt 或 messages 的 token 数 | +| `dashscope apps completions create` | 按 `--app-id` 调用百炼**应用**:`--prompt`/`--messages`、可重复的 `--image`/`--file`、`--session-id`/`--memory-id`、`--rag-options`、`--biz-params`。带 `--stream` 时会同时要求增量帧,因为服务端默认每次重发整段答案 | +| `dashscope images generate` | 生成图片并等到出图;一行一个 URL,所以 `$(...)` 和 `xargs wget` 直接能用 | +| `dashscope images jobs create` | 只提交图片任务、立刻拿到 task_id;`--wait` 阻塞,或之后用 `jobs get`/`jobs list` 取结果 | +| `dashscope images jobs get` / `list` / `wait` | 收取上一步提交的任务:查看单个、分页列出,或阻塞到它结束并像 `generate` 一样打印图片 URL | +| `dashscope images jobs cancel` | 取消 pending 的图片任务。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope videos generate` | 生成视频并等到出片;打印视频 URL,所以 `$(...)` 和 `xargs wget` 直接能用。`--timeout` 默认一小时 | +| `dashscope videos jobs create` | 只提交视频任务、立刻拿到 task_id;`--wait` 阻塞,或之后用 `jobs get`/`jobs list` 取结果 | +| `dashscope videos jobs get` / `list` / `wait` | 收取上一步提交的任务:查看单个、分页列出,或阻塞到它结束并像 `generate` 一样打印视频 URL | +| `dashscope videos jobs cancel` | 取消 pending 的视频任务。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope deployments create` | 为模型开通独享容量:`--capacity`,MU 部署还要 `--deploy-spec`/`--billing-method` | +| `dashscope deployments get` / `list` | 查看单个部署,或分页列出 | +| `dashscope deployments stream` | 跟随部署的服务端事件流,直到服务端关闭它 | +| `dashscope deployments scale` | 调整部署容量。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope deployments delete` | 下线部署。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope rl jobs create` | 跑完整的 agentic-RL 流程(注册函数、上传数据、提交);`--set key=value` 覆盖单个配置项,`--wait` 阻塞 | +| `dashscope rl jobs get` / `list` / `logs` | 查看单个 RL 作业、分页列出,或读它的训练日志 | +| `dashscope rl jobs wait` | 阻塞等待上一步拿到的 RL 作业 id;作业失败或被取消时退出码为 1 | +| `dashscope rl jobs cancel` / `delete` | 取消或删除 RL 作业。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope rl functions register` | 上传 rollout/reward 代码;返回 entity id 和 instance id | +| `dashscope rl functions test` | 用一份样例 `--input` 跑一个已注册的函数实例 | +| `dashscope rl functions upload-data` | 上传数据集;返回 `rl jobs create` 要用的 file id | +| `dashscope rl functions logs` | 读函数实例的日志;`--all` 聚合所有分页 | +| `dashscope rl functions delete` | 删除函数实例。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope audio tts synthesize` | 把 `TEXT` 合成语音并写出音频:`--out speech.mp3`;不给 `--out` 就写 stdout,所以 `dashscope audio tts synthesize hi > hi.mp3` 能用。`--voice`、`--format`、`--sample-rate`、`--volume`、`--rate`、`--pitch` | +| `dashscope audio tts http` | 走非流式端点的同一个请求,打印 `audio_url` 而不是音频字节。这里 `-m` 和 `--voice` 是**必填**的——流式端点那套 cosyvoice 默认值不适用 | +| `dashscope audio asr transcribe` | 转写一个或多个音视频 URL 并**等待**结果;`--no-wait` 只提交、返回 task_id | +| `dashscope audio asr get` / `wait` | 立刻读一个转写任务,或带 spinner 阻塞到它落定 | +| `dashscope audio asr recognize` | 走实时识别器转写**本地文件**;table 模式只打句子,`-o json` 一行一个原始事件 | +| `dashscope audio realtime run` | 在双工 Omni socket 上跑**一轮**:喂进一整个 `--audio` 文件(路径、`@路径`,或管道 stdin),打印答案文本和输入转写,把语音回复写进 `--out`。`-o json` 是这一轮里每个事件的 NDJSON 轨迹 | +| `dashscope audio vocabularies create` / `list` / `get` / `update` / `delete` | 热词表,由 `asr recognize --vocabulary-id` 消费。`--vocabulary` 是词条的 JSON **数组**。`delete` 是**破坏性**的 | +| `dashscope audio phrases create` / `list` / `get` / `update` / `delete` | 编译好的短语集,由 `asr transcribe --phrase-id` 消费。`--phrases` 是短语列表的 JSON **对象**。`delete` 是**破坏性**的 | +| `dashscope audio voices create` / `list` / `get` / `update` / `delete` | 从 `--url` 上的一段样本录音克隆出来的音色。`delete` 是**破坏性**的 | +| `dashscope audio voice-design create` / `list` / `get` / `delete` | 用文字描述(`--voice-prompt` + `--preview-text`)而不是录音造出来的音色——和 `voices` 共用同一个注册表、同一套 id 命名空间和同一份配额。没有 `update`,因为服务端就没有。`delete` 是**破坏性**的 | +| `dashscope tingwu call` | 跑一个离线 TingWu 任务:`-m`,加上一份由业务定义的载荷,要么写成 `--input @task.json`,要么走 `--file-url` / `--app-id` 这一对——它们的优先级高于 JSON。实时 websocket 只有 SDK | +| `dashscope decision create` | 针对一份 `--state`(自由文本、一段对话,或 JSON)回答**带类型**的问题:`--questions` 为每个 id 声明 `choice` / `noul` / `score`,每个答案回一行并带上它的置信度。模型不生成文本 | +| `dashscope agentstudio agents create` / `get` / `list` / `versions` / `update` / `archive` | 管理一个**托管 agent**:`--name`、`-m`、`--system-prompt`,以及作为 JSON 数组的 `--tools` / `--mcp-servers` / `--skills`。`versions` 列出 `get --version` 和 `update --version` 要填的版本号。`update` 必须给 `--version`——版本对不上就是 409。`archive` 是**破坏性**的 | +| `dashscope agentstudio sessions create` / `get` / `list` / `update` / `archive` / `delete` | 在某个 agent 上开一个会话(`--agent`、`--environment-id`、`--vault-ids`、`--env`、`--mcp-configs`)并查看它。`update` 打补丁,没给的字段保持原值;它的 `--agent-config` 是会话级的 agent 覆盖项(`system` / `tools` / `mcp_servers` / `skills`),**不是** agent id。`archive` 和 `delete` 都是**破坏性**的 | +| `dashscope agentstudio sessions events send` | 投递一轮对话:`--message` 会被替你包成一条 user 消息,`--events` 原样发送一个 JSON 数组且优先级更高,`--thread-id` 指定线程 | +| `dashscope agentstudio sessions events list` | 把会话已持久化的事件读回来:`--type`(可重复)、`--order`、`--limit`、`--page` | +| `dashscope agentstudio sessions events stream` | 跟随这次运行直到落定:table 模式打印答案文本,`--deltas message` 选择接收增量帧,`-o json` 一行一个事件 | +| `dashscope config set` | 往 `~/.dashscope/config.toml` 写一个键 | +| `dashscope config get` | 读回一个键(api_key 默认打码,除非 `--reveal`) | +| `dashscope config list` | 每个键、它的生效值,以及这个值来自哪一层 | + +两代命令使用相互独立的入口和实现。所有 1.x 命令都使用 `dashscope1`,包括 1.x 专有命令组(`generation`、`application`、`image-synthesis`、`multimodal-conversation`、`speech-synthesis`、`transcription`、`video-synthesis`、`image-generation`、`multimodal-embedding`)、Typer 之前的点式拼写,以及采用 1.x 参数的共享命令组。上述 2.0 命令使用 `dashscope`,按 SDK 资源树命名,因此应用调用是 `apps completions create`,不是简化的 `apps call`。在 `dashscope` 下运行 1.x 专有命令会被作为未知命令拒绝,并给出改用 `dashscope1` 的提示。不带子命令的裸 `dashscope` 会交互式打开内置的 `expert` agent。 + +这里*没有*的东西是一个决定,而不是一堆待办,而且这些空缺长得都一样:一条长连接的双向 socket,或者一长串 shell 加不出任何价值的 CRUD。`audio realtime run` 驱动双工 Omni socket 的**一轮**然后挂断;真正的实时麦克风——它跨多轮保持打开,并且要求音频以实时速率到达——是 Python 里的 `client.audio.realtime.connect()`,`tingwu realtime` 也是同一个故事。`agentstudio` 覆盖了你真会拿去写脚本的那个回路(agents、sessions、session events);它另外九个顶层资源(deployments 及其 runs、environments、files、skills、vaults、memory stores、webhook endpoints、security),加上 session threads 和 session resources,仍然只有 SDK。SDK 暴露的其余东西都有命令了。 + +### 输出格式 + +`-o table`(默认)给人看,`-o json` 和 `-o yaml` 给机器看。这个切分是刻意的,并且在三个地方被强制执行: + +| 行为 | table | json | yaml | +| --- | --- | --- | --- | +| 渲染方式 | Rich,会折行会上色 | stdout 上 `json.dumps(indent=2, ensure_ascii=False)` | stdout 上 `yaml.safe_dump(allow_unicode=True, sort_keys=False)` | +| 回答文本 | stdout | 在文档里 | 在文档里 | +| `reasoning_content`、`usage:`、分页信息 | **只走 stderr** | 是文档的一部分 | 是文档的一部分 | +| 流式 | 增量文本写 stdout | **NDJSON** | **NDJSON**(不是 YAML 文档) | +| 传输层 `headers` | 永不出现 | 剥掉,除非 `-v` | 剥掉,除非 `-v` | + +有两个后果值得内化: + +1. table 模式下 `dashscope chat create ... > answer.txt` 只会捕获回答本身。推理过程和 token 计数走 stderr,所以重定向永远不会捞到装饰性内容。 +2. `--stream -o yaml` 是个阅读陷阱:它每行吐一个 **JSON** 对象,和 `--stream -o json` 一模一样。流式输出天然是按行的,而多文档 YAML 流对 `while read` 极不友好。 + +管道配方: + +```bash +dashscope -o json chat create -m qwen-plus --prompt "hi" | jq -r '.choices[0].message.content' +dashscope -o json chat create -m qwen-plus --prompt "hi" | jq -r '.usage.total_tokens' +dashscope -o json ft list --size 50 | jq -r '.jobs[] | "\(.job_id)\t\(.status)"' +dashscope -o json chat create -m qwen-plus --prompt "hi" --stream \ + | jq -j '.choices[0].delta.content // empty' +``` + +需要原始 HTTP 响应头(提工单用)和失败堆栈时加 `-v` / `--verbose`。流水线里别加:`headers` 是传输元数据,不属于结果。 + +### 退出码与错误格式 + +退出码才是契约,不要去 grep 错误文本。 + +| 退出码 | 含义 | 可重试? | +| --- | --- | --- | +| `0` | 成功 | — | +| `1` | `server`、`ratelimit`、`network`——服务或链路失败 | 可以,带退避。`ratelimit` 要尊重 `Retry-After` | +| `2` | `usage`——你的命令行、你的输入文件,或缺了 `--yes` | 不行,改调用方式 | +| `3` | `auth`——凭据被拒 | 不行,改密钥 | +| `4` | `notfound`——这个 id 不存在 | 不行,改 id | + +每次失败都会在 stderr 上打印一行固定形状的内容,后面跟可选的 dim 上下文: + +```text +Error [ratelimit]: Rate limit exceeded. + request_id: 7574ee8f-38a3-4b1e-9280-11c33ab46e51 + solution: Reduce your request frequency. +``` + +`Error [category]: message` 是稳定的;两行 dim 只在有内容可说时才出现。`request_id` 是提工单需要的东西,`solution` 是注册表自带的指引——单独一行,这样你永远能分清哪些字来自网关。 + +HTTP 状态码如何变成 category(`cli/errors.py` 的 `_STATUS_CATEGORY`): + +| 状态码 | category | 退出码 | +| --- | --- | --- | +| 400、413 | `usage` | 2 | +| 401、403 | `auth` | 3 | +| 404 | `notfound` | 4 | +| 429 | `ratelimit` | 1 | +| 500、503、504 | `server` | 1 | + +SDK 侧的失败没有 HTTP 状态码,所以按注册表 code 归类:`sdk.ConnectionError` 和 `sdk.Timeout` → `network`(1),`sdk.InvalidApiKey` → `auth`(3),`sdk.IoError` → `usage`(2,因为「你指名的文件读不了」是你的错)。无法归因的一律落到 `server`。 + +shell 里的重试封装: + +```bash +attempt=1 +until dashscope -o json chat create -m qwen-plus --prompt "hi" > out.json; do + code=$? + case $code in + 1) [ $attempt -ge 5 ] && exit 1 + sleep $(( attempt * 2 )); attempt=$(( attempt + 1 )) ;; + *) exit $code ;; # 2 / 3 / 4 不会自己好 + esac +done +``` + +`cli_ci_harness.py` 的场景 2 是同一段逻辑的 Python 版,配一个「第一次限流、第二次放行」的 mock 网关,让你亲眼看它恢复。 + +### 配置 + +优先级,从高到低: + +```text +命令行 flag > -c KEY=VALUE > 环境变量 > 配置文件 > 内置默认值 +``` + +所以 `-k sk-a -c api_key=sk-b` 用的是 `sk-a`:专用 flag 是更具体的写法,永远赢。 + +配置文件是 `~/.dashscope/config.toml`。用 `DASHSCOPE_CONFIG` 重定向它——这就是给单个 CI 作业一份独立配置、又不碰任何人 home 目录的办法: + +```bash +DASHSCOPE_CONFIG=/tmp/job-42/config.toml dashscope config set default_model qwen-plus +``` + +顶层键都是 SDK 键,原样传给 `Client(...)`:`api_key`、`protocol`、`base_url`、`workspace`、`region`、`timeout`、`max_retries`、`http_max_connections`、`http_max_keepalive_connections`、`http_keepalive_expiry`。`[cli]` 表里只放两个 CLI 专用默认值:`output` 和 `default_model`。写成 `cli.output` / `cli.default_model` 可以,直接写也行——`config set output json` 知道该放哪。 + +```toml +api_key = "sk-xxx" +protocol = "native" + +[cli] +output = "json" +default_model = "qwen-plus" +``` + +三个实践上真正要紧的细节: + +- `config set api_key` **绝不回显值**,并且密钥落盘的那一刻文件就被 chmod 成 `0600`。终端录制和构建日志都抓不到你刚敲进去的东西。 +- `config get api_key` 会打码:前 4 位 + 星号 + 后 2 位(`sk-l*******mo`)。`--reveal` 打印完整值,留给你确实需要核对的那一次。 +- `config get` 读的是**文件本身**,`config list` 报告的是**生效值**加上赢的那一层。两者可能不一致——而这个不一致*就是*优先级这一课,`cli_ci_harness.py` 的场景 4 会把它讲明白,而不是让它看起来像个 bug。 + +`-c` 会被立刻校验,在任何命令上都一样,包括根本不联网的命令: + +```bash +dashscope -c defualt_model=qwen-max version +# Error [usage]: unknown configuration key 'defualt_model'; known keys: api_key, base_url, ... +# exit 2 +``` + +这是刻意的。流水线里的一个拼写错误应该在最便宜的命令上就暴露出来,而不是三个阶段之后变成一个「模型莫名其妙不对」的问题。 + +### 输入约定 + +每个文本选项都接受三种形态: + +| 形态 | 含义 | +| --- | --- | +| `--prompt "text"` | 字面值 | +| `--prompt @path/to/file.txt` | 读文件(会展开 `~`)。`@` 形态能让长 payload 和工具 schema 不进 shell history、不出现在 `ps` 输出里 | +| `--prompt -` | 读 stdin 到 EOF | + +三者都没给时,**管道进来的** stdin 会被自动拾取,所以下面这样是可行的: + +```bash +cat question.txt | dashscope chat create -m qwen-plus +``` + +会话来源优先级:`--messages` > `--prompt`(+ `--system`)> 管道 stdin。三者都拿不到东西时,你会得到 exit 2 和 `no input given: pass --prompt, --messages, or pipe text on stdin`。 + +`--messages` 接受三种形态,并自己判断你给的是哪一种: + +```bash +# 一个消息对象的 JSON 数组 +dashscope chat create -m qwen-plus \ + --messages '[{"role":"user","content":"你好"},{"role":"assistant","content":"你好!"},{"role":"user","content":"继续"}]' + +# JSONL——每行一个消息对象,用循环拼最省事 +dashscope chat create -m qwen-plus --messages @turns.jsonl + +# 一个带 messages 键的对象,也就是把抓下来的请求体原样回放 +dashscope chat create -m qwen-plus --messages @last_request.json +``` + +JSON 写错会报位置,不会耸肩:`--messages: invalid JSON (line 1 column 23)`。 + +`--tools` 和 `--parameters` 同样是 JSON,同样接受 `@file`。`--tools` 必须是 **JSON 数组**,给它一个对象是 `usage` 错误(exit 2)。消息里的本地图片和视频路径会原样传给 SDK,由 SDK 自己上传——你不需要预先上传。 + +### 配方 + +可直接复制。标 🔑 的需要真实的 `DASHSCOPE_API_KEY`,其余完全离线。 + +**一次性问答存进变量** 🔑 + +```bash +answer=$(dashscope -o json chat create -m qwen-plus --prompt "用一句话介绍杭州" \ + | jq -r '.choices[0].message.content') +``` + +**边到边流式打到终端** 🔑 + +```bash +dashscope chat create -m qwen-plus --prompt "写一首关于春天的短诗" --stream +``` + +**长 prompt 走文件,绝不走 argv** 🔑 + +```bash +dashscope chat create -m qwen-plus --prompt @brief.md --system "你是资深编辑" +``` + +**工具定义走文件** 🔑 + +```bash +dashscope chat create -m qwen-plus --prompt @question.txt --tools @tools.json -o json +``` + +**循环里拼 JSONL 做多轮** 🔑 + +```bash +: > turns.jsonl +while read -r line; do + printf '{"role":"user","content":"%s"}\n' "$line" >> turns.jsonl +done < questions.txt +dashscope chat create -m qwen-plus --messages @turns.jsonl +``` + +**只重试有可能自己好的** 🔑 ——见上面退出码一节的 shell 封装。 + +**微调并阻塞到尘埃落定** 🔑 + +```bash +dashscope ft create -m qwen-plus -t "$FILE_ID" --n-epochs 2 --wait --timeout 1800 +``` + +`ft create -m` **不会**回落到 `default_model`。这是有意为之:chat 的默认模型不是微调基座,而静默微调错模型比报个错糟糕得多。 + +**跟随作业事件** 🔑 + +```bash +dashscope ft stream "$JOB_ID" +``` + +**抓取作业的训练日志** 🔑 + +```bash +dashscope ft logs "$JOB_ID" --offset 1 --lines 500 > train.log +``` + +**取到生成的视频,然后下载** 🔑 + +```bash +url=$(dashscope videos generate -m wan2.2-t2v-plus --prompt "海浪拍打礁石" --timeout 1800) +curl -o clip.mp4 "$url" +``` + +只提交、之后统一收结果的批量写法: + +```bash +dashscope -o json videos jobs create -m wan2.2-t2v-plus --prompt @brief.md \ + | jq -r '.task_id' >> task_ids.txt +while read -r id; do dashscope videos jobs get "$id"; done < task_ids.txt +``` + +**一步提交,另一步阻塞等待** 🔑 + +```bash +# 第一步——提交并留下 id +dashscope -o json images jobs create -m wan2.2-t2i-flash --prompt @brief.md \ + | jq -r '.task_id' > task_id.txt + +# 第二步——可能是另一台 runner,可能是一小时之后 +dashscope images jobs wait "$(cat task_id.txt)" --timeout 1800 +``` + +`wait` 收的是一个 **id**,不是作业对象,所以两步之间需要活下来的只有这一个字符串。任务失败或被取消时退出码为 1;`--timeout` 是它无法兑现的值时退出码为 2——`0` 和 `nan` 都算,因为 `nan` 与任何值比较都为假,轮询会永远转下去。`ft wait`、`videos jobs wait`、`embeddings jobs wait`、`rl jobs wait` 和 `audio asr wait` 都是同一个形状。 + +**语音合成到文件,或直接进管道** 🔑 + +```bash +dashscope audio tts synthesize "你好,世界" --out hello.mp3 +dashscope audio tts synthesize @script.txt --voice longxiaochun | ffplay -i - -autoexit -nodisp +``` + +不给 `--out`,裸音频字节就写 stdout。给了 `--out`,音频进文件,`wrote N bytes to ...` 那行提示走 stderr——所以两种写法都不会污染重定向。 + +**转写一段录音并阻塞等结果** 🔑 + +```bash +dashscope audio asr transcribe "https://example.com/meeting.mp3" --timeout 1800 +``` + +**在实时 socket 上跑一轮** 🔑 + +```bash +dashscope audio realtime run -m qwen3.8-omni-flash-realtime \ + --audio question.pcm --out reply.wav +``` + +答案文本走 stdout,输入转写和 `wrote N bytes` 那行提示走 stderr,语音回复进 `--out`。不给 `--out`,回复音频就被**丢掉**——这个命令不会只因为你忘了个参数就往终端里喷二进制。`-o json` 会把整轮变成一行一个事件的 NDJSON 轨迹,这一轮行为不对的时候你要的就是它。 + +**先建热词表,再拿它去识别** 🔑 + +```bash +vid=$(dashscope -o json audio vocabularies create \ + --prefix demo --target-model paraformer-realtime-v2 \ + --vocabulary @hotwords.json \ + | jq -r '.vocabulary_id') +dashscope audio asr recognize meeting.pcm -m paraformer-realtime-v2 --vocabulary-id "$vid" +``` + +注意 `-o json` 的位置:在 `audio` 之前,因为它是全局选项。在这个命令组里面,短选项已经被占掉了——`tts synthesize` 和 `realtime run` 都用 `-o` 表示 `--out`。 + +**调用百炼应用,并把会话续上** 🔑 + +```bash +dashscope -o json apps completions create --app-id "$APP_ID" --prompt "帮我查一下订单" \ + | jq -r '.session_id' > session.txt +dashscope apps completions create --app-id "$APP_ID" \ + --prompt "那第二个呢" --session-id "$(cat session.txt)" +``` + +**用 shell 驱动一个托管 agent** 🔑 + +```bash +sid=$(dashscope -o json agentstudio sessions create --agent "$AGENT_ID" \ + | jq -r '.id') +dashscope agentstudio sessions events send "$sid" --message "帮我查一下订单" +dashscope agentstudio sessions events stream "$sid" --deltas message +``` + +`events send` 投递这一轮,`events stream` 跟随这次运行直到落定。它们是两个命令而不是一个,因为流水线通常要投 N 轮再统一收——也因为一条中途断掉的流应该以非零退出,而不是把已经发出去的 send 一起吞掉。 + +**针对一张工单回答带类型的问题** 🔑 + +```bash +dashscope decision create --state @ticket.txt --questions @questions.json +``` + +`--questions` 为每个 id 声明一个 `type`——`choice` 选出一个选项,`noul` 是「是」的概率,`score` 是加权后的档位下标。每个答案回一行并带上置信度,`-o json` 给你 `.answers..choice` 用来分支。模型不生成散文,所以没有需要从句子里抠出来的东西。`-m` 默认是 `decision-model-preview`,并且**不**读 `default_model`:这是一个决策模型,不是 chat 模型。 + +**CI:破坏性命令需要 `--yes`** 🔑 + +```bash +dashscope ft delete "$JOB_ID" --yes +``` + +没有 `--yes` 又没有终端时,它以 exit 2 拒绝,而不是替你猜。 + +**按作业隔离配置** 🔑 + +```bash +export DASHSCOPE_CONFIG="$CI_JOB_DIR/config.toml" +dashscope config set default_model qwen-plus +dashscope config set api_key "$DASHSCOPE_API_KEY" +dashscope chat create --prompt "hi" # 不传 -m:来自 default_model +``` + +**干净的日志**——不要颜色: + +```bash +export NO_COLOR=1 +``` + +`NO_COLOR=1` 和 `--no-color` 是一回事。Rich 本来就能识别管道,所以这一条主要是给那种「先分配伪终端再把它倒进日志」的 runner 用的。 + +### 运行示例 + +两个都离线、确定性,都不需要密钥。在仓库根目录、且检出可导入的情况下: + +```bash +PYTHONPATH=. python examples/05_cli/cli_output_and_errors.py +PYTHONPATH=. python examples/05_cli/cli_ci_harness.py +``` + +(只有包没被 pip 安装时才需要 `PYTHONPATH=.`。装了的话 `python examples/05_cli/cli_output_and_errors.py` 就够了。harness 会为自己的子进程设好这个变量,所以两种情况下都能跑。) + +两个都会在最后打印一段小结,并且只有全部检查通过才 exit `0`——`cli_output_and_errors.py` 会断言它跑的每条命令的退出码,`cli_ci_harness.py` 会断言全部七个场景。两者都不读也不写你真实的 `~/.dashscope/config.toml`:harness 把 `DASHSCOPE_CONFIG` 指向一个临时目录,退出时删掉。 + +要让 harness 的只读场景打真实网关: + +```bash +CLI_HARNESS_LIVE=1 DASHSCOPE_API_KEY=sk-xxx python examples/05_cli/cli_ci_harness.py +``` + +Live 模式跳过所有故障注入场景,并且**绝不**运行 `ft cancel` 或 `ft delete`。只有你同时把 `DASHSCOPE_JOB_ID` 设成一个你自己拥有的作业时,它才会跑 `ft stream`——对真实账户猜一个 id,正是「示例删掉了重要东西」的标准死法。 + +### 常见注意事项 + +1. **全局选项不能后置。**`dashscope chat create --prompt "hi" -o json` 得到的是 exit 2,不是格式化好的回答。顶层 `--help` 里的那些选项,位置是相对于 app 的。 +2. **`--stream -o yaml` 是 NDJSON,不是 YAML。**每行一个 JSON 对象,和 `-o json` 一样。YAML 解析器读不了它。 +3. **破坏性命令在 CI 里会拒绝执行。**没有终端 + 没有 `--yes` = exit 2。你的流水线需要在每一个 `cancel`、`delete`、`scale` 和 `archive` 上加 `--yes`——`ft`、`files`、`embeddings jobs`、`images jobs`、`videos jobs`、`deployments`、`rl`、`audio vocabularies`/`phrases`/`voices`/`voice-design`、`agentstudio agents`/`sessions`——而且必须是显式加,因为重点就在于这个命令不肯替你做决定。 +4. **`ft create -m` 永不回落到 `default_model`。**只有 `chat create` 和 `multimodal chat create` 会。源码注释写了原因:chat 的默认模型不是微调基座。 +5. **`config get` 和 `config list` 可能互相矛盾。**`get` 读文件,`list` 解析各层。两者不一致时,`list` 的 `source` 列在告诉你是哪一层赢了——通常是你忘了自己 export 过的环境变量。 +6. **table 模式下有用的元数据在 stderr 上。**`usage:`、推理文本和 `page x/y, total n` 是刻意不放 stdout 的,这样重定向才干净。想把它们当数据拿,就用 `-o json`,别去捕获 stderr。 +7. **`audio tts synthesize` 不带 `--out` 时,往 stdout 写的是裸字节。**这正是 `> hello.mp3` 能用的原因,也是忘了写 `--out` 时终端被二进制刷屏的原因。这条命令没有 table;`-o json` 不会改变音频写到哪里。 +8. **`apps completions create --stream` 会同时把服务端切成增量帧。**不切的话,每个事件都带着到目前为止的整段答案,终端上就是同一句话不断变长地重复。确实想要累积形式,就传 `--no-incremental-output`。 +9. **`chat create` 已经会自动路由媒体,`multimodal chat create` 依然存在。**当会话必须打到多模态端点、但里面并没有媒体分片时用它——两条命令收同样的参数,渲染方式也一样。 +10. **在 `audio` 里面,`-o` 是 `--out`,不是 `--output`。**`dashscope audio tts synthesize hi -o json` 会写一个叫 `json` 的文件然后 exit 0。格式选项是全局的,所以要放在命令组之前:`dashscope -o json audio ...`。这是整个 CLI 里唯一一处把同一个短选项用作另一个含义的地方,而且是有意为之——在这个命令组里,shell 配方真正要伸手去拿的就是 `--out`。 +11. **`audio` 下面那几个注册表的分页方式不一致。**`vocabularies`、`voices` 和 `voice-design` 收**从 0 开始**的 `--page-index` 加 `--page-size`;`phrases` 是微调那一套形状,收**从 1 开始**的 `--page` 加 `--size`。这不是 CLI 的疏漏——它们背后的两个服务端本身就不一致,而这几条命令选择按各自服务端的说法来写,而不是发明一个会悄悄吞掉一页的翻译层。 +12. **`agentstudio agents update` 不给 `--version` 就不会跑。**服务端在 agent 的当前版本上做乐观并发;漏了它,命令在发出任何调用之前就 exit 2。版本过期是一个 409,CLI 没有为它单独设一档,因此报成 exit 1 的 `Error [server]`——算可重试,但真管用的重试是「把 agent 重读一遍、取它新的 `version`、再 update」,而不是「睡一会儿然后把同样的东西再发一次」。 +13. **`agentstudio sessions events stream` 不主动要增量帧时,打印的是整条消息。**table 模式读的是流的 `text_stream`,也就是重连之后能够重建的那一路;`--deltas message`(可重复,还有 `reasoning`)选择接收增量帧,并把命令切到 `text_deltas`。`-o json` 不受影响——两种情况下都是一行一个原始事件。 +14. **`--timeout 0` 是错误,不是「查一次就返回」。**每一个 `wait` 命令和每一个 `--wait` 开关都会拒绝不是有限正秒数的超时值——退出码 2,且发生在任何 API 调用之前,所以即使流水线没有配 key,拿到的也是用法错误而不是鉴权错误。`0`、`-1`、`nan` 和 `inf` 全都被拒。其中真正危险的是 `nan`:它与任何值比较都为假,SDK 的截止时间判断永远不会触发,轮询会一直转下去而不是超时退出。想不阻塞地看当前状态,用 `get`。 + +SDK 侧对应的 mock 网关示例见 [`02_advanced_engineering/local_mock_debugging.py`](./02_advanced_engineering/local_mock_debugging.py)。 + ## legacy_1x —— 1.x 模块级 API(仅供参考) 以下使用旧的 1.x 风格(`dashscope.Generation`、`dashscope.MultiModalConversation` 等)。内部子目录布局原样保留、内容不做改动。新项目请使用上面的 2.0 分层示例。 +**1.x CLI:** 现有 1.x 命令形式与 [`05_cli/`](./05_cli/) 中的 2.0 命令共用 `dashscope` 入口。1.x 专有名称仍由冻结的 1.x 实现执行,共享名称由 2.0 执行并兼容旧参数拼写。 + | 路径 | 内容 | | --- | --- | | `generation/` | Generation 文本生成:基础、深度思考、工具调用、深度研究、自定义会话 | diff --git a/setup.py b/setup.py index d9a94d60..547e27d0 100644 --- a/setup.py +++ b/setup.py @@ -142,6 +142,9 @@ def run(self): zip_safe=False, cmdclass=cmdclass, entry_points={ - "console_scripts": ["dashscope = dashscope.legacy_1x.cli:main"], + "console_scripts": [ + "dashscope = dashscope.cli.main:main", + "dashscope1 = dashscope.legacy_1x.cli:main", + ], }, ) diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 00000000..cc26a06f --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 00000000..3d9794ab --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Shared fixtures for the v2 CLI tests.""" + +from __future__ import annotations + +import re +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, List + +import pytest +from typer.testing import CliRunner + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") + +# Everything the SDK reads from the environment. Cleared per test so a +# developer's own DASHSCOPE_API_KEY cannot make a test pass locally and +# fail in CI. +_ENV_KEYS = ( + "DASHSCOPE_API_KEY", + "DASHSCOPE_BASE_URL", + "DASHSCOPE_HTTP_BASE_URL", + "DASHSCOPE_WORKSPACE", + "DASHSCOPE_REGION", + "DASHSCOPE_WEBSOCKET_BASE_URL", + "DASHSCOPE_PROTOCOL", + "DASHSCOPE_TIMEOUT", + "DASHSCOPE_MAX_RETRIES", + "DASHSCOPE_HTTP_MAX_CONNECTIONS", + "DASHSCOPE_HTTP_MAX_KEEPALIVE_CONNECTIONS", + "DASHSCOPE_HTTP_KEEPALIVE_EXPIRY", + "DASHSCOPE_LOG", + "DASHSCOPE_CONFIG", + "DASHSCOPE_API_KEY_FILE_PATH", + "NO_COLOR", + "FORCE_COLOR", +) + + +def strip_ansi_codes(text: str) -> str: + """Remove SGR escapes so assertions can match literal text.""" + return _ANSI_RE.sub("", text) + + +def fake_client(**resources: Any) -> SimpleNamespace: + """A stand-in for ``dashscope.Client`` holding only what a test needs. + + The CLI's contract with the SDK is "one resource call, one render", + so a command test should pin exactly that and never touch HTTP. + """ + client = SimpleNamespace(**resources) + client.close = lambda: None + return client + + +@pytest.fixture +def runner() -> CliRunner: + """A CliRunner that keeps stdout and stderr in separate streams. + + The CLI's core promise is machine-readable stdout and diagnostics on + stderr; a mixed-stream runner would hide a violation of it, because + a spinner or an error line would land inside the captured JSON. + """ + try: + return CliRunner(mix_stderr=False) + except TypeError: # pragma: no cover - click >= 8.2 always separates + return CliRunner() + + +@pytest.fixture +def clean_env(monkeypatch, tmp_path: Path) -> Dict[str, Any]: + """Isolate config resolution from the developer's machine. + + Returns the paths the SDK was pointed at so a test can write a config + file without recomputing them. + """ + for key in _ENV_KEYS: + monkeypatch.delenv(key, raising=False) + + # ``settings`` is a process-wide singleton that sits above the env in + # the SDK's resolution chain; another test module may have set it. + from dashscope._config import settings + + settings.clear() + + config = tmp_path / "config.toml" + api_key_file = tmp_path / "api_key" + monkeypatch.setenv("DASHSCOPE_CONFIG", str(config)) + monkeypatch.setenv("DASHSCOPE_API_KEY_FILE_PATH", str(api_key_file)) + return { + "config": config, + "api_key_file": api_key_file, + "tmp_path": tmp_path, + } + + +@pytest.fixture +def make_client(monkeypatch): + """Patch the CLI's single SDK seam and record the overrides it got. + + The returned callable installs a fake client assembled from the + resource stand-ins passed in, and exposes the recorded ``Client(...)`` + keyword dicts as ``.calls``:: + + make_client(fine_tunes=SimpleNamespace(wait=...)) + ... run the command ... + assert make_client.calls == [{"api_key": "sk-1"}] + """ + calls: List[Dict[str, Any]] = [] + + def install(**resources: Any) -> SimpleNamespace: + client = fake_client(**resources) + + def build(overrides: Dict[str, Any]) -> SimpleNamespace: + calls.append(dict(overrides)) + return client + + monkeypatch.setattr("dashscope.cli.sdk.build_client", build) + return client + + install.calls = calls + return install diff --git a/tests/cli/test_agentstudio_cmd.py b/tests/cli/test_agentstudio_cmd.py new file mode 100644 index 00000000..61e52e44 --- /dev/null +++ b/tests/cli/test_agentstudio_cmd.py @@ -0,0 +1,897 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope agentstudio`` -- agents, sessions and session events.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, Dict, List, Optional + +import pytest + +from dashscope.applications.agentstudio.pagination import CursorPage +from dashscope.applications.agentstudio.resources._helpers import _coerce_event +from dashscope.applications.agentstudio.types import ( + Agent, + AgentVersion, + DeleteResponse, + Session, +) +from dashscope.cli.app import build_app + + +def _agent(**overrides: Any) -> Agent: + data: Dict[str, Any] = { + "id": "agent-1", + "type": "agent", + "version": 3, + "name": "helper", + "model": {"id": "qwen3.8-max"}, + "created_at": "2026-11-01T00:00:00Z", + "request_id": "req-1", + } + data.update(overrides) + return Agent(**data) + + +def _version(**overrides: Any) -> AgentVersion: + data: Dict[str, Any] = { + "agent_id": "agent-1", + "version": 3, + "created_at": "2026-11-01T00:00:00Z", + } + data.update(overrides) + return AgentVersion(**data) + + +def _session(**overrides: Any) -> Session: + data: Dict[str, Any] = { + "id": "sess-1", + "type": "session", + "title": "a run", + "status": "idle", + "agent": {"id": "agent-1", "version": 3}, + "created_at": "2026-11-01T00:00:00Z", + "request_id": "req-1", + } + data.update(overrides) + return Session(**data) + + +def _event_payload(**overrides: Any) -> Dict[str, Any]: + data: Dict[str, Any] = { + "type": "message", + "role": "assistant", + "sequence_number": 2, + "content": [{"type": "text", "text": "hello"}], + "created_at": "2026-11-01T00:00:01Z", + } + data.update(overrides) + return data + + +def _page( + items: List[Any], + *, + next_page: Optional[str] = None, + **extra: Any, +) -> CursorPage: + return CursorPage( + data=items, + next_page=next_page, + request_id="req-1", + extra=extra, + ) + + +class _Agents: + """Stand-in for ``client.agentstudio.agents``.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _go(self, method: str, args: tuple = (), **kwargs: Any) -> Any: + self.calls.append( + {"method": method, "args": args, "kwargs": kwargs}, + ) + if method in self._results: + return self._results[method] + if method == "list": + return _page([_agent()]) + if method == "list_versions": + return _page([_version()]) + return _agent() + + def create(self, **kwargs): + return self._go("create", **kwargs) + + def retrieve(self, agent_id, **kwargs): + return self._go("retrieve", (agent_id,), **kwargs) + + def list(self, **kwargs): + return self._go("list", **kwargs) + + def list_versions(self, agent_id, **kwargs): + return self._go("list_versions", (agent_id,), **kwargs) + + def update(self, agent_id, **kwargs): + return self._go("update", (agent_id,), **kwargs) + + def archive(self, agent_id, **kwargs): + return self._go("archive", (agent_id,), **kwargs) + + +class _SessionEvents: + """Stand-in for ``client.agentstudio.sessions.events``.""" + + def __init__(self, stream: Any = None, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + self._stream = stream + + def send(self, session_id, events, **kwargs): + self.calls.append( + {"method": "send", "args": (session_id, events), "kwargs": kwargs}, + ) + return self._results.get( + "send", + {"accepted": 1, "request_id": "req-1"}, + ) + + def list(self, session_id, **kwargs): + self.calls.append( + {"method": "list", "args": (session_id,), "kwargs": kwargs}, + ) + return self._results.get( + "list", + _page([_coerce_event(_event_payload())]), + ) + + def stream(self, session_id, **kwargs): + self.calls.append( + {"method": "stream", "args": (session_id,), "kwargs": kwargs}, + ) + return self._stream if self._stream is not None else _Stream() + + +class _Sessions: + """Stand-in for ``client.agentstudio.sessions``.""" + + def __init__(self, events: Any = None, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + self.events = events if events is not None else _SessionEvents() + + def _go(self, method: str, args: tuple = (), **kwargs: Any) -> Any: + self.calls.append( + {"method": method, "args": args, "kwargs": kwargs}, + ) + if method in self._results: + return self._results[method] + if method == "list": + return _page([_session()]) + if method == "delete": + return DeleteResponse( + id="sess-1", + type="session", + request_id="req-1", + ) + return _session() + + def create(self, **kwargs): + return self._go("create", **kwargs) + + def retrieve(self, session_id, **kwargs): + return self._go("retrieve", (session_id,), **kwargs) + + def list(self, **kwargs): + return self._go("list", **kwargs) + + def update(self, session_id, **kwargs): + return self._go("update", (session_id,), **kwargs) + + def archive(self, session_id, **kwargs): + return self._go("archive", (session_id,), **kwargs) + + def delete(self, session_id, **kwargs): + return self._go("delete", (session_id,), **kwargs) + + +class _Stream: + """Stand-in for the SDK's single-pass typed event stream.""" + + def __init__( + self, + events: Optional[List[Dict[str, Any]]] = None, + *, + texts: Optional[List[str]] = None, + deltas: Optional[List[str]] = None, + ) -> None: + self._events = events if events is not None else [_event_payload()] + self._texts = texts if texts is not None else ["hello ", "world"] + self._deltas = deltas if deltas is not None else [] + self.closed = False + + def __iter__(self): + return iter([_coerce_event(item) for item in self._events]) + + @property + def text_stream(self): + return iter(self._texts) + + @property + def text_deltas(self): + return iter(self._deltas) + + def close(self) -> None: + self.closed = True + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def studio(make_client): + """Install the agents/sessions pair and hand both back.""" + + def install( + *, + agents: Optional[Dict[str, Any]] = None, + sessions: Optional[Dict[str, Any]] = None, + events: Optional[Dict[str, Any]] = None, + stream: Any = None, + ) -> SimpleNamespace: + agent_resource = _Agents(**(agents or {})) + event_resource = _SessionEvents(stream, **(events or {})) + session_resource = _Sessions(event_resource, **(sessions or {})) + make_client( + agentstudio=SimpleNamespace( + agents=agent_resource, + sessions=session_resource, + ), + ) + return SimpleNamespace( + agents=agent_resource, + sessions=session_resource, + events=event_resource, + ) + + return install + + +class TestAgents: + def test_create(self, invoke, studio): + resource = studio() + result = invoke( + [ + "agentstudio", + "agents", + "create", + "--name", + "helper", + "-m", + "qwen3.8-max", + ], + ) + assert result.exit_code == 0 + assert "agent-1" in result.stdout + kwargs = resource.agents.calls[0]["kwargs"] + assert kwargs["name"] == "helper" + assert kwargs["model"] == "qwen3.8-max" + assert kwargs["tools"] is None + + def test_create_takes_the_json_definitions(self, invoke, studio): + resource = studio() + invoke( + [ + "agentstudio", + "agents", + "create", + "--name", + "helper", + "-m", + "qwen3.8-max", + "--system-prompt", + "be terse", + "--tools", + '[{"type": "function", "name": "lookup"}]', + "--mcp-servers", + '[{"url": "https://example.com/mcp"}]', + "--skills", + '[{"id": "skill-1"}]', + "--metadata", + '{"team": "sdk"}', + ], + ) + kwargs = resource.agents.calls[0]["kwargs"] + assert kwargs["system_prompt"] == "be terse" + assert kwargs["tools"] == [{"type": "function", "name": "lookup"}] + assert kwargs["mcp_servers"] == [ + {"url": "https://example.com/mcp"}, + ] + assert kwargs["skills"] == [{"id": "skill-1"}] + assert kwargs["metadata"] == {"team": "sdk"} + + def test_a_definition_must_be_an_array(self, invoke, studio): + resource = studio() + result = invoke( + [ + "agentstudio", + "agents", + "create", + "--name", + "helper", + "-m", + "qwen3.8-max", + "--tools", + '{"type": "function"}', + ], + ) + assert result.exit_code == 2 + assert "--tools: expected a JSON array" in result.stderr + assert resource.agents.calls == [] + + def test_get_reads_the_latest_version(self, invoke, studio): + resource = studio() + invoke(["agentstudio", "agents", "get", "agent-1"]) + call = resource.agents.calls[0] + assert call["method"] == "retrieve" + assert call["args"] == ("agent-1",) + assert call["kwargs"] == {"version": None} + + def test_get_can_pin_one_version(self, invoke, studio): + resource = studio() + invoke(["agentstudio", "agents", "get", "agent-1", "--version", "2"]) + assert resource.agents.calls[0]["kwargs"] == {"version": 2} + + def test_list_prints_this_page_only(self, invoke, studio): + """``CursorPage`` auto-paginates when iterated; the CLI must not.""" + resource = studio( + agents={ + "list": _page( + [_agent(), _agent(id="agent-2", name="second")], + next_page="cursor-2", + ), + }, + ) + result = invoke(["agentstudio", "agents", "list"]) + assert result.exit_code == 0 + assert "agent-1" in result.stdout + assert "agent-2" in result.stdout + assert "next page: cursor-2" in result.stderr + assert resource.agents.calls[0]["kwargs"] == { + "limit": None, + "page": None, + "include_archived": None, + } + + def test_list_envelope_carries_the_cursor(self, invoke, studio): + studio( + agents={"list": _page([_agent()], next_page="cursor-2")}, + ) + result = invoke( + ["agentstudio", "agents", "list"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["next_page"] == "cursor-2" + assert doc["request_id"] == "req-1" + assert doc["data"][0]["id"] == "agent-1" + + def test_versions_lists_the_page(self, invoke, studio): + """`agents get --version N` needs a way to discover N.""" + resource = studio( + agents={"list_versions": _page([_version(version=3)])}, + ) + result = invoke(["agentstudio", "agents", "versions", "agent-1"]) + assert result.exit_code == 0 + assert resource.agents.calls[0] == { + "method": "list_versions", + "args": ("agent-1",), + "kwargs": {"limit": None, "page": None}, + } + assert "agent-1" in result.stdout + + def test_versions_forwards_the_cursor(self, invoke, studio): + resource = studio(agents={"list_versions": _page([_version()])}) + invoke( + [ + "agentstudio", + "agents", + "versions", + "agent-1", + "--limit", + "5", + "--page", + "cursor-2", + ], + ) + assert resource.agents.calls[0]["kwargs"] == { + "limit": 5, + "page": "cursor-2", + } + + def test_versions_reports_the_next_cursor(self, invoke, studio): + studio( + agents={ + "list_versions": _page([_version()], next_page="cursor-9"), + }, + ) + result = invoke(["agentstudio", "agents", "versions", "agent-1"]) + assert "next page: cursor-9" in result.stderr + + def test_versions_json_envelope_carries_the_cursor(self, invoke, studio): + studio( + agents={ + "list_versions": _page( + [_version(version=2)], + next_page="cursor-9", + ), + }, + ) + result = invoke( + ["agentstudio", "agents", "versions", "agent-1"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["next_page"] == "cursor-9" + assert doc["data"][0]["version"] == 2 + + def test_update_needs_the_version_it_expects(self, invoke, studio): + resource = studio() + result = invoke(["agentstudio", "agents", "update", "agent-1"]) + assert result.exit_code == 2 + assert resource.agents.calls == [] + + def test_update_sends_the_new_fields(self, invoke, studio): + resource = studio(agents={"update": _agent(version=4, name="renamed")}) + result = invoke( + [ + "agentstudio", + "agents", + "update", + "agent-1", + "--version", + "3", + "--name", + "renamed", + ], + ) + assert result.exit_code == 0 + assert "renamed" in result.stdout + call = resource.agents.calls[0] + assert call["args"] == ("agent-1",) + assert call["kwargs"]["version"] == 3 + assert call["kwargs"]["name"] == "renamed" + + def test_archive_needs_a_confirmation(self, invoke, studio): + resource = studio() + result = invoke(["agentstudio", "agents", "archive", "agent-1"]) + assert result.exit_code != 0 + assert resource.agents.calls == [] + + def test_archive_with_yes_goes_through(self, invoke, studio): + resource = studio() + result = invoke( + ["agentstudio", "agents", "archive", "agent-1", "--yes"], + ) + assert result.exit_code == 0 + assert resource.agents.calls[0]["method"] == "archive" + + +class TestSessions: + def test_create(self, invoke, studio): + resource = studio() + result = invoke( + ["agentstudio", "sessions", "create", "--agent", "agent-1"], + ) + assert result.exit_code == 0 + assert "sess-1" in result.stdout + kwargs = resource.sessions.calls[0]["kwargs"] + assert kwargs["agent"] == "agent-1" + assert kwargs["vault_ids"] is None + + def test_create_takes_the_environment_options(self, invoke, studio): + resource = studio() + invoke( + [ + "agentstudio", + "sessions", + "create", + "--agent", + "agent-1", + "--environment-id", + "env-1", + "--title", + "a run", + "--vault-ids", + '["vault-1"]', + "--env", + '{"TOKEN": "t"}', + "--mcp-configs", + '[{"url": "https://example.com/mcp"}]', + "--metadata", + '{"team": "sdk"}', + ], + ) + kwargs = resource.sessions.calls[0]["kwargs"] + assert kwargs["environment_id"] == "env-1" + assert kwargs["vault_ids"] == ["vault-1"] + assert kwargs["environment_variables"] == {"TOKEN": "t"} + assert kwargs["mcp_configs"] == [ + {"url": "https://example.com/mcp"}, + ] + assert kwargs["metadata"] == {"team": "sdk"} + + def test_the_agent_is_required(self, invoke, studio): + studio() + assert invoke(["agentstudio", "sessions", "create"]).exit_code == 2 + + def test_get(self, invoke, studio): + resource = studio() + invoke(["agentstudio", "sessions", "get", "sess-1"]) + assert resource.sessions.calls[0]["method"] == "retrieve" + assert resource.sessions.calls[0]["args"] == ("sess-1",) + + def test_list_filters_by_agent_and_status(self, invoke, studio): + resource = studio() + result = invoke( + [ + "agentstudio", + "sessions", + "list", + "--agent-id", + "agent-1", + "--status", + "idle", + "--status", + "running", + "--limit", + "5", + ], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + assert resource.sessions.calls[0]["kwargs"] == { + "limit": 5, + "page": None, + "agent_id": "agent-1", + "statuses": ["idle", "running"], + } + assert json.loads(result.stdout)["data"][0]["status"] == "idle" + + def test_update_sends_only_the_fields_it_was_given(self, invoke, studio): + """An omitted field must stay absent, not arrive as None.""" + resource = studio(sessions={"update": _session(title="renamed")}) + result = invoke( + ["agentstudio", "sessions", "update", "sess-1", "--title", "new"], + ) + assert result.exit_code == 0 + assert resource.sessions.calls[0] == { + "method": "update", + "args": ("sess-1",), + "kwargs": {"title": "new"}, + } + + def test_update_with_nothing_to_change_is_refused(self, invoke, studio): + """A no-op POST that prints the session back looks like success.""" + resource = studio() + result = invoke(["agentstudio", "sessions", "update", "sess-1"]) + assert result.exit_code == 2 + assert "nothing to update" in result.stderr + assert resource.sessions.calls == [] + + def test_update_maps_the_json_options(self, invoke, studio): + resource = studio(sessions={"update": _session()}) + invoke( + [ + "agentstudio", + "sessions", + "update", + "sess-1", + "--vault-ids", + '["vault-1"]', + "--env", + '{"TOKEN": "t"}', + "--mcp-configs", + '[{"name": "m"}]', + "--metadata", + '{"team": "infra"}', + "--agent-config", + '{"system": "be brief"}', + ], + ) + assert resource.sessions.calls[0]["kwargs"] == { + "vault_ids": ["vault-1"], + "environment_variables": {"TOKEN": "t"}, + "mcp_configs": [{"name": "m"}], + "metadata": {"team": "infra"}, + "agent": {"system": "be brief"}, + } + + def test_update_can_clear_a_list_with_an_empty_array(self, invoke, studio): + """``[]`` means remove them all; omitting the flag means keep them.""" + resource = studio(sessions={"update": _session()}) + invoke( + [ + "agentstudio", + "sessions", + "update", + "sess-1", + "--vault-ids", + "[]", + ], + ) + assert resource.sessions.calls[0]["kwargs"] == {"vault_ids": []} + + def test_agent_config_must_be_an_object(self, invoke, studio): + """It is a config patch, not the agent id ``create --agent`` takes.""" + resource = studio() + result = invoke( + [ + "agentstudio", + "sessions", + "update", + "sess-1", + "--agent-config", + "agent-1", + ], + ) + assert result.exit_code == 2 + assert "--agent-config" in result.stderr + assert resource.sessions.calls == [] + + def test_update_renders_the_patched_session(self, invoke, studio): + studio(sessions={"update": _session(title="renamed")}) + result = invoke( + ["agentstudio", "sessions", "update", "sess-1", "--title", "x"], + ) + assert "renamed" in result.stdout + + def test_archive_needs_a_confirmation(self, invoke, studio): + resource = studio() + result = invoke(["agentstudio", "sessions", "archive", "sess-1"]) + assert result.exit_code != 0 + assert resource.sessions.calls == [] + + def test_archive_with_yes_goes_through(self, invoke, studio): + resource = studio() + result = invoke( + ["agentstudio", "sessions", "archive", "sess-1", "-y"], + ) + assert result.exit_code == 0 + assert resource.sessions.calls[0]["method"] == "archive" + + def test_delete_with_yes_goes_through(self, invoke, studio): + resource = studio() + result = invoke( + ["agentstudio", "sessions", "delete", "sess-1", "-y"], + ) + assert result.exit_code == 0 + assert "sess-1" in result.stdout + assert resource.sessions.calls[0]["method"] == "delete" + + +class TestSessionEvents: + def test_send_wraps_the_message(self, invoke, studio): + resource = studio() + result = invoke( + [ + "agentstudio", + "sessions", + "events", + "send", + "sess-1", + "--message", + "hi", + ], + ) + assert result.exit_code == 0 + assert "accepted" in result.stdout + call = resource.events.calls[0] + assert call["args"][0] == "sess-1" + sent = call["args"][1] + assert len(sent) == 1 + assert sent[0]["type"] == "message" + assert sent[0]["role"] == "user" + assert sent[0]["content"][0]["text"] == "hi" + + def test_send_can_post_into_one_thread(self, invoke, studio): + resource = studio() + invoke( + [ + "agentstudio", + "sessions", + "events", + "send", + "sess-1", + "--message", + "hi", + "--thread-id", + "thread-1", + ], + ) + sent = resource.events.calls[0]["args"][1] + assert sent[0]["session_thread_id"] == "thread-1" + + def test_send_takes_raw_events(self, invoke, studio): + """``--events`` is the escape hatch for frames the CLI has no flag.""" + resource = studio() + invoke( + [ + "agentstudio", + "sessions", + "events", + "send", + "sess-1", + "--events", + '[{"type": "message", "role": "user", "content": []}]', + ], + ) + assert resource.events.calls[0]["args"][1] == [ + {"type": "message", "role": "user", "content": []}, + ] + + def test_send_needs_one_of_the_two_inputs(self, invoke, studio): + resource = studio() + result = invoke( + ["agentstudio", "sessions", "events", "send", "sess-1"], + ) + assert result.exit_code == 2 + assert "no input given" in result.stderr + assert resource.events.calls == [] + + def test_list_reads_the_persisted_events(self, invoke, studio): + resource = studio() + result = invoke( + ["agentstudio", "sessions", "events", "list", "sess-1"], + ) + assert result.exit_code == 0 + assert "message" in result.stdout + assert resource.events.calls[0]["kwargs"] == { + "types": None, + "limit": None, + "order": None, + "page": None, + } + + def test_list_filters_by_type(self, invoke, studio): + resource = studio() + invoke( + [ + "agentstudio", + "sessions", + "events", + "list", + "sess-1", + "--type", + "message", + "--type", + "error", + "--order", + "desc", + ], + ) + kwargs = resource.events.calls[0]["kwargs"] + assert kwargs["types"] == ["message", "error"] + assert kwargs["order"] == "desc" + + def test_stream_prints_the_answer_text(self, invoke, studio): + stream = _Stream(texts=["hello ", "world"]) + resource = studio(stream=stream) + result = invoke( + ["agentstudio", "sessions", "events", "stream", "sess-1"], + ) + assert result.exit_code == 0 + assert result.stdout == "hello world\n" + assert resource.events.calls[0]["kwargs"] == { + "event_deltas": None, + "timeout": None, + } + assert stream.closed is True + + def test_stream_opting_into_deltas_reads_the_deltas(self, invoke, studio): + """``text_deltas`` only yields when the stream asked for frames.""" + stream = _Stream(texts=["ignored"], deltas=["a", "b"]) + resource = studio(stream=stream) + result = invoke( + [ + "agentstudio", + "sessions", + "events", + "stream", + "sess-1", + "--deltas", + "message", + ], + ) + assert result.stdout == "ab\n" + assert resource.events.calls[0]["kwargs"]["event_deltas"] == [ + "message", + ] + + def test_stream_json_is_one_event_per_line(self, invoke, studio): + stream = _Stream( + [ + _event_payload(), + _event_payload(type="error", sequence_number=3), + ], + ) + studio(stream=stream) + result = invoke( + ["agentstudio", "sessions", "events", "stream", "sess-1"], + global_args=["-o", "json"], + ) + lines = [json.loads(line) for line in result.stdout.splitlines()] + assert [line["type"] for line in lines] == ["message", "error"] + assert stream.closed is True + + def test_stream_passes_the_timeout(self, invoke, studio): + resource = studio(stream=_Stream([])) + invoke( + [ + "agentstudio", + "sessions", + "events", + "stream", + "sess-1", + "--timeout", + "30", + ], + ) + assert resource.events.calls[0]["kwargs"]["timeout"] == 30.0 + + +class TestGroupShape: + def test_events_is_nested_under_sessions(self, app): + import typer + + studio = typer.main.get_command(app).commands["agentstudio"] + assert set(studio.commands) == {"agents", "sessions"} + assert set(studio.commands["agents"].commands) == { + "create", + "get", + "list", + "versions", + "update", + "archive", + } + sessions = studio.commands["sessions"] + assert set(sessions.commands) - {"events"} == { + "create", + "get", + "list", + "update", + "archive", + "delete", + } + assert "events" in sessions.commands + assert set(sessions.commands["events"].commands) == { + "send", + "list", + "stream", + } + + def test_the_other_eleven_resources_stay_in_the_sdk(self, app): + """The core loop only: environments, files, vaults and the rest.""" + import typer + + studio = typer.main.get_command(app).commands["agentstudio"] + assert "environments" not in studio.commands + assert "vaults" not in studio.commands + + def test_bare_group_shows_help(self, app, runner): + result = runner.invoke(app, ["agentstudio"]) + assert "Usage" in result.stdout diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py new file mode 100644 index 00000000..23c072c2 --- /dev/null +++ b/tests/cli/test_app.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The root app: global options, ``version``, and the ``config`` group.""" + +import json + +import pytest +import typer +from typer.testing import CliRunner + +from dashscope.cli.app import build_app +from dashscope.cli.errors import ErrorHandlingGroup +from dashscope.cli.main import _VALUE_TAKING_OPTIONS +from dashscope.version import __version__ + + +def _runner() -> CliRunner: + try: + return CliRunner(mix_stderr=False) + except TypeError: # click >= 8.2 always separates the streams + return CliRunner() + + +@pytest.fixture +def app(clean_env): + return build_app() + + +def _invoke(app, args): + return _runner().invoke(app, args) + + +class TestRootApp: + def test_uses_the_error_handling_group(self, app): + assert isinstance(typer.main.get_command(app), ErrorHandlingGroup) + + def test_completion_is_enabled(self, app): + """Design doc §4.2: shell completion must be installable.""" + opts = { + opt + for param in typer.main.get_command(app).params + for opt in getattr(param, "opts", []) + } + assert "--install-completion" in opts + assert "--show-completion" in opts + + def test_help_lists_the_v2_commands_and_names_the_removed_1x_ones( + self, + app, + ): + result = _invoke(app, ["--help"]) + assert result.exit_code == 0 + text = result.stdout + assert "version" in text and "config" in text + # Removed 1.x names are not registered as commands, so the epilog + # carries them: one that was replaced, one that has no equivalent. + assert "generation" in text + assert "oss" in text + # A registered v2 group, so it is enumerated by typer itself. + assert "files" in text + + def test_short_help_flag_works(self, app): + assert _invoke(app, ["-h"]).exit_code == 0 + + def test_help_escapes_survive_rich_without_leaking_a_backslash(self, app): + r"""``Error [category]: message`` must render with its brackets. + + Rich reads a bracketed run as a style tag and drops it, so the help + text escapes it as ``\[``. Two ways this breaks: forget the escape + and the brackets vanish, or make the string raw *and* open it with a + line-continuation backslash, which a raw string keeps as text. + """ + text = _invoke(app, ["--help"]).stdout + assert "[category]" in text + assert "\\" not in text + + def test_no_arguments_shows_help(self, app): + result = _invoke(app, []) + assert "Usage" in result.stdout + + def test_unknown_command_is_a_usage_error(self, app): + assert _invoke(app, ["nope"]).exit_code == 2 + + def test_router_and_callback_agree_on_value_taking_options(self, app): + """Pin the drift the router cannot detect at runtime. + + ``classify`` has to know which root options swallow the next token + or it reads their values as command names. That knowledge lives in + two places, so it is checked here rather than trusted. + """ + actual = set() + for param in typer.main.get_command(app).params: + if getattr(param, "is_flag", False) or getattr( + param, + "count", + False, + ): + continue + actual.update(param.opts) + # click's own help option is added by the framework, not by the + # callback, and takes no value. + actual -= {"--help", "-h"} + assert actual == set(_VALUE_TAKING_OPTIONS) + + +class TestVersionCommand: + def test_table_format_prints_the_bare_version(self, app): + result = _invoke(app, ["version"]) + assert result.exit_code == 0 + assert result.stdout.strip() == __version__ + + def test_json_format(self, app): + result = _invoke(app, ["--output", "json", "version"]) + assert json.loads(result.stdout) == {"version": __version__} + + def test_yaml_format(self, app): + result = _invoke(app, ["-o", "yaml", "version"]) + assert result.stdout.strip() == f"version: {__version__}" + + def test_dash_c_can_set_the_format(self, app): + result = _invoke(app, ["-c", "output=json", "version"]) + assert json.loads(result.stdout) == {"version": __version__} + + def test_flag_beats_dash_c(self, app): + result = _invoke(app, ["-c", "output=json", "-o", "yaml", "version"]) + assert result.stdout.strip() == f"version: {__version__}" + + +class TestGlobalOptions: + def test_invalid_output_format_is_a_usage_error(self, app): + result = _invoke(app, ["-o", "csv", "version"]) + assert result.exit_code == 2 + assert "invalid --output" in result.stderr + assert result.stdout == "" + + def test_unknown_config_key_fails_on_every_command(self, app): + """Even `version`, which never touches the service.""" + result = _invoke(app, ["-c", "nope=1", "version"]) + assert result.exit_code == 2 + assert "unknown configuration key" in result.stderr + + def test_invalid_config_value_is_a_usage_error(self, app): + result = _invoke(app, ["-c", "protocol=bogus", "version"]) + assert result.exit_code == 2 + assert "invalid protocol" in result.stderr + + def test_malformed_dash_c(self, app): + result = _invoke(app, ["-c", "api_key", "version"]) + assert result.exit_code == 2 + assert "KEY=VALUE" in result.stderr + + def test_dash_c_is_repeatable(self, app): + result = _invoke( + app, + ["-c", "output=json", "-c", "default_model=qwen-plus", "version"], + ) + assert result.exit_code == 0 + assert json.loads(result.stdout)["version"] == __version__ + + def test_valid_global_options_do_not_build_a_client( + self, + app, + make_client, + ): + """--help and `version` must not open a connection pool.""" + make_client() + assert ( + _invoke( + app, + ["-k", "sk-1", "--workspace", "ws-1", "version"], + ).exit_code + == 0 + ) + assert make_client.calls == [] + + +class TestConfigGroup: + def test_help(self, app): + result = _invoke(app, ["config", "--help"]) + assert result.exit_code == 0 + assert "set" in result.stdout and "get" in result.stdout + assert "[cli]" in result.stdout + assert "\\" not in result.stdout + + def test_no_subcommand_shows_help(self, app): + assert "Usage" in _invoke(app, ["config"]).stdout + + def test_set_get_roundtrip(self, app, clean_env): + assert ( + _invoke(app, ["config", "set", "workspace", "ws-1"]).exit_code == 0 + ) + result = _invoke(app, ["config", "get", "workspace"]) + assert result.stdout.strip() == "ws-1" + assert ( + clean_env["config"].read_text(encoding="utf-8").count("workspace") + == 1 + ) + + def test_set_does_not_echo_the_value(self, app, clean_env): + result = _invoke(app, ["config", "set", "api_key", "sk-secret-123456"]) + assert result.exit_code == 0 + assert "sk-secret-123456" not in result.stdout + result.stderr + assert "set api_key" in result.stderr + + def test_set_is_silent_in_machine_formats(self, app, clean_env): + result = _invoke( + app, + ["-o", "json", "config", "set", "workspace", "ws-1"], + ) + assert json.loads(result.stdout) == {"key": "workspace"} + + def test_get_masks_api_key(self, app, clean_env): + _invoke(app, ["config", "set", "api_key", "sk-secret-123456"]) + masked = _invoke(app, ["config", "get", "api_key"]).stdout.strip() + assert masked != "sk-secret-123456" + assert masked.startswith("sk-s") and masked.endswith("56") + + def test_get_reveal_shows_api_key(self, app, clean_env): + _invoke(app, ["config", "set", "api_key", "sk-secret-123456"]) + assert ( + _invoke( + app, + ["config", "get", "api_key", "--reveal"], + ).stdout.strip() + == "sk-secret-123456" + ) + + def test_get_masks_in_json_too(self, app, clean_env): + _invoke(app, ["config", "set", "api_key", "sk-secret-123456"]) + result = _invoke(app, ["-o", "json", "config", "get", "api_key"]) + assert json.loads(result.stdout)["value"] != "sk-secret-123456" + + def test_get_unset_key_prints_nothing_and_succeeds(self, app, clean_env): + result = _invoke(app, ["config", "get", "workspace"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "" + + def test_get_unset_key_is_null_in_json(self, app, clean_env): + result = _invoke(app, ["-o", "json", "config", "get", "workspace"]) + assert json.loads(result.stdout) == {"key": "workspace", "value": None} + + def test_get_unknown_key_is_a_usage_error(self, app, clean_env): + result = _invoke(app, ["config", "get", "nope"]) + assert result.exit_code == 2 + assert "unknown config key" in result.stderr + + def test_set_invalid_value_is_a_usage_error(self, app, clean_env): + assert ( + _invoke(app, ["config", "set", "protocol", "bogus"]).exit_code == 2 + ) + + def test_list_shows_values_and_sources(self, app, clean_env): + _invoke(app, ["config", "set", "workspace", "ws-1"]) + result = _invoke(app, ["config", "list"]) + assert result.exit_code == 0 + assert "ws-1" in result.stdout + assert "config file" in result.stdout + assert "source" in result.stdout + + def test_list_shows_the_environment_layer( + self, + app, + clean_env, + monkeypatch, + ): + monkeypatch.setenv("DASHSCOPE_WORKSPACE", "ws-env") + result = _invoke(app, ["config", "list"]) + assert "ws-env" in result.stdout + assert "env DASHSCOPE_WORKSPACE" in result.stdout + + def test_list_in_json(self, app, clean_env): + _invoke(app, ["config", "set", "default_model", "qwen-plus"]) + result = _invoke(app, ["-o", "json", "config", "list"]) + rows = {row["key"]: row for row in json.loads(result.stdout)} + assert rows["cli.default_model"]["value"] == "qwen-plus" + + def test_config_commands_never_build_a_client( + self, + app, + clean_env, + make_client, + ): + make_client() + _invoke(app, ["config", "set", "workspace", "ws-1"]) + _invoke(app, ["config", "get", "workspace"]) + _invoke(app, ["config", "list"]) + assert make_client.calls == [] + + +class TestNoColor: + def test_flag_suppresses_ansi(self, app, clean_env): + result = _invoke( + app, + ["--no-color", "config", "set", "workspace", "ws-1"], + ) + assert "\x1b[" not in result.stderr + + def test_env_suppresses_ansi(self, app, clean_env, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + result = _invoke(app, ["config", "set", "workspace", "ws-1"]) + assert "\x1b[" not in result.stderr diff --git a/tests/cli/test_apps_cmd.py b/tests/cli/test_apps_cmd.py new file mode 100644 index 00000000..79433623 --- /dev/null +++ b/tests/cli/test_apps_cmd.py @@ -0,0 +1,448 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope apps`` -- Bailian application completions.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.apps import ( + AppCompletion, + AppStream, + AppUsage, + DocReference, + Thought, +) + + +def _completion(**overrides: Any) -> AppCompletion: + data: Dict[str, Any] = {"text": "hello", "finish_reason": "stop"} + data.update(overrides) + return AppCompletion(**data) + + +class _Gen: + """Stand-in for the SSE event iterator behind an ``AppStream``.""" + + def __init__(self, events: List[AppCompletion]) -> None: + self._events = events + self.closed = False + + def __iter__(self): + return iter(self._events) + + def close(self) -> None: + self.closed = True + + +def _stream(events: List[AppCompletion]) -> AppStream: + return AppStream(_Gen(events), SimpleNamespace(headers={})) + + +class _Completions: + """Stand-in for ``client.apps.completions``.""" + + def __init__(self, result_fn) -> None: + self.calls: List[Dict[str, Any]] = [] + self._result_fn = result_fn + + def create(self, **kwargs): + self.calls.append(kwargs) + return self._result_fn(kwargs) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def completions(make_client): + def install(result_fn=lambda kwargs: _completion()) -> _Completions: + recorder = _Completions(result_fn) + make_client(apps=SimpleNamespace(completions=recorder)) + return recorder + + return install + + +class TestCreate: + def test_prints_the_answer(self, invoke, completions): + recorder = completions() + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + ], + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "hello" + assert recorder.calls[0]["app_id"] == "app-1" + assert recorder.calls[0]["prompt"] == "hi" + + def test_app_id_is_required(self, invoke, completions): + completions() + result = invoke(["apps", "completions", "create", "--prompt", "hi"]) + assert result.exit_code == 2 + + def test_no_input_is_a_usage_error(self, invoke, completions): + recorder = completions() + result = invoke(["apps", "completions", "create", "--app-id", "app-1"]) + assert result.exit_code == 2 + assert "--prompt" in result.stderr + assert recorder.calls == [] + + def test_reads_a_piped_prompt_from_stdin(self, invoke, completions): + recorder = completions() + result = invoke( + ["apps", "completions", "create", "--app-id", "app-1"], + input="piped question\n", + ) + assert result.exit_code == 0 + assert recorder.calls[0]["prompt"] == "piped question" + + def test_prompt_and_messages_are_both_sent(self, invoke, completions): + recorder = completions() + invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "and again", + "--messages", + '[{"role": "user", "content": "hi"}]', + ], + ) + kwargs = recorder.calls[0] + assert kwargs["messages"] == [{"role": "user", "content": "hi"}] + # The SDK appends the prompt as the latest turn, so the CLI must + # not fold it into the transcript itself. + assert kwargs["prompt"] == "and again" + + def test_repeatable_media_and_options(self, invoke, completions): + recorder = completions() + invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--image", + "https://example.com/a.png", + "--image", + "b.png", + "--file", + "https://example.com/c.pdf", + "--doc-tag-code", + "tag-a", + "--doc-tag-code", + "tag-b", + "--session-id", + "sess-1", + "--memory-id", + "mem-1", + "--doc-reference-type", + "simple", + "--has-thoughts", + "--temperature", + "0.2", + "--top-p", + "0.9", + "--top-k", + "5", + "--seed", + "7", + ], + ) + kwargs = recorder.calls[0] + assert kwargs["image_list"] == ["https://example.com/a.png", "b.png"] + assert kwargs["file_list"] == ["https://example.com/c.pdf"] + assert kwargs["doc_tag_codes"] == ["tag-a", "tag-b"] + assert kwargs["session_id"] == "sess-1" + assert kwargs["memory_id"] == "mem-1" + assert kwargs["doc_reference_type"] == "simple" + assert kwargs["has_thoughts"] is True + assert kwargs["temperature"] == 0.2 + assert kwargs["top_p"] == 0.9 + assert kwargs["top_k"] == 5 + assert kwargs["seed"] == 7 + + def test_json_object_options(self, invoke, completions): + recorder = completions() + invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--biz-params", + '{"city": "hangzhou"}', + "--rag-options", + '{"pipeline_ids": ["p1"]}', + "--parameters", + '{"a": 1}', + ], + ) + kwargs = recorder.calls[0] + assert kwargs["biz_params"] == {"city": "hangzhou"} + assert kwargs["rag_options"] == {"pipeline_ids": ["p1"]} + assert kwargs["parameters"] == {"a": 1} + + def test_biz_params_must_be_an_object(self, invoke, completions): + completions() + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--biz-params", + "[1]", + ], + ) + assert result.exit_code == 2 + assert "--biz-params" in result.stderr + + def test_incremental_output_defaults_off_without_streaming( + self, + invoke, + completions, + ): + recorder = completions() + invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + ], + ) + assert recorder.calls[0]["incremental_output"] is None + + def test_stream_asks_for_incremental_output(self, invoke, completions): + recorder = completions( + result_fn=lambda kwargs: _stream([_completion(text="hi")]), + ) + invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--stream", + ], + ) + assert recorder.calls[0]["incremental_output"] is True + + def test_stream_honours_an_explicit_no_incremental_output( + self, + invoke, + completions, + ): + recorder = completions( + result_fn=lambda kwargs: _stream([_completion(text="hi")]), + ) + invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--stream", + "--no-incremental-output", + ], + ) + assert recorder.calls[0]["incremental_output"] is False + + +class TestRendering: + def test_diagnostics_stay_off_stdout(self, invoke, completions): + completions( + result_fn=lambda kwargs: _completion( + text="the answer", + session_id="sess-1", + thoughts=[Thought(thought="looking it up")], + doc_references=[ + DocReference( + doc_name="guide.pdf", + doc_url="https://example.com/guide.pdf", + ), + ], + usage=AppUsage(input_tokens=3, output_tokens=4), + ), + ) + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + ], + ) + assert result.stdout == "the answer\n" + assert "looking it up" in result.stderr + assert "guide.pdf" in result.stderr + assert "sess-1" in result.stderr + assert "'input_tokens': 3" in result.stderr + + def test_a_reference_without_a_name_falls_back_to_its_id( + self, + invoke, + completions, + ): + completions( + result_fn=lambda kwargs: _completion( + doc_references=[DocReference(doc_id="doc-9")], + ), + ) + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + ], + ) + assert "doc-9" in result.stderr + + def test_json_output_is_the_whole_completion(self, invoke, completions): + completions( + result_fn=lambda kwargs: _completion(session_id="sess-1"), + ) + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + ], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["text"] == "hello" + assert doc["session_id"] == "sess-1" + + +class TestStreaming: + def test_table_writes_incremental_text(self, invoke, completions): + inner = _Gen([_completion(text="Hel"), _completion(text="lo")]) + completions( + result_fn=lambda kwargs: AppStream( + inner, + SimpleNamespace(headers={}), + ), + ) + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--stream", + ], + ) + assert result.exit_code == 0 + assert result.stdout == "Hello\n" + assert inner.closed is True + + def test_json_is_one_object_per_line(self, invoke, completions): + completions( + result_fn=lambda kwargs: _stream( + [_completion(text="Hel"), _completion(text="lo")], + ), + ) + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--stream", + ], + global_args=["-o", "json"], + ) + lines = [json.loads(line) for line in result.stdout.splitlines()] + assert [line["text"] for line in lines] == ["Hel", "lo"] + + def test_a_stream_failure_exits_non_zero(self, invoke, completions): + from dashscope import errors as sdk_errors + + class _Broken(_Gen): + def __iter__(self): + yield _completion(text="Hel") + raise sdk_errors.DashScopeError("boom") + + completions( + result_fn=lambda kwargs: AppStream( + _Broken([]), + SimpleNamespace(headers={}), + ), + ) + result = invoke( + [ + "apps", + "completions", + "create", + "--app-id", + "app-1", + "--prompt", + "hi", + "--stream", + ], + ) + assert result.exit_code == 1 + assert "boom" in result.stderr diff --git a/tests/cli/test_audio_cmd.py b/tests/cli/test_audio_cmd.py new file mode 100644 index 00000000..e28c439d --- /dev/null +++ b/tests/cli/test_audio_cmd.py @@ -0,0 +1,978 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope audio`` -- tts and asr.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources._jobs import Job +from dashscope.resources.audio.http_tts import HttpSynthesis + +_AUDIO = b"\xff\xfb\x90\x00audio-bytes" + + +def _synthesis(**overrides: Any) -> HttpSynthesis: + data: Dict[str, Any] = { + "output": { + "audio": { + "url": "https://example.com/hello.mp3", + "expires_at": 1767225600, + }, + }, + "request_id": "req-1", + } + data.update(overrides) + return HttpSynthesis(**data) + + +def _job(**overrides: Any) -> Job: + data: Dict[str, Any] = { + "task_id": "task-abc123", + "status": "SUCCEEDED", + "output": { + "results": [ + { + "file_url": "https://example.com/a.wav", + "transcription_url": "https://example.com/a.json", + }, + ], + }, + } + data.update(overrides) + return Job(**data) + + +def _event( + text: str, + *, + end_time: Any = None, + name: str = "result-generated", + heartbeat: bool = False, +) -> Dict[str, Any]: + sentence: Dict[str, Any] = {"text": text} + if end_time is not None: + sentence["end_time"] = end_time + if heartbeat: + sentence["heartbeat"] = True + return { + "header": {"event": name}, + "payload": {"output": {"sentence": sentence}}, + } + + +class _Tts: + """Stand-in for ``client.audio.tts``.""" + + def __init__(self, http_result: Any = None) -> None: + self.calls: List[Dict[str, Any]] = [] + self.http = _HttpTts( + http_result if http_result is not None else _synthesis(), + ) + + def synthesize(self, text, **kwargs): + self.calls.append( + {"method": "synthesize", "text": text, "kwargs": kwargs}, + ) + return _AUDIO + + def stream(self, text, **kwargs): + self.calls.append( + {"method": "stream", "text": text, "kwargs": kwargs}, + ) + return iter((_AUDIO[:2], _AUDIO[2:])) + + +class _HttpTts: + def __init__(self, result: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._result = result + + def synthesize(self, text, **kwargs): + self.calls.append({"text": text, "kwargs": kwargs}) + return self._result + + +class _AsrJobs: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def create(self, **kwargs): + self.calls.append({"method": "create", "args": (), "kwargs": kwargs}) + return self._results.get("create", _job(status="PENDING", output=None)) + + def fetch(self, task_id, **kwargs): + self.calls.append( + {"method": "fetch", "args": (task_id,), "kwargs": kwargs}, + ) + return self._results.get("fetch", _job()) + + def wait(self, job, **kwargs): + self.calls.append({"method": "wait", "args": (job,), "kwargs": kwargs}) + return self._results.get("wait", job) + + +class _Asr: + """Stand-in for ``client.audio.asr``.""" + + def __init__(self, *, transcribe=None, events=None, **jobs: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self.jobs = _AsrJobs(**jobs) + self._transcribe = transcribe if transcribe is not None else _job() + self._events = events if events is not None else [] + + def transcribe(self, file_urls, **kwargs): + self.calls.append( + {"method": "transcribe", "args": (file_urls,), "kwargs": kwargs}, + ) + return self._transcribe + + def recognize_file(self, path, **kwargs): + self.calls.append( + {"method": "recognize_file", "args": (path,), "kwargs": kwargs}, + ) + return iter(self._events) + + +class _RealtimeSession: + """Stand-in for one ``client.audio.realtime.connect()`` session.""" + + def __init__(self, events: List[Dict[str, Any]]) -> None: + self.calls: List[Dict[str, Any]] = [] + self.fed = bytearray() + self.chunks: List[int] = [] + self.closed = False + self._events = events + + def __enter__(self) -> "_RealtimeSession": + return self + + def __exit__(self, *exc: Any) -> None: + self.closed = True + + def update_session(self, **kwargs): + self.calls.append({"method": "update_session", "kwargs": kwargs}) + + def append_audio(self, chunk: bytes): + self.chunks.append(len(chunk)) + self.fed.extend(chunk) + + def commit(self): + self.calls.append({"method": "commit"}) + + def create_response(self, **kwargs): + self.calls.append({"method": "create_response", "kwargs": kwargs}) + + def events(self): + return iter(self._events) + + +class _Realtime: + """Stand-in for ``client.audio.realtime``.""" + + def __init__(self, session: _RealtimeSession) -> None: + self.calls: List[Dict[str, Any]] = [] + self._session = session + + def connect(self, **kwargs): + self.calls.append(kwargs) + return self._session + + +def _rt_event(kind: str, **fields: Any) -> Dict[str, Any]: + return {"type": kind, **fields} + + +def _turn(text: str = "hi there") -> List[Dict[str, Any]]: + """One complete realtime turn, ending with the event that closes it.""" + return [ + _rt_event( + "conversation.item.input_audio_transcription.completed", + transcript="what time is it", + ), + _rt_event("response.audio.delta", delta="QUJD"), + _rt_event("response.text.delta", delta=text), + _rt_event( + "response.done", + response={"id": "resp-1", "usage": {"input_tokens": 20}}, + ), + # Never reached: the turn is over at response.done. + _rt_event("session.finished"), + ] + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def audio(make_client): + def install(**asr_kwargs: Any) -> SimpleNamespace: + tts = _Tts(asr_kwargs.pop("http_result", None)) + asr = _Asr(**asr_kwargs) + make_client(audio=SimpleNamespace(tts=tts, asr=asr)) + return SimpleNamespace(tts=tts, asr=asr) + + return install + + +@pytest.fixture +def realtime(make_client): + def install(events: List[Dict[str, Any]] = None) -> SimpleNamespace: + session = _RealtimeSession(events if events is not None else _turn()) + resource = _Realtime(session) + make_client(audio=SimpleNamespace(realtime=resource)) + return SimpleNamespace(connect=resource, session=session) + + return install + + +class TestTtsSynthesize: + def test_writes_the_audio_to_stdout(self, invoke, audio): + resource = audio() + result = invoke(["audio", "tts", "synthesize", "hello"]) + assert result.exit_code == 0 + assert result.stdout_bytes == _AUDIO + assert resource.tts.calls[0]["text"] == "hello" + + def test_writes_the_audio_to_a_file(self, invoke, audio, tmp_path): + audio() + out = tmp_path / "hello.mp3" + result = invoke( + ["audio", "tts", "synthesize", "hello", "--out", str(out)], + ) + assert result.exit_code == 0 + assert out.read_bytes() == _AUDIO + # Nothing on stdout: a binary payload must not be echoed twice. + assert result.stdout_bytes == b"" + assert "wrote 15 bytes" in result.stderr + + def test_passes_the_voice_options(self, invoke, audio): + resource = audio() + invoke( + [ + "audio", + "tts", + "synthesize", + "hello", + "-m", + "cosyvoice-v2", + "--voice", + "longcheng", + "--format", + "wav", + "--sample-rate", + "48000", + "--volume", + "80", + "--rate", + "1.2", + "--pitch", + "0.9", + "--language-hint", + "zh", + "--language-hint", + "en", + "--enable-ssml", + "--parameters", + '{"a": 1}', + ], + ) + kwargs = resource.tts.calls[0]["kwargs"] + assert kwargs["model"] == "cosyvoice-v2" + assert kwargs["voice"] == "longcheng" + assert kwargs["format"] == "wav" + assert kwargs["sample_rate"] == 48000 + assert kwargs["volume"] == 80 + assert kwargs["rate"] == 1.2 + assert kwargs["pitch"] == 0.9 + assert kwargs["language_hints"] == ["zh", "en"] + assert kwargs["enable_ssml"] is True + assert kwargs["parameters"] == {"a": 1} + + def test_reads_the_text_from_a_file(self, invoke, audio, tmp_path): + payload = tmp_path / "script.txt" + payload.write_text("a long script\n", encoding="utf-8") + resource = audio() + invoke(["audio", "tts", "synthesize", f"@{payload}"]) + assert resource.tts.calls[0]["text"] == "a long script\n" + + def test_reads_the_text_from_stdin(self, invoke, audio): + resource = audio() + result = invoke( + ["audio", "tts", "synthesize", "-"], + input="from stdin\n", + ) + assert result.exit_code == 0 + assert resource.tts.calls[0]["text"] == "from stdin\n" + + def test_refuses_blank_text(self, invoke, audio): + resource = audio() + result = invoke(["audio", "tts", "synthesize", " "]) + assert result.exit_code == 2 + assert "no text given" in result.stderr + assert resource.tts.calls == [] + + def test_a_missing_output_file_is_a_usage_error( + self, + invoke, + audio, + tmp_path, + ): + audio() + result = invoke( + [ + "audio", + "tts", + "synthesize", + "hello", + "--out", + str(tmp_path / "nope" / "hello.mp3"), + ], + ) + assert result.exit_code == 2 + assert "cannot write" in result.stderr + + +class TestTtsStream: + def test_streams_the_audio_to_stdout(self, invoke, audio): + resource = audio() + result = invoke(["audio", "tts", "stream", "hello"]) + assert result.exit_code == 0 + assert result.stdout_bytes == _AUDIO + assert resource.tts.calls[0]["method"] == "stream" + assert resource.tts.calls[0]["text"] == "hello" + + def test_streams_the_audio_to_a_file(self, invoke, audio, tmp_path): + resource = audio() + out = tmp_path / "hello.mp3" + result = invoke( + ["audio", "tts", "stream", "hello", "--out", str(out)], + ) + assert result.exit_code == 0 + assert out.read_bytes() == _AUDIO + assert result.stdout_bytes == b"" + assert resource.tts.calls[0]["method"] == "stream" + assert "wrote 15 bytes" in result.stderr + + def test_shares_the_synthesis_options(self, invoke, audio): + resource = audio() + result = invoke( + [ + "audio", + "tts", + "stream", + "hello", + "-m", + "cosyvoice-v2", + "--voice", + "longcheng", + "--parameters", + '{"a": 1}', + ], + ) + assert result.exit_code == 0 + kwargs = resource.tts.calls[0]["kwargs"] + assert kwargs["model"] == "cosyvoice-v2" + assert kwargs["voice"] == "longcheng" + assert kwargs["parameters"] == {"a": 1} + + +class TestTtsHttp: + def test_prints_the_audio_url(self, invoke, audio): + resource = audio() + result = invoke( + [ + "audio", + "tts", + "http", + "hello", + "-m", + "qwen3-tts-flash", + "--voice", + "Cherry", + ], + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "https://example.com/hello.mp3" + call = resource.tts.http.calls[0] + assert call["text"] == "hello" + assert call["kwargs"]["model"] == "qwen3-tts-flash" + assert call["kwargs"]["voice"] == "Cherry" + + def test_model_and_voice_are_required(self, invoke, audio): + audio() + assert invoke(["audio", "tts", "http", "hello"]).exit_code == 2 + + def test_json_output_is_the_whole_response(self, invoke, audio): + audio() + result = invoke( + [ + "audio", + "tts", + "http", + "hello", + "-m", + "qwen3-tts-flash", + "--voice", + "Cherry", + ], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["output"]["audio"]["url"] == "https://example.com/hello.mp3" + + def test_passes_the_extra_options(self, invoke, audio): + resource = audio() + invoke( + [ + "audio", + "tts", + "http", + "hello", + "-m", + "qwen3-tts-flash", + "--voice", + "Cherry", + "--format", + "wav", + "--sample-rate", + "24000", + "--language-type", + "zh", + "--extra-input", + '{"text_type": "ssml"}', + "--parameters", + '{"a": 1}', + ], + ) + kwargs = resource.tts.http.calls[0]["kwargs"] + assert kwargs["format"] == "wav" + assert kwargs["sample_rate"] == 24000 + assert kwargs["language_type"] == "zh" + assert kwargs["extra_input"] == {"text_type": "ssml"} + assert kwargs["parameters"] == {"a": 1} + + +class TestAsrTranscribe: + def test_waits_by_default_and_prints_the_transcripts(self, invoke, audio): + resource = audio() + result = invoke( + ["audio", "asr", "transcribe", "https://example.com/a.wav"], + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "https://example.com/a.json" + call = resource.asr.calls[0] + assert call["method"] == "transcribe" + assert call["args"] == (["https://example.com/a.wav"],) + assert call["kwargs"]["timeout"] == 3600.0 + + def test_sends_every_url(self, invoke, audio): + resource = audio() + invoke( + [ + "audio", + "asr", + "transcribe", + "https://example.com/a.wav", + "https://example.com/b.wav", + "-m", + "paraformer-v2", + "--phrase-id", + "phrase-1", + "--parameters", + '{"channel_id": [0]}', + ], + ) + kwargs = resource.asr.calls[0]["kwargs"] + assert kwargs["model"] == "paraformer-v2" + assert kwargs["phrase_id"] == "phrase-1" + assert kwargs["parameters"] == {"channel_id": [0]} + + def test_no_wait_only_submits(self, invoke, audio): + resource = audio() + result = invoke( + [ + "audio", + "asr", + "transcribe", + "https://example.com/a.wav", + "--no-wait", + ], + ) + assert result.exit_code == 0 + assert "task-abc123" in result.stdout + assert resource.asr.calls == [] + assert resource.asr.jobs.calls[0]["method"] == "create" + assert resource.asr.jobs.calls[0]["kwargs"]["file_urls"] == [ + "https://example.com/a.wav", + ] + + def test_requires_a_url(self, invoke, audio): + audio() + assert invoke(["audio", "asr", "transcribe"]).exit_code == 2 + + def test_a_running_task_shows_its_state(self, invoke, audio): + audio(transcribe=_job(status="RUNNING", output=None)) + result = invoke( + ["audio", "asr", "transcribe", "https://example.com/a.wav"], + ) + assert "RUNNING" in result.stdout + + def test_json_output_is_the_whole_job(self, invoke, audio): + audio() + result = invoke( + ["audio", "asr", "transcribe", "https://example.com/a.wav"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["output"]["results"][0]["transcription_url"] == ( + "https://example.com/a.json" + ) + + +class TestAsrJobs: + def test_get(self, invoke, audio): + resource = audio() + invoke(["audio", "asr", "get", "task-abc123"]) + assert resource.asr.jobs.calls[0]["args"] == ("task-abc123",) + + def test_wait_passes_the_timeout(self, invoke, audio): + # ``wait`` takes an id and answers with the finished job. + resource = audio(wait=_job()) + result = invoke( + ["audio", "asr", "wait", "task-abc123", "--timeout", "30"], + ) + assert result.exit_code == 0 + call = resource.asr.jobs.calls[0] + assert call["method"] == "wait" + assert call["args"] == ("task-abc123",) + assert call["kwargs"]["timeout"] == 30.0 + assert result.stdout.strip() == "https://example.com/a.json" + + @pytest.mark.parametrize("timeout", ["0", "nan"]) + def test_wait_rejects_a_timeout_it_could_not_honour( + self, + invoke, + audio, + timeout, + ): + """``nan`` is false against every comparison, so the poll never ends.""" + resource = audio(wait=_job()) + result = invoke( + ["audio", "asr", "wait", "task-abc123", "--timeout", timeout], + ) + assert result.exit_code == 2 + assert "--timeout" in result.stderr + assert resource.asr.jobs.calls == [] + + +class TestAsrStream: + def test_prints_only_the_final_sentences(self, invoke, audio, tmp_path): + source = tmp_path / "a.wav" + source.write_bytes(b"RIFF") + resource = audio( + events=[ + _event("hel"), + _event("hello", end_time=900), + _event("", heartbeat=True), + _event("ignored", name="task-finished"), + ], + ) + result = invoke( + [ + "audio", + "asr", + "stream", + str(source), + "-m", + "paraformer-realtime-v2", + ], + ) + assert result.exit_code == 0 + assert result.stdout == "hello\n" + call = resource.asr.calls[0] + assert call["method"] == "recognize_file" + assert call["args"] == (str(source),) + + def test_passes_the_recognition_options(self, invoke, audio, tmp_path): + source = tmp_path / "a.wav" + source.write_bytes(b"RIFF") + resource = audio() + invoke( + [ + "audio", + "asr", + "stream", + str(source), + "-m", + "paraformer-realtime-v2", + "--format", + "wav", + "--sample-rate", + "8000", + "--vocabulary-id", + "vocab-1", + "--disfluency-removal", + "--no-diarization", + "--parameters", + '{"a": 1}', + ], + ) + kwargs = resource.asr.calls[0]["kwargs"] + assert kwargs["model"] == "paraformer-realtime-v2" + assert kwargs["format"] == "wav" + assert kwargs["sample_rate"] == 8000 + assert kwargs["vocabulary_id"] == "vocab-1" + assert kwargs["disfluency_removal_enabled"] is True + assert kwargs["diarization_enabled"] is False + assert kwargs["parameters"] == {"a": 1} + + def test_json_output_is_one_event_per_line(self, invoke, audio, tmp_path): + source = tmp_path / "a.wav" + source.write_bytes(b"RIFF") + audio(events=[_event("hel"), _event("hello", end_time=900)]) + result = invoke( + [ + "audio", + "asr", + "stream", + str(source), + "-m", + "paraformer-realtime-v2", + ], + global_args=["-o", "json"], + ) + lines = [json.loads(line) for line in result.stdout.splitlines()] + assert len(lines) == 2 + assert lines[1]["payload"]["output"]["sentence"]["end_time"] == 900 + + def test_a_missing_file_is_a_usage_error(self, invoke, audio, tmp_path): + resource = audio() + result = invoke( + [ + "audio", + "asr", + "stream", + str(tmp_path / "nope.wav"), + "-m", + "paraformer-realtime-v2", + ], + ) + assert result.exit_code == 2 + assert "not found" in result.stderr + assert resource.asr.calls == [] + + +class TestRealtimeRun: + def test_feeds_the_file_and_prints_the_answer( + self, + invoke, + realtime, + tmp_path, + ): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00\x01" * 100) + resource = realtime() + result = invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + ], + ) + assert result.exit_code == 0 + assert result.stdout == "hi there\n" + assert resource.connect.calls == [ + {"model": "qwen3-omni-flash-realtime", "timeout": None}, + ] + session = resource.session + assert bytes(session.fed) == source.read_bytes() + assert [call["method"] for call in session.calls] == [ + "update_session", + "commit", + "create_response", + ] + # The socket is hung up even though the turn ended cleanly. + assert session.closed is True + + def test_long_audio_is_sent_in_chunks(self, invoke, realtime, tmp_path): + from dashscope.cli.commands.audio import AUDIO_CHUNK_SIZE + + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00" * (AUDIO_CHUNK_SIZE * 2 + 7)) + resource = realtime() + invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + ], + ) + assert resource.session.chunks == [ + AUDIO_CHUNK_SIZE, + AUDIO_CHUNK_SIZE, + 7, + ] + + def test_the_input_transcript_goes_to_stderr( + self, + invoke, + realtime, + tmp_path, + ): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + realtime() + result = invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + ], + ) + assert "input: what time is it" in result.stderr + assert "what time is it" not in result.stdout + + def test_the_reply_audio_lands_in_out(self, invoke, realtime, tmp_path): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + reply = tmp_path / "reply.wav" + realtime() + result = invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + "--out", + str(reply), + ], + ) + assert result.exit_code == 0 + assert reply.read_bytes() == b"ABC" + # Text stays on stdout, so the two payloads never share a redirect. + assert result.stdout == "hi there\n" + + def test_without_out_the_reply_audio_is_dropped( + self, + invoke, + realtime, + tmp_path, + ): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + realtime() + result = invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + ], + ) + assert result.stdout_bytes == b"hi there\n" + + def test_instructions_drive_the_response(self, invoke, realtime, tmp_path): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + resource = realtime() + invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + "--instructions", + "answer in one word", + ], + ) + response = [ + call + for call in resource.session.calls + if call["method"] == "create_response" + ][0] + assert response["kwargs"] == {"instructions": "answer in one word"} + + def test_session_options_are_passed(self, invoke, realtime, tmp_path): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + resource = realtime() + invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + "--input-format", + "wav", + "--output-format", + "wav", + "--voice", + "Cherry", + "--modality", + "text", + "--modality", + "audio", + "--transcribe-input", + "--timeout", + "30", + ], + ) + assert resource.connect.calls[0]["timeout"] == 30.0 + settings = [ + call + for call in resource.session.calls + if call["method"] == "update_session" + ][0]["kwargs"] + assert settings == { + "input_audio_format": "wav", + "voice": "Cherry", + "output_audio_format": "wav", + "output_modalities": ["text", "audio"], + "enable_input_audio_transcription": True, + } + + def test_unset_options_are_left_out_entirely( + self, + invoke, + realtime, + tmp_path, + ): + """``update_session`` is sentinel-defaulted: None would unset them.""" + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + resource = realtime() + invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + ], + ) + settings = resource.session.calls[0]["kwargs"] + assert settings == {"input_audio_format": "pcm"} + + def test_json_output_is_every_event_of_the_turn( + self, + invoke, + realtime, + tmp_path, + ): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + realtime() + result = invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + ], + global_args=["-o", "json"], + ) + lines = [json.loads(line) for line in result.stdout.splitlines()] + assert [line["type"] for line in lines] == [ + "conversation.item.input_audio_transcription.completed", + "response.audio.delta", + "response.text.delta", + "response.done", + ] + + def test_the_usage_note_goes_to_stderr(self, invoke, realtime, tmp_path): + source = tmp_path / "in.pcm" + source.write_bytes(b"\x00") + realtime() + result = invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(source), + ], + ) + assert "input_tokens" in result.stderr + assert "usage" not in result.stdout + + def test_a_missing_audio_file_is_a_usage_error( + self, + invoke, + realtime, + tmp_path, + ): + resource = realtime() + result = invoke( + [ + "audio", + "realtime", + "run", + "-m", + "qwen3-omni-flash-realtime", + "--audio", + str(tmp_path / "nope.pcm"), + ], + ) + assert result.exit_code == 2 + assert "not found" in result.stderr + assert resource.connect.calls == [] + + def test_no_audio_at_all_is_a_usage_error(self, invoke, realtime): + """The runner's stdin is an empty pipe, so there is nothing to feed.""" + resource = realtime() + result = invoke(["audio", "realtime", "run", "-m", "qwen3-omni"]) + assert result.exit_code == 2 + assert "no audio given" in result.stderr + assert resource.connect.calls == [] + + def test_the_model_is_required(self, invoke, realtime): + realtime() + assert invoke(["audio", "realtime", "run"]).exit_code == 2 diff --git a/tests/cli/test_audio_customization_cmd.py b/tests/cli/test_audio_customization_cmd.py new file mode 100644 index 00000000..cd9ad134 --- /dev/null +++ b/tests/cli/test_audio_customization_cmd.py @@ -0,0 +1,602 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope audio`` management plane: vocabularies, phrases, voices.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.audio.customization import ( + DesignedVoice, + DesignedVoiceList, + Vocabulary, + VocabularyList, + Voice, + VoiceList, +) +from dashscope.resources.audio.phrases import Phrase, PhraseList +from dashscope.types.common import JobStatus + +_ENTRIES = [{"text": "心肌梗死", "weight": 4, "lang": "zh"}] + + +def _vocabulary(**overrides: Any) -> Vocabulary: + data: Dict[str, Any] = { + "vocabulary_id": "vocab-med-1", + "prefix": "med", + "target_model": "paraformer-v2", + "status": "OK", + "request_id": "req-1", + } + data.update(overrides) + return Vocabulary(**data) + + +def _voice(**overrides: Any) -> Voice: + data: Dict[str, Any] = { + "voice_id": "cosyvoice-v2-mine-1", + "status": "OK", + "target_model": "cosyvoice-v2", + "request_id": "req-1", + } + data.update(overrides) + return Voice(**data) + + +def _designed_voice(**overrides: Any) -> DesignedVoice: + data: Dict[str, Any] = { + "voice_id": "cosyvoice-v3-plus-design-1", + "status": "DEPLOYING", + "target_model": "cosyvoice-v3-plus", + "voice_prompt": "a calm narrator", + "preview_text": "hello", + "request_id": "req-1", + } + data.update(overrides) + return DesignedVoice(**data) + + +def _phrase(**overrides: Any) -> Phrase: + data: Dict[str, Any] = { + "phrase_id": "phrase-1", + "status": "OK", + "request_id": "req-1", + } + data.update(overrides) + return Phrase(**data) + + +class _Recorder: + """One registry stand-in: every method records and returns its value.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _answer(self, method: str, default: Any) -> Any: + return self._results.get(method, default) + + def create(self, **kwargs): + self.calls.append({"method": "create", "args": (), "kwargs": kwargs}) + return self._answer("create", self._default_create()) + + def list(self, **kwargs): # pylint: disable=redefined-builtin + self.calls.append({"method": "list", "args": (), "kwargs": kwargs}) + return self._answer("list", self._default_list()) + + def get(self, resource_id, **kwargs): + self.calls.append( + {"method": "get", "args": (resource_id,), "kwargs": kwargs}, + ) + return self._answer("get", self._default_create()) + + def update(self, resource_id, **kwargs): + self.calls.append( + {"method": "update", "args": (resource_id,), "kwargs": kwargs}, + ) + return self._answer("update", JobStatus(status="OK")) + + def delete(self, resource_id, **kwargs): + self.calls.append( + {"method": "delete", "args": (resource_id,), "kwargs": kwargs}, + ) + return self._answer("delete", JobStatus(status="DELETED")) + + def _default_create(self) -> Any: # pragma: no cover - overridden + raise NotImplementedError + + def _default_list(self) -> Any: # pragma: no cover - overridden + raise NotImplementedError + + +class _Vocabularies(_Recorder): + def _default_create(self) -> Any: + return _vocabulary() + + def _default_list(self) -> Any: + return VocabularyList( + vocabularies=[ + { + "vocabulary_id": "vocab-med-1", + "prefix": "med", + "target_model": "paraformer-v2", + "status": "OK", + }, + ], + ) + + +class _Voices(_Recorder): + def _default_create(self) -> Any: + return _voice() + + def _default_list(self) -> Any: + return VoiceList( + voices=[ + { + "voice_id": "cosyvoice-v2-mine-1", + "status": "OK", + "target_model": "cosyvoice-v2", + }, + ], + ) + + +class _VoiceDesign(_Recorder): + def _default_create(self) -> Any: + return _designed_voice() + + def _default_list(self) -> Any: + return DesignedVoiceList( + voices=[ + { + "voice_id": "cosyvoice-v3-plus-design-1", + "status": "DEPLOYING", + "target_model": "cosyvoice-v3-plus", + }, + ], + ) + + def update(self, *args, **kwargs): # pragma: no cover - not on the wire + raise AssertionError("voice-design has no update") + + +class _Phrases(_Recorder): + def _default_create(self) -> Any: + return _phrase() + + def _default_list(self) -> Any: + return PhraseList( + page_no=1, + page_size=10, + total_count=2, + phrases=[_phrase(), _phrase(phrase_id="phrase-2")], + ) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def registries(make_client): + """Install all four registries and hand them back by name.""" + + def install(**overrides: Any) -> SimpleNamespace: + vocabularies = _Vocabularies(**overrides.pop("vocabularies", {})) + phrases = _Phrases(**overrides.pop("phrases", {})) + voices = _Voices(**overrides.pop("voices", {})) + voice_design = _VoiceDesign(**overrides.pop("voice_design", {})) + audio = SimpleNamespace( + vocabularies=vocabularies, + phrases=phrases, + voices=voices, + voice_design=voice_design, + ) + make_client(audio=audio) + return audio + + return install + + +class TestVocabularies: + def test_create_sends_the_entries(self, invoke, registries): + audio = registries() + result = invoke( + [ + "audio", + "vocabularies", + "create", + "--target-model", + "paraformer-v2", + "--prefix", + "med", + "--vocabulary", + json.dumps(_ENTRIES), + ], + ) + assert result.exit_code == 0 + assert "vocab-med-1" in result.stdout + kwargs = audio.vocabularies.calls[0]["kwargs"] + assert kwargs["target_model"] == "paraformer-v2" + assert kwargs["prefix"] == "med" + assert kwargs["vocabulary"] == _ENTRIES + assert kwargs["model"] is None + + def test_create_reads_the_entries_from_a_file( + self, + invoke, + registries, + tmp_path, + ): + payload = tmp_path / "words.json" + payload.write_text(json.dumps(_ENTRIES), encoding="utf-8") + audio = registries() + invoke( + [ + "audio", + "vocabularies", + "create", + "--target-model", + "paraformer-v2", + "--prefix", + "med", + "--vocabulary", + f"@{payload}", + ], + ) + assert audio.vocabularies.calls[0]["kwargs"]["vocabulary"] == _ENTRIES + + def test_create_rejects_a_json_object(self, invoke, registries): + """The service wants an array of entries, not one entry.""" + audio = registries() + result = invoke( + [ + "audio", + "vocabularies", + "create", + "--target-model", + "paraformer-v2", + "--prefix", + "med", + "--vocabulary", + '{"text": "x"}', + ], + ) + assert result.exit_code == 2 + assert "expected a JSON array" in result.stderr + assert audio.vocabularies.calls == [] + + def test_list_pages_from_zero(self, invoke, registries): + audio = registries() + result = invoke( + ["audio", "vocabularies", "list", "--prefix", "med"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + kwargs = audio.vocabularies.calls[0]["kwargs"] + assert kwargs == {"prefix": "med", "page_index": 0, "page_size": 10} + assert json.loads(result.stdout)["vocabularies"][0]["prefix"] == "med" + + def test_get_shows_one_table(self, invoke, registries): + audio = registries() + result = invoke(["audio", "vocabularies", "get", "vocab-med-1"]) + assert result.exit_code == 0 + assert "vocab-med-1" in result.stdout + assert audio.vocabularies.calls[0]["args"] == ("vocab-med-1",) + + def test_update_replaces_the_contents(self, invoke, registries): + audio = registries() + invoke( + [ + "audio", + "vocabularies", + "update", + "vocab-med-1", + "--vocabulary", + json.dumps(_ENTRIES), + ], + ) + call = audio.vocabularies.calls[0] + assert call["args"] == ("vocab-med-1",) + assert call["kwargs"]["vocabulary"] == _ENTRIES + + def test_delete_needs_a_confirmation(self, invoke, registries): + audio = registries() + result = invoke(["audio", "vocabularies", "delete", "vocab-med-1"]) + assert result.exit_code != 0 + assert audio.vocabularies.calls == [] + + def test_delete_with_yes_goes_through(self, invoke, registries): + audio = registries() + result = invoke( + ["audio", "vocabularies", "delete", "vocab-med-1", "--yes"], + ) + assert result.exit_code == 0 + assert "DELETED" in result.stdout + assert audio.vocabularies.calls[0]["method"] == "delete" + + +class TestPhrases: + def test_create_compiles_a_phrase_set(self, invoke, registries): + audio = registries() + result = invoke( + [ + "audio", + "phrases", + "create", + "--phrases", + '{"class_a": ["alpha", "beta"]}', + ], + ) + assert result.exit_code == 0 + assert "phrase-1" in result.stdout + kwargs = audio.phrases.calls[0]["kwargs"] + assert kwargs["phrases"] == {"class_a": ["alpha", "beta"]} + assert kwargs["training_type"] == "compile_asr_phrase" + + def test_create_rejects_an_empty_set(self, invoke, registries): + audio = registries() + result = invoke(["audio", "phrases", "create", "--phrases", "{}"]) + assert result.exit_code == 2 + assert "must not be empty" in result.stderr + assert audio.phrases.calls == [] + + def test_list_pages_from_one(self, invoke, registries): + """This endpoint is fine-tune-shaped, unlike its three siblings.""" + audio = registries() + result = invoke(["audio", "phrases", "list", "--page", "2"]) + assert result.exit_code == 0 + assert audio.phrases.calls[0]["kwargs"] == { + "page_no": 2, + "page_size": 10, + } + assert "page 1/1, total 2" in result.stderr + + def test_update_recompiles(self, invoke, registries): + audio = registries() + invoke( + [ + "audio", + "phrases", + "update", + "phrase-1", + "--phrases", + '{"class_a": ["gamma"]}', + "--training-type", + "compile_asr_phrase_v2", + ], + ) + call = audio.phrases.calls[0] + assert call["args"] == ("phrase-1",) + assert call["kwargs"]["training_type"] == "compile_asr_phrase_v2" + + def test_delete_with_yes_goes_through(self, invoke, registries): + audio = registries() + result = invoke(["audio", "phrases", "delete", "phrase-1", "-y"]) + assert result.exit_code == 0 + assert audio.phrases.calls[0]["method"] == "delete" + + +class TestVoices: + def test_create_clones_from_a_sample(self, invoke, registries): + audio = registries() + result = invoke( + [ + "audio", + "voices", + "create", + "--target-model", + "cosyvoice-v2", + "--prefix", + "mine", + "--url", + "https://example.com/sample.wav", + "--language-hint", + "zh", + "--max-prompt-audio-length", + "8.5", + "--extra-input", + '{"a": 1}', + ], + ) + assert result.exit_code == 0 + assert "cosyvoice-v2-mine-1" in result.stdout + kwargs = audio.voices.calls[0]["kwargs"] + assert kwargs["target_model"] == "cosyvoice-v2" + assert kwargs["prefix"] == "mine" + assert kwargs["url"] == "https://example.com/sample.wav" + assert kwargs["language_hints"] == ["zh"] + assert kwargs["max_prompt_audio_length"] == 8.5 + assert kwargs["extra_input"] == {"a": 1} + + def test_unset_options_stay_unset(self, invoke, registries): + audio = registries() + invoke( + [ + "audio", + "voices", + "create", + "--target-model", + "cosyvoice-v2", + "--prefix", + "mine", + "--url", + "https://example.com/sample.wav", + ], + ) + kwargs = audio.voices.calls[0]["kwargs"] + assert kwargs["language_hints"] is None + assert kwargs["max_prompt_audio_length"] is None + assert kwargs["extra_input"] is None + + def test_list_shares_the_registry_with_design(self, invoke, registries): + audio = registries() + result = invoke( + ["audio", "voices", "list"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + assert audio.voices.calls[0]["kwargs"] == { + "prefix": None, + "page_index": 0, + "page_size": 10, + } + assert json.loads(result.stdout)["voices"][0]["voice_id"] == ( + "cosyvoice-v2-mine-1" + ) + + def test_update_re_clones(self, invoke, registries): + audio = registries() + invoke( + [ + "audio", + "voices", + "update", + "cosyvoice-v2-mine-1", + "--url", + "https://example.com/better.wav", + ], + ) + call = audio.voices.calls[0] + assert call["args"] == ("cosyvoice-v2-mine-1",) + assert call["kwargs"] == {"url": "https://example.com/better.wav"} + + def test_delete_with_yes_goes_through(self, invoke, registries): + audio = registries() + result = invoke( + ["audio", "voices", "delete", "cosyvoice-v2-mine-1", "--yes"], + ) + assert result.exit_code == 0 + assert audio.voices.calls[0]["method"] == "delete" + + +class TestVoiceDesign: + def test_create_sends_both_texts(self, invoke, registries): + audio = registries() + result = invoke( + [ + "audio", + "voice-design", + "create", + "--target-model", + "cosyvoice-v3-plus", + "--prefix", + "design", + "--voice-prompt", + "a calm narrator", + "--preview-text", + "hello there", + "--language-hint", + "en", + ], + ) + assert result.exit_code == 0 + assert "DEPLOYING" in result.stdout + kwargs = audio.voice_design.calls[0]["kwargs"] + assert kwargs["voice_prompt"] == "a calm narrator" + assert kwargs["preview_text"] == "hello there" + assert kwargs["language_hints"] == ["en"] + + def test_create_reads_the_prompt_from_stdin(self, invoke, registries): + audio = registries() + result = invoke( + [ + "audio", + "voice-design", + "create", + "--target-model", + "cosyvoice-v3-plus", + "--prefix", + "design", + "--voice-prompt", + "-", + "--preview-text", + "hello", + ], + input="a warm voice\n", + ) + assert result.exit_code == 0 + assert audio.voice_design.calls[0]["kwargs"]["voice_prompt"] == ( + "a warm voice\n" + ) + + def test_an_empty_prompt_is_a_usage_error(self, invoke, registries): + audio = registries() + result = invoke( + [ + "audio", + "voice-design", + "create", + "--target-model", + "cosyvoice-v3-plus", + "--prefix", + "design", + "--voice-prompt", + " ", + "--preview-text", + "hello", + ], + ) + assert result.exit_code == 2 + assert "--voice-prompt must not be empty" in result.stderr + assert audio.voice_design.calls == [] + + def test_get_polls_until_it_is_usable(self, invoke, registries): + """The docstring tells the caller to poll; `get` is how.""" + audio = registries( + voice_design={"get": _designed_voice(status="OK")}, + ) + result = invoke( + ["audio", "voice-design", "get", "cosyvoice-v3-plus-design-1"], + ) + assert result.exit_code == 0 + assert "OK" in result.stdout + assert audio.voice_design.calls[0]["method"] == "get" + + def test_delete_with_yes_goes_through(self, invoke, registries): + audio = registries() + result = invoke( + [ + "audio", + "voice-design", + "delete", + "cosyvoice-v3-plus-design-1", + "--yes", + ], + ) + assert result.exit_code == 0 + assert audio.voice_design.calls[0]["method"] == "delete" + + +class TestGroupShape: + def test_the_four_registries_are_subgroups_of_audio(self, app): + import typer + + audio = typer.main.get_command(app).commands["audio"] + for name in ("vocabularies", "phrases", "voices", "voice-design"): + assert name in audio.commands + + def test_voice_design_has_no_update(self, app): + """The service has none, so the CLI must not invent one.""" + import typer + + audio = typer.main.get_command(app).commands["audio"] + assert "update" not in audio.commands["voice-design"].commands + + def test_bare_subgroup_shows_help(self, app, runner): + assert "Usage" in runner.invoke(app, ["audio", "vocabularies"]).stdout diff --git a/tests/cli/test_auth_cmd.py b/tests/cli/test_auth_cmd.py new file mode 100644 index 00000000..33a4cdcb --- /dev/null +++ b/tests/cli/test_auth_cmd.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope auth`` -- v2 key-file management and validation.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest +import typer + +from dashscope.cli.app import build_app + + +class _Models: + def __init__(self): + self.calls = [] + + def list(self, **kwargs): + self.calls.append(kwargs) + return SimpleNamespace(models=[]) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def home(monkeypatch, tmp_path): + path = tmp_path / "home" + monkeypatch.setenv("HOME", str(path)) + return path + + +def _install_client(make_client, key="sk-test-123456"): + models = _Models() + client = make_client( + config=SimpleNamespace(api_key=key), + models=models, + ) + return client, models + + +class TestLogin: + def test_key_writes_the_shared_v2_key_file( + self, + invoke, + home, + ): + result = invoke(["auth", "login", "--key", " sk-secret "]) + + assert result.exit_code == 0 + path = home / ".dashscope" / "api_key" + assert path.read_text(encoding="utf-8") == "sk-secret" + assert str(path) in result.stdout + assert "sk-secret" not in result.stdout + + def test_prompt_hides_the_key(self, invoke, home, monkeypatch): + prompts = [] + + def prompt(text, **kwargs): + prompts.append((text, kwargs)) + return "sk-prompted" + + monkeypatch.setattr(typer, "prompt", prompt) + result = invoke(["auth", "login"]) + + assert result.exit_code == 0 + assert prompts[0][1]["hide_input"] is True + path = home / ".dashscope" / "api_key" + assert path.read_text(encoding="utf-8") == "sk-prompted" + + def test_empty_key_is_a_usage_error(self, invoke, home): + result = invoke(["auth", "login", "--key", " "]) + + assert result.exit_code == 2 + assert not (home / ".dashscope" / "api_key").exists() + + def test_json_output_never_contains_the_key(self, invoke, home): + result = invoke( + ["auth", "login", "--key", "sk-secret"], + global_args=["-o", "json"], + ) + + payload = json.loads(result.stdout) + assert payload["saved"] is True + assert "sk-secret" not in result.stdout + + +class TestWhoami: + def test_no_key_is_an_auth_error_without_a_request( + self, + invoke, + make_client, + ): + _, models = _install_client(make_client, key=None) + result = invoke(["auth", "whoami"]) + + assert result.exit_code == 3 + assert "no API key configured" in result.stderr + assert models.calls == [] + + def test_validates_with_the_v2_models_resource( + self, + invoke, + make_client, + ): + _, models = _install_client(make_client) + result = invoke(["auth", "whoami"]) + + assert result.exit_code == 0 + assert models.calls == [{"page_no": 1, "page_size": 1}] + assert "key=sk-tes...3456" in result.stdout + assert "sk-test-123456" not in result.stdout + + def test_command_line_source_and_json_output( + self, + invoke, + make_client, + ): + _install_client(make_client) + result = invoke( + ["auth", "whoami"], + global_args=["-k", "sk-test-123456", "-o", "json"], + ) + + payload = json.loads(result.stdout) + assert payload == { + "authenticated": True, + "key": "sk-tes...3456", + "source": "command line", + } + + +class TestLogout: + def test_removes_only_the_default_key_file(self, invoke, home, clean_env): + path = home / ".dashscope" / "api_key" + path.parent.mkdir(parents=True) + path.write_text("sk-secret", encoding="utf-8") + redirected = clean_env["api_key_file"] + redirected.write_text("sk-other", encoding="utf-8") + + result = invoke(["auth", "logout"]) + + assert result.exit_code == 0 + assert not path.exists() + assert redirected.read_text(encoding="utf-8") == "sk-other" + + def test_missing_file_is_a_successful_noop(self, invoke, home): + result = invoke(["auth", "logout"]) + + assert result.exit_code == 0 + assert "nothing to remove" in result.stdout diff --git a/tests/cli/test_chat_cmd.py b/tests/cli/test_chat_cmd.py new file mode 100644 index 00000000..e948b7c7 --- /dev/null +++ b/tests/cli/test_chat_cmd.py @@ -0,0 +1,660 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope chat create`` -- the first of the two pattern command groups.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, Dict, List, Optional + +import pytest + +from dashscope import errors as sdk_errors +from dashscope.cli.app import build_app +from dashscope.resources.chat.completions import ChatStream +from dashscope.types.common import Usage +from dashscope.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + Choice, + Delta, + StreamChoice, +) + + +def _completion( + content: str, + *, + reasoning: Optional[str] = None, + usage: Optional[Dict[str, int]] = None, +) -> ChatCompletion: + return ChatCompletion( + model="qwen-plus", + choices=[ + Choice( + message=ChatCompletionMessage( + role="assistant", + content=content, + reasoning_content=reasoning, + ), + finish_reason="stop", + ), + ], + usage=Usage(**usage) if usage else None, + ) + + +def _chunk(content: str) -> ChatCompletionChunk: + return ChatCompletionChunk( + choices=[StreamChoice(delta=Delta(role="assistant", content=content))], + ) + + +class _Inner: + """Stand-in for ``transport.sse.Stream``: iterable, closable, headers.""" + + def __init__(self, chunks: List[ChatCompletionChunk]) -> None: + self._chunks = chunks + self.headers: Dict[str, str] = {} + self.closed = False + + def __iter__(self): + return iter(self._chunks) + + def close(self) -> None: + self.closed = True + + +class _ExplodingInner(_Inner): + """Yields the first chunk, then fails the way a dropped stream does.""" + + def __init__(self, chunks, error: Exception) -> None: + super().__init__(chunks) + self._error = error + + def __iter__(self): + yield self._chunks[0] + raise self._error + + +class _Recorder: + """``client.chat.completions`` that records kwargs and returns a canned + result. The CLI's contract is one resource call plus one render, so a + command test pins exactly that and never touches HTTP.""" + + def __init__(self, result_fn) -> None: + self.calls: List[Dict[str, Any]] = [] + self._result_fn = result_fn + + def create(self, **kwargs): + self.calls.append(kwargs) + return self._result_fn(kwargs) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke( + app, + [*global_args, "chat", "create", *args], + **kwargs, + ) + + return run + + +@pytest.fixture +def completions(make_client): + """Install a fake client returning one non-streaming completion.""" + + def install(result_fn=lambda kwargs: _completion("hello")): + recorder = _Recorder(result_fn) + make_client(chat=SimpleNamespace(completions=recorder)) + return recorder + + return install + + +class TestModelResolution: + def test_flag_model_is_used(self, invoke, completions): + recorder = completions() + assert invoke(["-m", "qwen-max", "--prompt", "hi"]).exit_code == 0 + assert recorder.calls[0]["model"] == "qwen-max" + + def test_falls_back_to_the_configured_default( + self, + invoke, + completions, + clean_env, + ): + from dashscope.cli import config_store + + config_store.set_value("default_model", "qwen-plus") + recorder = completions() + assert invoke(["--prompt", "hi"]).exit_code == 0 + assert recorder.calls[0]["model"] == "qwen-plus" + + def test_flag_beats_the_configured_default( + self, + invoke, + completions, + clean_env, + ): + from dashscope.cli import config_store + + config_store.set_value("default_model", "qwen-plus") + recorder = completions() + invoke(["-m", "qwen-max", "--prompt", "hi"]) + assert recorder.calls[0]["model"] == "qwen-max" + + def test_no_model_anywhere_is_a_usage_error(self, invoke, completions): + completions() + result = invoke(["--prompt", "hi"]) + assert result.exit_code == 2 + assert "Error [usage]" in result.stderr + assert "default_model" in result.stderr + + def test_no_model_builds_no_client(self, invoke, make_client): + make_client( + chat=SimpleNamespace(completions=_Recorder(lambda k: None)), + ) + invoke(["--prompt", "hi"]) + assert make_client.calls == [] + + +class TestInputPrecedence: + def test_prompt_becomes_a_user_message(self, invoke, completions): + recorder = completions() + invoke(["-m", "qwen-plus", "--prompt", "hi there"]) + assert recorder.calls[0]["messages"] == [ + {"role": "user", "content": "hi there"}, + ] + + def test_system_is_prepended_to_the_prompt(self, invoke, completions): + recorder = completions() + invoke(["-m", "qwen-plus", "--prompt", "hi", "--system", "be brief"]) + assert recorder.calls[0]["messages"] == [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "hi"}, + ] + + def test_messages_wins_over_prompt(self, invoke, completions): + recorder = completions() + invoke( + [ + "-m", + "qwen-plus", + "--prompt", + "ignored", + "--messages", + '[{"role": "user", "content": "kept"}]', + ], + ) + assert recorder.calls[0]["messages"] == [ + {"role": "user", "content": "kept"}, + ] + + def test_messages_accepts_jsonl_from_a_file( + self, + invoke, + completions, + clean_env, + ): + path = clean_env["tmp_path"] / "msgs.jsonl" + path.write_text( + '{"role": "system", "content": "s"}\n' + '{"role": "user", "content": "u"}\n', + encoding="utf-8", + ) + recorder = completions() + assert ( + invoke(["-m", "qwen-plus", "--messages", f"@{path}"]).exit_code + == 0 + ) + assert [m["content"] for m in recorder.calls[0]["messages"]] == [ + "s", + "u", + ] + + def test_piped_stdin_becomes_the_prompt(self, invoke, completions): + recorder = completions() + result = invoke(["-m", "qwen-plus"], input="piped question\n") + assert result.exit_code == 0 + assert recorder.calls[0]["messages"] == [ + {"role": "user", "content": "piped question"}, + ] + + def test_stdin_positional_reads_json_messages(self, invoke, completions): + recorder = completions() + result = invoke( + ["-m", "qwen-plus", "-"], + input='[{"role": "user", "content": "from stdin"}]', + ) + assert result.exit_code == 0 + assert recorder.calls[0]["messages"] == [ + {"role": "user", "content": "from stdin"}, + ] + + def test_stdin_positional_reads_plain_text(self, invoke, completions): + recorder = completions() + result = invoke(["-m", "qwen-plus", "-"], input="from stdin\n") + assert result.exit_code == 0 + assert recorder.calls[0]["messages"] == [ + {"role": "user", "content": "from stdin"}, + ] + + def test_prompt_keeps_precedence_over_stdin_positional( + self, + invoke, + completions, + ): + recorder = completions() + result = invoke( + ["-m", "qwen-plus", "-", "--prompt", "kept"], + input="ignored", + ) + assert result.exit_code == 0 + assert recorder.calls[0]["messages"] == [ + {"role": "user", "content": "kept"}, + ] + + def test_other_positional_input_is_a_usage_error( + self, + invoke, + completions, + ): + completions() + result = invoke(["-m", "qwen-plus", "literal"]) + assert result.exit_code == 2 + assert "positional input must be '-'" in result.stderr + + def test_no_input_is_a_usage_error(self, invoke, completions): + completions() + result = invoke(["-m", "qwen-plus"]) + assert result.exit_code == 2 + assert "--prompt" in result.stderr + + def test_local_media_paths_reach_the_sdk_untouched( + self, + invoke, + completions, + ): + """The SDK uploads local files itself (``resources/_oss.py``). + + A CLI that called ``check_and_upload_local`` too would upload twice, + so the path has to survive verbatim. + """ + recorder = completions() + payload = json.dumps( + [ + { + "role": "user", + "content": [ + {"image": "/Users/me/cat.png"}, + {"text": "describe"}, + ], + }, + ], + ) + invoke(["-m", "qwen-vl-plus", "--messages", payload]) + content = recorder.calls[0]["messages"][0]["content"] + assert content[0] == {"image": "/Users/me/cat.png"} + + +class TestNonStreamingOutput: + def test_table_prints_the_content_on_stdout(self, invoke, completions): + completions(lambda k: _completion("the answer")) + result = invoke(["-m", "qwen-plus", "--prompt", "hi"]) + assert result.stdout.strip() == "the answer" + + def test_table_keeps_usage_and_reasoning_off_stdout( + self, + invoke, + completions, + ): + completions( + lambda k: _completion( + "the answer", + reasoning="thinking hard", + usage={ + "input_tokens": 3, + "output_tokens": 5, + "total_tokens": 8, + }, + ), + ) + result = invoke(["-m", "qwen-plus", "--prompt", "hi"]) + assert result.stdout.strip() == "the answer" + assert "thinking hard" in result.stderr + assert "total_tokens" in result.stderr + assert "usage" not in result.stdout + + def test_json_prints_the_whole_completion(self, invoke, completions): + completions( + lambda k: _completion( + "the answer", + usage={ + "input_tokens": 3, + "output_tokens": 5, + "total_tokens": 8, + }, + ), + ) + result = invoke( + ["-m", "qwen-plus", "--prompt", "hi"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["choices"][0]["message"]["content"] == "the answer" + assert payload["usage"]["total_tokens"] == 8 + + def test_json_stdout_is_exactly_one_document(self, invoke, completions): + """Nothing may be printed around the payload. + + Reasoning and usage belong *inside* the JSON -- they are part of the + resource -- but a trailing note would make ``| jq`` fail, which is + why the table renderer sends them to stderr instead. + """ + completions( + lambda k: _completion( + "x", + reasoning="noise", + usage={"total_tokens": 1}, + ), + ) + result = invoke( + ["-m", "qwen-plus", "--prompt", "hi"], + global_args=["-o", "json"], + ) + payload = json.loads(result.stdout) + assert json.dumps(payload, indent=2) == result.stdout.strip() + assert payload["choices"][0]["message"]["reasoning_content"] == "noise" + assert result.stderr.strip() == "" + + def test_a_global_option_after_the_command_is_a_usage_error( + self, + invoke, + completions, + ): + """Global options follow click's convention and precede the command. + + The 1.x tree rewrote argv to tolerate a trailing ``-k``; that + tolerance is deliberately not reimplemented here. + """ + completions() + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "-o", "json"]) + assert result.exit_code == 2 + + +class TestStreamingOutput: + @staticmethod + def _stream(chunks, inner_cls=_Inner): + def build(kwargs): + inner = inner_cls([_chunk(c) for c in chunks]) + build.inner = inner + return ChatStream(inner) + + build.inner = None + return build + + def test_table_writes_incremental_text(self, invoke, completions): + completions(self._stream(["Hel", "lo", " world"])) + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "--stream"]) + assert result.exit_code == 0 + assert result.stdout == "Hello world\n" + + def test_table_does_not_repeat_server_cumulative_snapshots( + self, + invoke, + completions, + ): + """``--parameters '{"incremental_output": false}'``. + + The server then streams whole snapshots, so appending each frame + in turn printed "HiHi!" instead of "Hi!". + """ + + def build(kwargs): + return ChatStream( + _Inner([_chunk(text) for text in ["Hi", "Hi!"]]), + server_cumulative=True, + ) + + completions(build) + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "--stream"]) + assert result.exit_code == 0 + assert result.stdout == "Hi!\n" + + def test_table_does_not_repeat_client_merged_snapshots( + self, + invoke, + completions, + ): + """The 1.x-compatible merge accumulates deltas into snapshots.""" + + def build(kwargs): + return ChatStream( + _Inner([_chunk(text) for text in ["Hel", "lo"]]), + merge_incremental=True, + ) + + completions(build) + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "--stream"]) + assert result.exit_code == 0 + assert result.stdout == "Hello\n" + + def test_table_output_is_not_a_dict_repr(self, invoke, completions): + """The 1.x tree printed ``chunk`` objects; the spec forbids that.""" + completions(self._stream(["Hello"])) + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "--stream"]) + assert "{" not in result.stdout + assert "ChatCompletionChunk" not in result.stdout + + def test_json_emits_one_valid_object_per_line(self, invoke, completions): + completions(self._stream(["Hel", "lo"])) + result = invoke( + ["-m", "qwen-plus", "--prompt", "hi", "--stream"], + global_args=["-o", "json"], + ) + lines = [line for line in result.stdout.splitlines() if line.strip()] + assert len(lines) == 2 + frames = [json.loads(line) for line in lines] + assert [f["choices"][0]["delta"]["content"] for f in frames] == [ + "Hel", + "lo", + ] + + def test_yaml_streams_as_ndjson(self, invoke, completions): + """A multi-document YAML stream is legal but nobody consumes one.""" + completions(self._stream(["a", "b"])) + result = invoke( + ["-m", "qwen-plus", "--prompt", "hi", "--stream"], + global_args=["-o", "yaml"], + ) + lines = [line for line in result.stdout.splitlines() if line.strip()] + assert len(lines) == 2 + assert all(json.loads(line) for line in lines) + + def test_the_stream_is_closed(self, invoke, completions): + build = self._stream(["a"]) + completions(build) + invoke(["-m", "qwen-plus", "--prompt", "hi", "--stream"]) + assert build.inner.closed is True + + def test_a_midstream_failure_exits_nonzero(self, invoke, completions): + """Design doc §4.2: 流式中途出错必须 exit 1. + + The 1.x tree caught the error inside the loop, printed it, and + returned -- which exits 0. There is no ``except`` in the streaming + path here, so the failure reaches the error handler. + """ + build = self._stream( + ["a", "b"], + lambda chunks: _ExplodingInner( + chunks, + sdk_errors.InternalServerError("boom"), + ), + ) + completions(build) + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "--stream"]) + assert result.exit_code == 1 + assert "Error [server]: boom" in result.stderr + + def test_a_midstream_failure_still_closes_the_stream( + self, + invoke, + completions, + ): + build = self._stream( + ["a"], + lambda chunks: _ExplodingInner( + chunks, + sdk_errors.InternalServerError("boom"), + ), + ) + completions(build) + invoke(["-m", "qwen-plus", "--prompt", "hi", "--stream"]) + assert build.inner.closed is True + + +class TestParameterPassthrough: + def test_unset_options_arrive_as_none(self, invoke, completions): + """``None`` means "let the SDK and the service decide".""" + recorder = completions() + invoke(["-m", "qwen-plus", "--prompt", "hi"]) + call = recorder.calls[0] + for name in ( + "temperature", + "top_p", + "max_tokens", + "seed", + "tools", + "stop", + "parameters", + "enable_thinking", + "enable_search", + ): + assert call[name] is None, name + + def test_sampling_parameters_are_forwarded(self, invoke, completions): + recorder = completions() + invoke( + [ + "-m", + "qwen-plus", + "--prompt", + "hi", + "--temperature", + "0.3", + "--top-p", + "0.9", + "--max-tokens", + "128", + "--seed", + "7", + ], + ) + call = recorder.calls[0] + assert call["temperature"] == 0.3 + assert call["top_p"] == 0.9 + assert call["max_tokens"] == 128 + assert call["seed"] == 7 + + def test_stop_is_repeatable(self, invoke, completions): + recorder = completions() + invoke( + [ + "-m", + "qwen-plus", + "--prompt", + "hi", + "--stop", + "a", + "--stop", + "b", + ], + ) + assert recorder.calls[0]["stop"] == ["a", "b"] + + def test_thinking_and_search_toggles(self, invoke, completions): + recorder = completions() + invoke( + [ + "-m", + "qwen-plus", + "--prompt", + "hi", + "--no-enable-thinking", + "--enable-search", + ], + ) + call = recorder.calls[0] + assert call["enable_thinking"] is False + assert call["enable_search"] is True + + def test_tools_json_is_forwarded(self, invoke, completions): + recorder = completions() + tools = '[{"type": "function", "function": {"name": "f"}}]' + invoke(["-m", "qwen-plus", "--prompt", "hi", "--tools", tools]) + assert recorder.calls[0]["tools"] == json.loads(tools) + + def test_tools_must_be_an_array(self, invoke, completions): + completions() + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "--tools", "{}"]) + assert result.exit_code == 2 + assert "--tools" in result.stderr + + def test_invalid_tools_json_is_a_usage_error(self, invoke, completions): + completions() + result = invoke(["-m", "qwen-plus", "--prompt", "hi", "--tools", "{"]) + assert result.exit_code == 2 + assert "--tools" in result.stderr + + def test_parameters_object_is_forwarded_verbatim( + self, + invoke, + completions, + ): + recorder = completions() + invoke( + [ + "-m", + "qwen-plus", + "--prompt", + "hi", + "--parameters", + '{"vl_high_resolution_images": true}', + ], + ) + assert recorder.calls[0]["parameters"] == { + "vl_high_resolution_images": True, + } + + def test_parameters_must_be_an_object(self, invoke, completions): + completions() + result = invoke( + ["-m", "qwen-plus", "--prompt", "hi", "--parameters", "[]"], + ) + assert result.exit_code == 2 + assert "--parameters" in result.stderr + + +class TestGroupShape: + def test_chat_is_a_group_with_a_create_command(self, app, runner): + result = runner.invoke(app, ["chat", "--help"]) + assert result.exit_code == 0 + assert "create" in result.stdout + + def test_bare_chat_shows_help(self, app, runner): + assert "Usage" in runner.invoke(app, ["chat"]).stdout + + def test_help_does_not_leak_a_backslash(self, app, runner): + assert "\\" not in runner.invoke(app, ["chat", "--help"]).stdout diff --git a/tests/cli/test_config_store.py b/tests/cli/test_config_store.py new file mode 100644 index 00000000..de9c77fe --- /dev/null +++ b/tests/cli/test_config_store.py @@ -0,0 +1,477 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The config.toml line editor behind ``dashscope config``.""" + +import os +import stat + +import pytest + +from dashscope.cli import config_store +from dashscope.cli.config_store import ( + CLI_SECTION, + effective_values, + get_value, + known_keys, + load_cli_section, + load_raw, + mask, + parse_toml_value, + render_toml_value, + restrict_permissions, + set_value, +) +from dashscope.cli.errors import CliError + + +@pytest.fixture +def config(clean_env): + return clean_env["config"] + + +@pytest.fixture +def permissive_umask(): + """The ``umask 022`` default a world-readable temp file needs.""" + previous = os.umask(0o022) + try: + yield + finally: + os.umask(previous) + + +class TestValueParsing: + @pytest.mark.parametrize( + "raw,expected", + [ + ("30", 30), + ("1.5", 1.5), + ("true", True), + ("false", False), + ('"quoted"', "quoted"), + ("qwen-plus", "qwen-plus"), + ("[1, 2]", [1, 2]), + # JSON object syntax is not TOML (inline tables use ``=``), so + # this stays a string rather than silently becoming a dict. + ('{"a": 1}', '{"a": 1}'), + ], + ) + def test_parse(self, raw, expected): + assert parse_toml_value(raw) == expected + + @pytest.mark.parametrize( + "value", + ["plain", "with space", 'quote"inside', "back\\slash", "你好"], + ) + def test_roundtrip(self, value): + assert parse_toml_value(render_toml_value(value)) == value + + def test_numbers_roundtrip(self): + assert parse_toml_value(render_toml_value(30)) == 30 + assert parse_toml_value(render_toml_value(1.5)) == 1.5 + assert parse_toml_value(render_toml_value(True)) is True + + +class TestKeyRouting: + def test_sdk_keys_are_top_level(self): + assert "api_key" in known_keys() + assert "protocol" in known_keys() + + def test_cli_keys_routed_to_the_cli_section(self, config): + set_value("output", "json", path=config) + assert load_cli_section(config) == {"output": "json"} + assert load_raw(config)[CLI_SECTION]["output"] == "json" + + def test_dotted_spelling_is_the_same_key(self, config): + set_value("cli.default_model", "qwen-plus", path=config) + set_value("default_model", "qwen-max", path=config) + assert load_cli_section(config) == {"default_model": "qwen-max"} + + def test_unknown_key_is_a_usage_error(self, config): + with pytest.raises(CliError) as info: + set_value("nope", "x", path=config) + assert info.value.category == "usage" + assert "known keys" in info.value.message + + def test_unknown_section_is_a_usage_error(self, config): + with pytest.raises(CliError) as info: + set_value("other.output", "json", path=config) + assert "unknown config section" in info.value.message + + +class TestValidation: + @pytest.mark.parametrize( + "key,value", + [ + ("protocol", "bogus"), + ("region", "mars-1"), + ("output", "csv"), + ("timeout", "0"), + ("timeout", "-1"), + ("timeout", "abc"), + ("max_retries", "-1"), + ("http_keepalive_expiry", "0"), + ("http_max_connections", "0"), + ("http_max_connections", "-1"), + ("api_key", ""), + ], + ) + def test_rejected(self, key, value, config): + with pytest.raises(CliError) as info: + set_value(key, value, path=config) + assert info.value.category == "usage" + + def test_the_pool_floor_matches_the_sdk_setters(self, config): + """A config file is read on every later invocation, so writing a + value `Settings` refuses would break the CLI permanently.""" + assert set_value("http_max_connections", "1", path=config) == ( + "http_max_connections", + 1, + ) + assert ( + set_value("http_max_keepalive_connections", "0", path=config)[1] + == 0 + ) + assert set_value("max_retries", "0", path=config)[1] == 0 + + def test_timeout_becomes_a_number(self, config): + assert set_value("timeout", "30", path=config) == ("timeout", 30) + assert load_raw(config)["timeout"] == 30 + + def test_valid_protocol_and_region(self, config): + set_value("protocol", "openai", path=config) + set_value("region", "cn-beijing", path=config) + assert load_raw(config)["protocol"] == "openai" + + +class TestPreservation: + def test_comments_and_unknown_keys_survive(self, config): + config.write_text( + "# my note\n" + "some_future_key = 1\n" + 'api_key = "sk-old"\n' + "\n" + "# trailing note\n", + encoding="utf-8", + ) + set_value("api_key", "sk-new", path=config) + text = config.read_text(encoding="utf-8") + assert "# my note" in text + assert "some_future_key = 1" in text + assert "# trailing note" in text + assert 'api_key = "sk-new"' in text + assert "sk-old" not in text + + def test_replace_does_not_duplicate(self, config): + set_value("workspace", "ws-1", path=config) + set_value("workspace", "ws-2", path=config) + set_value("workspace", "ws-3", path=config) + text = config.read_text(encoding="utf-8") + assert text.count("workspace") == 1 + assert load_raw(config)["workspace"] == "ws-3" + + def test_inline_comment_on_an_existing_line_is_replaced_whole( + self, + config, + ): + config.write_text( + 'api_key = "sk-old" # keep out of git\n', + encoding="utf-8", + ) + set_value("api_key", "sk-new", path=config) + assert load_raw(config)["api_key"] == "sk-new" + + def test_cli_key_lands_inside_its_own_section(self, config): + config.write_text( + 'api_key = "sk-1"\n\n[cli]\ndefault_model = "qwen-plus"\n', + encoding="utf-8", + ) + set_value("output", "json", path=config) + text = config.read_text(encoding="utf-8") + assert text.index("[cli]") < text.index("output") + assert load_cli_section(config) == { + "default_model": "qwen-plus", + "output": "json", + } + + def test_new_cli_section_is_appended(self, config): + config.write_text('api_key = "sk-1"\n', encoding="utf-8") + set_value("output", "yaml", path=config) + text = config.read_text(encoding="utf-8") + assert text.rstrip().endswith('output = "yaml"') + assert "[cli]" in text + + def test_top_level_key_precedes_existing_tables(self, config): + """TOML would otherwise attribute the key to the last table.""" + config.write_text( + '[cli]\ndefault_model = "qwen-plus"\n', + encoding="utf-8", + ) + set_value("api_key", "sk-1", path=config) + data = load_raw(config) + assert data["api_key"] == "sk-1" + assert "api_key" not in data[CLI_SECTION] + + def test_missing_file_is_created(self, config): + assert not config.exists() + set_value("api_key", "sk-1", path=config) + assert load_raw(config) == {"api_key": "sk-1"} + + def test_missing_directory_is_created(self, tmp_path): + target = tmp_path / "nested" / "config.toml" + set_value("api_key", "sk-1", path=target) + assert load_raw(target) == {"api_key": "sk-1"} + + +class TestTomlSemantics: + """The editor reads the file as TOML, not as text that looks like it.""" + + def test_a_table_header_may_carry_a_comment(self, config): + config.write_text( + '[cli] # my settings\noutput = "json"\n', + encoding="utf-8", + ) + set_value("workspace", "ws-1", path=config) + data = load_raw(config) + # Top level is where the SDK reads it; inside [cli] it is ignored. + assert data["workspace"] == "ws-1" + assert data["cli"] == {"output": "json"} + + def test_a_key_inside_a_multiline_string_is_left_alone(self, config): + config.write_text( + 'prompt = """\n[cli]\noutput = "yaml"\n"""\n', + encoding="utf-8", + ) + set_value("output", "json", path=config) + data = load_raw(config) + assert data["prompt"] == '[cli]\noutput = "yaml"\n' + assert data["cli"]["output"] == "json" + + def test_a_literal_multiline_string_is_left_alone(self, config): + config.write_text( + "prompt = '''\napi_key = \"not mine\"\n'''\n", + encoding="utf-8", + ) + set_value("api_key", "sk-1", path=config) + data = load_raw(config) + assert data["prompt"] == 'api_key = "not mine"\n' + assert data["api_key"] == "sk-1" + + def test_a_hash_inside_a_string_is_not_a_comment(self, config): + config.write_text('base_url = "http://host#frag"\n', encoding="utf-8") + set_value("base_url", "http://other#2", path=config) + assert load_raw(config)["base_url"] == "http://other#2" + + def test_a_commented_out_key_is_not_revived(self, config): + config.write_text('# api_key = "old"\n', encoding="utf-8") + set_value("api_key", "sk-new", path=config) + text = config.read_text(encoding="utf-8") + assert text.startswith('# api_key = "old"') + assert load_raw(config)["api_key"] == "sk-new" + + def test_a_quoted_table_header_is_recognised(self, config): + config.write_text('[ "cli" ]\n', encoding="utf-8") + set_value("output", "json", path=config) + assert load_raw(config)["cli"]["output"] == "json" + + def test_a_top_level_key_stays_out_of_an_array_of_tables(self, config): + config.write_text('[[models]]\nid = "a"\n', encoding="utf-8") + set_value("workspace", "ws-1", path=config) + assert load_raw(config)["workspace"] == "ws-1" + + def test_a_comment_after_a_header_does_not_leak_into_the_name( + self, + config, + ): + config.write_text("[cli] # see [docs]\n", encoding="utf-8") + set_value("output", "json", path=config) + set_value("workspace", "ws-1", path=config) + data = load_raw(config) + assert data["cli"]["output"] == "json" + assert data["workspace"] == "ws-1" + + +class TestReadBack: + """An edit that would not be read back must not report success.""" + + def test_a_value_written_into_the_wrong_table_is_refused( + self, + config, + monkeypatch, + ): + # Stands in for any future scanner bug: force the key into [cli] + # and check the command fails instead of claiming success. + monkeypatch.setattr( + config_store, + "_insert_key", + lambda lines, section, rendered: lines.extend( + ["[cli]", rendered], + ), + ) + with pytest.raises(CliError) as info: + set_value("workspace", "ws-1", path=config) + assert info.value.category == "usage" + assert "did not take effect" in info.value.message + assert not config.exists() + + +class TestSafety: + def test_refuses_to_write_invalid_toml_and_leaves_the_original( + self, + config, + ): + # A dotted key already defines cli.output; appending the same key + # under a [cli] header would be a duplicate, which is invalid TOML. + original = 'cli.output = "json"\n' + config.write_text(original, encoding="utf-8") + with pytest.raises(CliError) as info: + set_value("output", "yaml", path=config) + assert info.value.category == "usage" + assert "invalid TOML" in info.value.message + assert config.read_text(encoding="utf-8") == original + assert not list(config.parent.glob("*.tmp")) + + def test_existing_invalid_file_is_reported_not_overwritten_silently( + self, + config, + ): + config.write_text("api_key = \n", encoding="utf-8") + with pytest.raises(CliError) as info: + load_raw(config) + assert "invalid TOML" in info.value.message + + def test_restrict_permissions(self, config): + set_value("api_key", "sk-1", path=config) + restrict_permissions(config) + mode = stat.S_IMODE(os.stat(config).st_mode) + assert mode == 0o600 + + def test_file_mode_is_preserved_across_rewrites(self, config): + config.write_text('api_key = "sk-1"\n', encoding="utf-8") + os.chmod(config, 0o600) + set_value("api_key", "sk-2", path=config) + assert stat.S_IMODE(os.stat(config).st_mode) == 0o600 + + def test_a_new_file_is_owner_only_under_a_permissive_umask( + self, + config, + permissive_umask, + ): + assert not config.exists() + set_value("api_key", "sk-secret", path=config) + assert stat.S_IMODE(os.stat(config).st_mode) == 0o600 + + def test_the_temp_file_already_holds_the_secret_at_0600( + self, + config, + permissive_umask, + monkeypatch, + ): + """There must be no window where the key is world-readable.""" + modes = [] + replace = os.replace + + def spy(source, destination): + modes.append(stat.S_IMODE(os.stat(source).st_mode)) + return replace(source, destination) + + monkeypatch.setattr(os, "replace", spy) + set_value("api_key", "sk-secret", path=config) + assert modes == [0o600] + + def test_group_and_other_bits_are_dropped_on_rewrite( + self, + config, + permissive_umask, + ): + config.write_text('api_key = "sk-1"\n', encoding="utf-8") + os.chmod(config, 0o644) + set_value("api_key", "sk-2", path=config) + assert stat.S_IMODE(os.stat(config).st_mode) == 0o600 + assert load_raw(config)["api_key"] == "sk-2" + + @pytest.mark.skipif(os.name == "nt", reason="POSIX symlinks") + def test_a_planted_tmp_symlink_is_not_followed(self, config): + """The old fixed name let anyone redirect the write.""" + victim = config.with_name("victim") + victim.write_text("precious", encoding="utf-8") + config.with_name(config.name + ".tmp").symlink_to(victim) + set_value("api_key", "sk-1", path=config) + assert victim.read_text(encoding="utf-8") == "precious" + assert load_raw(config) == {"api_key": "sk-1"} + + def test_restrict_permissions_tolerates_a_missing_file(self, tmp_path): + restrict_permissions(tmp_path / "absent.toml") + + +class TestMasking: + def test_long_secret(self): + assert mask("sk-1234abcd5678") == "sk-1*********78" + + def test_short_secret_is_fully_hidden(self): + assert mask("abc123") == "***" + assert mask("") == "***" + + def test_keeps_only_the_ends(self): + masked = mask("sk-abcdefghij") + assert masked.startswith("sk-a") + assert masked.endswith("ij") + assert "bcdefgh" not in masked + + +class TestEffectiveValues: + def test_file_value(self, config): + set_value("workspace", "ws-1", path=config) + rows = {row["key"]: row for row in effective_values(path=config)} + assert rows["workspace"] == { + "key": "workspace", + "value": "ws-1", + "source": "config file", + } + + def test_env_beats_file(self, config, monkeypatch): + set_value("workspace", "ws-file", path=config) + monkeypatch.setenv("DASHSCOPE_WORKSPACE", "ws-env") + rows = {row["key"]: row for row in effective_values(path=config)} + assert rows["workspace"]["value"] == "ws-env" + assert rows["workspace"]["source"] == "env DASHSCOPE_WORKSPACE" + + def test_unset_reports_default(self, config): + rows = {row["key"]: row for row in effective_values(path=config)} + assert rows["region"]["source"] == "default" + assert rows["region"]["value"] is None + + def test_api_key_is_masked_from_every_source(self, config, monkeypatch): + monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-envkey123456") + rows = {row["key"]: row for row in effective_values(path=config)} + assert rows["api_key"]["value"] == mask("sk-envkey123456") + assert rows["api_key"]["value"].startswith("sk-e") + assert "envkey" not in rows["api_key"]["value"] + + def test_cli_keys_are_included(self, config): + set_value("default_model", "qwen-plus", path=config) + rows = {row["key"]: row for row in effective_values(path=config)} + assert rows["cli.default_model"]["value"] == "qwen-plus" + assert rows["cli.output"]["source"] == "default" + + def test_every_known_key_appears(self, config): + keys = {row["key"] for row in effective_values(path=config)} + for key in known_keys(): + expected = ( + f"{CLI_SECTION}.{key}" + if key in ("output", "default_model") + else key + ) + assert expected in keys + + +class TestGetValue: + def test_roundtrip(self, config): + set_value("api_key", "sk-1", path=config) + assert get_value("api_key", path=config) == "sk-1" + + def test_cli_key(self, config): + set_value("output", "json", path=config) + assert get_value("output", path=config) == "json" + + def test_unset_is_none(self, config): + assert get_value("workspace", path=config) is None diff --git a/tests/cli/test_confirm.py b/tests/cli/test_confirm.py new file mode 100644 index 00000000..b2489405 --- /dev/null +++ b/tests/cli/test_confirm.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Confirmation gate for destructive commands.""" + +import io + +import pytest + +from dashscope.cli.confirm import confirm_or_die, stdin_is_tty +from dashscope.cli.errors import CliError + + +class _Tty(io.StringIO): + def isatty(self): + return True + + +@pytest.fixture +def tty(monkeypatch): + stream = _Tty("") + monkeypatch.setattr("sys.stdin", stream) + return stream + + +@pytest.fixture +def pipe(monkeypatch): + monkeypatch.setattr("sys.stdin", io.StringIO("")) + + +@pytest.fixture +def answers(monkeypatch): + """Feed ``typer.confirm`` a canned answer without touching stdin.""" + + def install(answer: bool) -> list: + asked = [] + + def fake_confirm(prompt, **kwargs): + asked.append((prompt, kwargs)) + return answer + + monkeypatch.setattr( + "dashscope.cli.confirm.typer.confirm", + fake_confirm, + ) + return asked + + return install + + +class TestYes: + def test_skips_everything(self, pipe, answers): + asked = answers(True) + confirm_or_die("delete job-1?", yes=True) + assert asked == [] + + def test_skips_even_without_a_terminal(self, pipe, answers): + answers(True) + confirm_or_die("delete job-1?", yes=True) + + +class TestNonInteractive: + def test_refuses_with_exit_two(self, pipe, answers): + asked = answers(True) + with pytest.raises(CliError) as info: + confirm_or_die("delete job-1?", yes=False) + assert info.value.category == "usage" + assert info.value.exit_code is None + assert asked == [] + + def test_message_names_the_flag_and_the_action(self, pipe, answers): + answers(True) + with pytest.raises(CliError) as info: + confirm_or_die("delete job-1?", yes=False) + assert "--yes" in info.value.message + assert "delete job-1?" in info.value.message + + def test_closed_stdin_counts_as_non_interactive( + self, + monkeypatch, + answers, + ): + class Broken: + def isatty(self): + raise ValueError("I/O operation on closed file") + + monkeypatch.setattr("sys.stdin", Broken()) + answers(True) + assert stdin_is_tty() is False + with pytest.raises(CliError): + confirm_or_die("x?", yes=False) + + +class TestInteractive: + def test_accept_proceeds(self, tty, answers): + asked = answers(True) + confirm_or_die("delete job-1?", yes=False) + assert len(asked) == 1 + assert asked[0][0] == "delete job-1?" + + def test_default_is_no(self, tty, answers): + """A bare Enter at the prompt must not destroy anything.""" + asked = answers(True) + confirm_or_die("delete job-1?", yes=False) + assert asked[0][1].get("default") is False + + def test_the_prompt_is_written_to_stderr(self, tty, answers): + """click defaults to stdout, which would land ahead of ``-o json``.""" + asked = answers(True) + confirm_or_die("delete job-1?", yes=False) + assert asked[0][1].get("err") is True + + def test_decline_is_a_usage_error(self, tty, answers): + answers(False) + with pytest.raises(CliError) as info: + confirm_or_die("delete job-1?", yes=False) + assert info.value.category == "usage" + assert info.value.message == "aborted by user" diff --git a/tests/cli/test_context.py b/tests/cli/test_context.py new file mode 100644 index 00000000..daca9d68 --- /dev/null +++ b/tests/cli/test_context.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Resolution of global options into SDK settings and CLI defaults.""" + +import pytest + +from dashscope.cli.config_store import set_value +from dashscope.cli.context import CliContext, CliDefaults, GlobalOptions +from dashscope.cli.errors import CliError +from dashscope.cli.output import JSON, TABLE, YAML + + +def _ctx(**kwargs) -> CliContext: + return CliContext(GlobalOptions(**kwargs)) + + +class TestSdkOverrides: + def test_nothing_specified_means_nothing_overridden(self, clean_env): + assert _ctx().sdk_overrides() == {} + + def test_flags_map_onto_client_keywords(self, clean_env): + ctx = _ctx( + api_key="sk-1", + protocol="openai", + base_url="https://api.test.local", + workspace="ws-1", + region="cn-beijing", + timeout=30.0, + ) + assert ctx.sdk_overrides() == { + "api_key": "sk-1", + "protocol": "openai", + "base_url": "https://api.test.local", + "workspace": "ws-1", + "region": "cn-beijing", + "timeout": 30.0, + } + + def test_dash_c_reaches_the_client(self, clean_env): + ctx = _ctx(overrides=("max_retries=4",)) + assert ctx.sdk_overrides() == {"max_retries": 4} + + def test_dash_c_value_is_typed_not_a_string(self, clean_env): + ctx = _ctx(overrides=("timeout=30",)) + overrides = ctx.sdk_overrides() + assert overrides["timeout"] == 30 + # _validate coerces to the SDK's own type; the point is that the + # Client gets a number rather than the literal text "30". + assert not isinstance(overrides["timeout"], str) + + def test_flag_beats_dash_c_for_the_same_setting(self, clean_env): + """The dedicated flag is the more specific spelling.""" + ctx = _ctx(api_key="sk-flag", overrides=("api_key=sk-c",)) + assert ctx.sdk_overrides()["api_key"] == "sk-flag" + + def test_dash_c_only_settings_are_kept(self, clean_env): + ctx = _ctx(api_key="sk-flag", overrides=("workspace=ws-c",)) + assert ctx.sdk_overrides() == { + "api_key": "sk-flag", + "workspace": "ws-c", + } + + def test_invalid_value_is_a_usage_error(self, clean_env): + with pytest.raises(CliError) as info: + _ctx(overrides=("protocol=bogus",)).sdk_overrides() + assert info.value.category == "usage" + assert "invalid protocol" in info.value.message + + def test_unknown_key_is_a_usage_error_naming_the_known_ones( + self, + clean_env, + ): + with pytest.raises(CliError) as info: + _ctx(overrides=("nope=1",)).sdk_overrides() + assert info.value.category == "usage" + assert "unknown configuration key" in info.value.message + assert "api_key" in info.value.message + + def test_missing_equals_is_a_usage_error(self, clean_env): + for raw in ("api_key", ""): + with pytest.raises(CliError) as info: + _ctx(overrides=(raw,)).sdk_overrides() + assert "KEY=VALUE" in info.value.message + + def test_value_may_contain_equals(self, clean_env): + ctx = _ctx(overrides=("base_url=https://x.test.local/?a=b",)) + assert ctx.sdk_overrides()["base_url"] == "https://x.test.local/?a=b" + + def test_sdk_overrides_returns_a_copy(self, clean_env): + """A caller mutating the result must not poison the cached set.""" + ctx = _ctx(api_key="sk-1", overrides=("workspace=ws-1",)) + ctx.sdk_overrides()["injected"] = True + assert "injected" not in ctx.sdk_overrides() + + +class TestCliDefaults: + def test_cli_keys_are_not_forwarded_to_the_client(self, clean_env): + ctx = _ctx(overrides=("output=json", "default_model=qwen-plus")) + assert ctx.sdk_overrides() == {} + assert ctx.defaults == CliDefaults( + default_model="qwen-plus", + output="json", + ) + + def test_defaults_come_from_the_config_file(self, clean_env): + set_value("default_model", "qwen-max", path=clean_env["config"]) + assert _ctx().defaults.default_model == "qwen-max" + + def test_dash_c_beats_the_config_file(self, clean_env): + set_value("default_model", "qwen-max", path=clean_env["config"]) + assert _ctx( + overrides=("default_model=qwen-plus",), + ).defaults == CliDefaults( + default_model="qwen-plus", + output=None, + ) + + def test_invalid_output_format_rejected(self, clean_env): + with pytest.raises(CliError): + _ = _ctx(overrides=("output=csv",)).defaults + + def test_config_file_is_not_read_until_needed( + self, + clean_env, + monkeypatch, + ): + calls = [] + monkeypatch.setattr( + "dashscope.cli.config_store.load_cli_section", + lambda *a, **k: calls.append(1) or {}, + ) + ctx = _ctx() + ctx.sdk_overrides() + assert calls == [] + _ = ctx.defaults + assert len(calls) == 1 + _ = ctx.defaults + assert len(calls) == 1 + + +class TestOutputFormat: + def test_default_is_table(self, clean_env): + assert _ctx().output_format == TABLE + + def test_flag(self, clean_env): + assert _ctx(output=JSON).output_format == JSON + + def test_dash_c(self, clean_env): + assert _ctx(overrides=("output=yaml",)).output_format == YAML + + def test_config_file(self, clean_env): + set_value("output", "json", path=clean_env["config"]) + assert _ctx().output_format == JSON + + def test_flag_beats_dash_c_beats_file(self, clean_env): + set_value("output", "yaml", path=clean_env["config"]) + assert _ctx(overrides=("output=json",)).output_format == JSON + assert ( + _ctx(output=TABLE, overrides=("output=json",)).output_format + == TABLE + ) + + +class TestRendererAndClient: + def test_renderer_is_cached_and_configured(self, clean_env, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + ctx = _ctx(output=JSON, verbose=True) + renderer = ctx.renderer + assert renderer is ctx.renderer + assert (renderer.fmt, renderer.verbose, renderer.no_color) == ( + JSON, + True, + True, + ) + + def test_no_color_flag(self, clean_env, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + assert _ctx(no_color=True).renderer.no_color is True + + def test_verbose_is_an_attribute(self, clean_env): + """errors._verbose_from reads it before ctx.obj may exist.""" + assert _ctx(verbose=True).verbose is True + assert _ctx().verbose is False + + def test_client_is_built_lazily_with_the_resolved_overrides( + self, + clean_env, + make_client, + ): + make_client(chat=object()) + ctx = _ctx(api_key="sk-1") + assert make_client.calls == [] + assert ctx.client is ctx.client + assert make_client.calls == [{"api_key": "sk-1"}] + + def test_close_releases_the_client(self, clean_env, make_client): + make_client() + ctx = _ctx(api_key="sk-1") + client = ctx.client + closed = [] + client.close = lambda: closed.append(True) + ctx.close() + assert closed == [True] + + def test_close_without_a_client_is_a_noop(self, clean_env, make_client): + make_client() + ctx = _ctx() + ctx.close() + assert make_client.calls == [] + + def test_close_twice_is_safe(self, clean_env, make_client): + make_client() + ctx = _ctx() + _ = ctx.client + ctx.close() + ctx.close() + + def test_client_is_rebuilt_after_close(self, clean_env, make_client): + make_client() + ctx = _ctx() + _ = ctx.client + ctx.close() + _ = ctx.client + assert len(make_client.calls) == 2 diff --git a/tests/cli/test_decision_cmd.py b/tests/cli/test_decision_cmd.py new file mode 100644 index 00000000..4caf8731 --- /dev/null +++ b/tests/cli/test_decision_cmd.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope decision`` -- one round of typed questions.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.decision import DecisionResponse + +_QUESTIONS = { + "department": { + "type": "choice", + "instructions": "Which team?", + "criteria": {"billing": "invoices", "technical": "bugs"}, + }, + "escalate": {"type": "noul", "instructions": "Page on-call?"}, +} + + +def _response(**overrides: Any) -> DecisionResponse: + data: Dict[str, Any] = { + "model": "decision-model-preview", + "answers": { + "department": { + "type": "choice", + "choice": "billing", + "confidence": 0.91, + "probabilities": {"billing": 0.91, "technical": 0.09}, + }, + "escalate": {"type": "noul", "noul": 0.2}, + "severity": {"type": "score", "score": 1.43, "confidence": 0.7}, + }, + "usage": {"input_tokens": 30}, + "request_id": "req-1", + } + data.update(overrides) + return DecisionResponse(**data) + + +class _Decision: + """Stand-in for ``client.decision``.""" + + def __init__(self, result: Any = None) -> None: + self.calls: List[Dict[str, Any]] = [] + self._result = result if result is not None else _response() + + def create(self, **kwargs): + self.calls.append(kwargs) + return self._result + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def decision(make_client): + def install(result: Any = None) -> _Decision: + resource = _Decision(result) + make_client(decision=resource) + return resource + + return install + + +class TestCreate: + def test_one_row_per_answer(self, invoke, decision): + resource = decision() + result = invoke( + [ + "decision", + "create", + "--state", + "cannot open the invoice pdf", + "--questions", + json.dumps(_QUESTIONS), + ], + ) + assert result.exit_code == 0 + for word in ("department", "billing", "escalate", "severity"): + assert word in result.stdout + assert resource.calls[0]["model"] == "decision-model-preview" + assert resource.calls[0]["state"] == "cannot open the invoice pdf" + assert resource.calls[0]["questions"] == _QUESTIONS + + def test_a_json_state_is_parsed(self, invoke, decision): + resource = decision() + invoke( + [ + "decision", + "create", + "--state", + '{"ticket": 7, "tags": ["billing"]}', + "--questions", + json.dumps(_QUESTIONS), + ], + ) + assert resource.calls[0]["state"] == {"ticket": 7, "tags": ["billing"]} + + def test_a_json_array_state_is_parsed(self, invoke, decision): + resource = decision() + invoke( + [ + "decision", + "create", + "--state", + '[{"role": "user", "text": "hi"}]', + "--questions", + json.dumps(_QUESTIONS), + ], + ) + assert resource.calls[0]["state"] == [ + {"role": "user", "text": "hi"}, + ] + + def test_reads_the_state_from_a_file(self, invoke, decision, tmp_path): + payload = tmp_path / "state.txt" + payload.write_text("a long ticket body\n", encoding="utf-8") + resource = decision() + invoke( + [ + "decision", + "create", + "--state", + f"@{payload}", + "--questions", + json.dumps(_QUESTIONS), + ], + ) + assert resource.calls[0]["state"] == "a long ticket body\n" + + def test_reads_the_questions_from_stdin(self, invoke, decision): + resource = decision() + result = invoke( + [ + "decision", + "create", + "--state", + "hi", + "--questions", + "-", + ], + input=json.dumps(_QUESTIONS), + ) + assert result.exit_code == 0 + assert resource.calls[0]["questions"] == _QUESTIONS + + def test_the_model_can_be_overridden(self, invoke, decision): + resource = decision() + invoke( + [ + "decision", + "create", + "-m", + "decision-model-2", + "--state", + "hi", + "--questions", + json.dumps(_QUESTIONS), + ], + ) + assert resource.calls[0]["model"] == "decision-model-2" + + def test_a_malformed_json_state_is_a_usage_error(self, invoke, decision): + resource = decision() + result = invoke( + [ + "decision", + "create", + "--state", + "{not json", + "--questions", + json.dumps(_QUESTIONS), + ], + ) + assert result.exit_code == 2 + assert "--state" in result.stderr + assert resource.calls == [] + + def test_the_state_is_required(self, invoke, decision): + resource = decision() + result = invoke( + ["decision", "create", "--questions", json.dumps(_QUESTIONS)], + ) + assert result.exit_code == 2 + assert "--state is required" in result.stderr + assert resource.calls == [] + + def test_the_questions_are_required(self, invoke, decision): + decision() + assert invoke(["decision", "create", "--state", "hi"]).exit_code == 2 + + def test_an_empty_question_set_is_a_usage_error(self, invoke, decision): + resource = decision() + result = invoke( + ["decision", "create", "--state", "hi", "--questions", "{}"], + ) + assert result.exit_code == 2 + assert "must not be empty" in result.stderr + assert resource.calls == [] + + def test_json_output_is_the_whole_response(self, invoke, decision): + decision() + result = invoke( + [ + "decision", + "create", + "--state", + "hi", + "--questions", + json.dumps(_QUESTIONS), + ], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["answers"]["department"]["choice"] == "billing" + assert doc["request_id"] == "req-1" + + def test_an_answerless_round_still_renders(self, invoke, decision): + """A success body may omit the answers table entirely.""" + decision(_response(answers={})) + result = invoke( + [ + "decision", + "create", + "--state", + "hi", + "--questions", + json.dumps(_QUESTIONS), + ], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + assert json.loads(result.stdout)["answers"] == {} + + def test_the_usage_note_stays_off_stdout(self, invoke, decision): + decision() + result = invoke( + [ + "decision", + "create", + "--state", + "hi", + "--questions", + json.dumps(_QUESTIONS), + ], + ) + assert "usage:" in result.stderr + assert "usage" not in result.stdout + + +class TestGroupShape: + def test_bare_decision_shows_help(self, app, runner): + assert "Usage" in runner.invoke(app, ["decision"]).stdout + + def test_help_carries_a_worked_question_set(self, app, runner): + """`--questions` is the one flag whose shape is not guessable.""" + text = runner.invoke(app, ["decision", "--help"]).stdout + assert "criteria" in text diff --git a/tests/cli/test_deployments_cmd.py b/tests/cli/test_deployments_cmd.py new file mode 100644 index 00000000..e119e011 --- /dev/null +++ b/tests/cli/test_deployments_cmd.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope deployments`` -- create, inspect, scale, delete, stream.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.deployments import ( + Deployment, + DeploymentEvent, + DeploymentList, +) +from dashscope.types.common import JobStatus + + +def _deployment(**overrides: Any) -> Deployment: + data: Dict[str, Any] = { + "deployed_model": "qwen-plus-ft-202601", + "model": "qwen-plus-ft", + "status": "RUNNING", + "capacity": 2, + "ready_capacity": 2, + "created_at": "2026-01-01 12:00:00", + } + data.update(overrides) + return Deployment(**data) + + +class _Deployments: + """Stand-in for ``client.deployments`` recording every call.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def create(self, **kwargs): + return self._record("create", **kwargs) + + def get(self, deployed_model, **kwargs): + return self._record("get", deployed_model, **kwargs) + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def scale(self, deployed_model, **kwargs): + return self._record("scale", deployed_model, **kwargs) + + def delete(self, deployed_model, **kwargs): + return self._record("delete", deployed_model, **kwargs) + + def stream_events(self, deployed_model, **kwargs): + self.calls.append( + { + "method": "stream_events", + "args": (deployed_model,), + "kwargs": kwargs, + }, + ) + return iter(self._results.get("stream_events", [])) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def deployments(make_client): + def install(**results: Any) -> _Deployments: + resource = _Deployments(**results) + make_client(deployments=resource) + return resource + + return install + + +def _as_terminal_tty(monkeypatch, answer: bool): + monkeypatch.setattr("dashscope.cli.confirm.stdin_is_tty", lambda: True) + monkeypatch.setattr( + "dashscope.cli.confirm.typer.confirm", + lambda *a, **k: answer, + ) + + +class TestCreate: + def test_create_passes_model_and_capacity(self, invoke, deployments): + resource = deployments(create=_deployment()) + result = invoke( + [ + "deployments", + "create", + "-m", + "qwen-plus-ft", + "--capacity", + "2", + ], + ) + assert result.exit_code == 0 + kwargs = resource.calls[0]["kwargs"] + assert kwargs["model"] == "qwen-plus-ft" + assert kwargs["capacity"] == 2 + assert "qwen-plus-ft-202601" in result.stdout + + def test_create_accepts_a_deploy_spec(self, invoke, deployments): + resource = deployments(create=_deployment()) + invoke( + [ + "deployments", + "create", + "-m", + "qwen-plus-ft", + "--capacity", + "2", + "--deploy-spec", + '{"gpu": "a10"}', + "--billing-method", + "monthly", + ], + ) + kwargs = resource.calls[0]["kwargs"] + assert kwargs["deploy_spec"] == {"gpu": "a10"} + assert kwargs["billing_method"] == "monthly" + + def test_create_rejects_a_non_object_deploy_spec( + self, + invoke, + deployments, + ): + deployments(create=_deployment()) + result = invoke( + [ + "deployments", + "create", + "-m", + "qwen-plus-ft", + "--capacity", + "2", + "--deploy-spec", + "[1,2]", + ], + ) + assert result.exit_code == 2 + + def test_create_defaults_capacity_to_one(self, invoke, deployments): + resource = deployments(create=_deployment()) + result = invoke(["deployments", "create", "-m", "qwen-plus-ft"]) + assert result.exit_code == 0 + assert resource.calls[0]["kwargs"]["capacity"] == 1 + + +class TestRead: + def test_get(self, invoke, deployments): + resource = deployments(get=_deployment()) + result = invoke(["deployments", "get", "qwen-plus-ft-202601"]) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("qwen-plus-ft-202601",) + assert "RUNNING" in result.stdout + + def test_list_renders_rows(self, invoke, deployments): + deployments( + list=DeploymentList( + deployments=[ + _deployment(), + _deployment(deployed_model="other-202602"), + ], + page_no=1, + page_size=10, + total_count=2, + ), + ) + result = invoke(["deployments", "list"]) + assert result.exit_code == 0 + assert "qwen-plus-ft-202601" in result.stdout + assert "other-202602" in result.stdout + + def test_list_pagination_flags(self, invoke, deployments): + resource = deployments( + list=DeploymentList(deployments=[], page_no=2, page_size=5), + ) + invoke(["deployments", "list", "--page", "2", "--size", "5"]) + assert resource.calls[0]["kwargs"] == {"page_no": 2, "page_size": 5} + + def test_list_json_output_is_the_envelope(self, invoke, deployments): + deployments( + list=DeploymentList( + deployments=[_deployment()], + page_no=1, + page_size=10, + total_count=1, + ), + ) + result = invoke(["deployments", "list"], global_args=["-o", "json"]) + doc = json.loads(result.stdout) + assert doc["total_count"] == 1 + assert len(doc["deployments"]) == 1 + + +class TestScale: + def test_scale_with_yes(self, invoke, deployments): + resource = deployments(scale=_deployment(capacity=4)) + result = invoke( + [ + "deployments", + "scale", + "qwen-plus-ft-202601", + "--capacity", + "4", + "--yes", + ], + ) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("qwen-plus-ft-202601",) + assert resource.calls[0]["kwargs"] == {"capacity": 4} + + def test_scale_refuses_without_a_tty(self, invoke, deployments): + resource = deployments(scale=_deployment(capacity=4)) + result = invoke( + [ + "deployments", + "scale", + "qwen-plus-ft-202601", + "--capacity", + "4", + ], + ) + assert result.exit_code == 2 + assert "--yes" in result.stderr + assert resource.calls == [] + + def test_scale_confirms_in_a_tty(self, invoke, deployments, monkeypatch): + _as_terminal_tty(monkeypatch, answer=True) + resource = deployments(scale=_deployment(capacity=4)) + result = invoke( + [ + "deployments", + "scale", + "qwen-plus-ft-202601", + "--capacity", + "4", + ], + ) + assert result.exit_code == 0 + assert len(resource.calls) == 1 + + +class TestDelete: + def test_delete_with_yes(self, invoke, deployments): + resource = deployments(delete=JobStatus()) + result = invoke( + ["deployments", "delete", "qwen-plus-ft-202601", "--yes"], + ) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("qwen-plus-ft-202601",) + + def test_delete_refuses_without_a_tty(self, invoke, deployments): + resource = deployments(delete=JobStatus()) + result = invoke(["deployments", "delete", "qwen-plus-ft-202601"]) + assert result.exit_code == 2 + assert "--yes" in result.stderr + assert resource.calls == [] + + def test_delete_aborts_on_no(self, invoke, deployments, monkeypatch): + _as_terminal_tty(monkeypatch, answer=False) + resource = deployments(delete=JobStatus()) + result = invoke(["deployments", "delete", "qwen-plus-ft-202601"]) + assert result.exit_code != 0 + assert resource.calls == [] + + +class TestStream: + def test_stream_echoes_each_event(self, invoke, deployments): + resource = deployments( + stream_events=[ + DeploymentEvent(output={"status": "DEPLOYING"}), + DeploymentEvent(output={"status": "RUNNING"}), + ], + ) + result = invoke(["deployments", "stream", "qwen-plus-ft-202601"]) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("qwen-plus-ft-202601",) + lines = [line for line in result.stdout.splitlines() if line] + assert lines == ['{"status":"DEPLOYING"}', '{"status":"RUNNING"}'] + + def test_stream_emits_ndjson_for_machines(self, invoke, deployments): + deployments( + stream_events=[ + DeploymentEvent(output={"status": "DEPLOYING"}), + DeploymentEvent(output={"status": "RUNNING"}), + ], + ) + result = invoke( + ["deployments", "stream", "qwen-plus-ft-202601"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + docs = [ + json.loads(line) + for line in result.stdout.splitlines() + if line.strip() + ] + assert [doc["output"]["status"] for doc in docs] == [ + "DEPLOYING", + "RUNNING", + ] diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py new file mode 100644 index 00000000..da2a4b7b --- /dev/null +++ b/tests/cli/test_dispatch.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Routing at the independent ``dashscope`` console-script entry point.""" + +import sys +from types import ModuleType + +import pytest +import typer + +from dashscope.cli.main import ( + AGENT, + LEGACY, + LEGACY_DOTTED_COMMANDS, + LEGACY_GROUPS, + NEW, + NEW_COMMANDS, + classify, + main, +) + + +class TestClassify: + @pytest.mark.parametrize( + "argv", + [ + ["auth", "login", "--key", "sk-1"], + ["auth", "whoami"], + ["auth", "logout"], + ["oss", "upload", "-f", "input.png", "-m", "qwen-vl-max"], + ["chat", "create", "-m", "qwen-plus"], + ["ft", "list"], + ["fine-tunes", "get", "job-1"], + ["files", "list"], + ["models", "list"], + ["images", "generate", "-m", "wan2.2-t2i-flash"], + ["videos", "generate", "-m", "wan2.2-t2v-plus"], + ["embeddings", "create"], + ["multimodal", "chat", "create"], + ["rerank", "create"], + ["tokenization", "count"], + ["apps", "completions", "create", "--app-id", "app-1"], + ["deployments", "list"], + ["rl", "jobs", "list"], + ["audio", "tts", "synthesize", "hi"], + ["config", "list"], + ["version"], + ["--output", "json", "ft", "list"], + ["-k", "sk-1", "chat", "create"], + ["--api-key", "sk-1", "-o", "json", "ft", "list"], + ["--api-key=sk-1", "version"], + ["chatz", "create"], + ["auth_x"], + ["-k"], + ["--verbose"], + ["--help"], + ["-h"], + ["-k", "sk-1", "--help"], + ["chatz.create"], + ], + ) + def test_new_tree(self, argv): + assert classify(argv) == NEW + + @pytest.mark.parametrize( + "argv", + [ + [], + ["-k", "sk-1"], + ["--api-key=sk-1"], + ["expert", "what", "is", "lora"], + ["expert", "--help"], + ["--cli"], + ["--tui"], + ], + ) + def test_agent(self, argv): + assert classify(argv) == AGENT + + @pytest.mark.parametrize("group", sorted(LEGACY_GROUPS)) + def test_legacy_groups(self, group): + assert classify([group, "create"]) == LEGACY + + @pytest.mark.parametrize("command", sorted(LEGACY_DOTTED_COMMANDS)) + def test_legacy_dotted_commands(self, command): + assert classify([command]) == LEGACY + + @pytest.mark.parametrize( + "argv", + [ + ["login"], + ["whoami"], + ["logout"], + ["ft", "call"], + ["fine-tunes", "call"], + ["fine_tunes", "list"], + ["deployments", "call"], + ["tokenization", "create"], + ["rl", "run"], + ["rl", "list"], + ["agentic-rl", "register-functions"], + ], + ) + def test_legacy_verbs_on_shared_group_names(self, argv): + assert classify(argv) == LEGACY + + def test_global_options_do_not_hide_the_command(self): + assert ( + classify(["-k", "sk-1", "--output", "json", "ft", "list"]) == NEW + ) + + def test_value_of_a_global_option_is_not_read_as_the_command(self): + assert classify(["-o", "json", "generation", "create"]) == LEGACY + assert classify(["--output", "json", "ft", "list"]) == NEW + assert ( + classify(["-c", "output=json", "generation", "create"]) == LEGACY + ) + assert classify(["--timeout", "30", "application", "create"]) == LEGACY + assert classify(["--api-key", "sk-1", "generation.call"]) == LEGACY + + +class TestCommandNameSets: + def test_new_command_names_match_the_registered_tree(self): + from dashscope.cli.app import build_app + + root = typer.main.get_command(build_app()) + assert NEW_COMMANDS == set(root.commands) + + +@pytest.fixture +def expert(monkeypatch): + calls = [] + + def route_to_expert(command, tui=None): + calls.append((command, tui)) + + module = ModuleType("dashscope.acli.expert") + module._route_to_expert = route_to_expert + monkeypatch.setitem(sys.modules, "dashscope.acli.expert", module) + return calls + + +@pytest.fixture +def new_app(monkeypatch): + calls = [] + + def fake_build_app(): + def app(argv): + calls.append(list(argv)) + + return app + + monkeypatch.setattr("dashscope.cli.app.build_app", fake_build_app) + return calls + + +class TestMainDispatch: + def test_new_route_builds_and_calls_the_app(self, new_app, expert): + main(["version"]) + assert new_app == [["version"]] + assert expert == [] + + def test_reads_sys_argv_when_called_with_no_arguments( + self, + new_app, + expert, + monkeypatch, + ): + monkeypatch.setattr(sys, "argv", ["dashscope", "version"]) + main() + assert new_app == [["version"]] + + def test_bare_invocation_opens_the_agent(self, new_app, expert): + main([]) + assert expert == [(None, True)] + assert new_app == [] + + def test_shell_completion_routes_to_the_v2_app( + self, + new_app, + expert, + monkeypatch, + ): + monkeypatch.setenv("_DASHSCOPE_COMPLETE", "bash_complete") + main([]) + assert new_app == [[]] + assert expert == [] + + def test_agent_prompt_and_tui_flags(self, new_app, expert): + main(["expert", "what", "is", "lora"]) + assert expert == [("what is lora", True)] + expert.clear() + main(["--cli"]) + assert expert == [(None, False)] + expert.clear() + main(["expert", "--cli", "hi"]) + assert expert == [("hi", False)] + + def test_agent_receives_the_api_key(self, new_app, expert, monkeypatch): + import dashscope + + monkeypatch.setattr(dashscope, "api_key", None, raising=False) + main(["--api-key=sk-agent", "expert", "hi"]) + assert expert == [("hi", True)] + assert dashscope.api_key == "sk-agent" + + def test_agent_help_is_answered_without_starting_the_agent( + self, + capsys, + new_app, + expert, + ): + main(["expert", "--help"]) + assert expert == [] + assert "DashScope SDK Expert" in capsys.readouterr().out + + @pytest.mark.parametrize( + "argv", + [ + ["generation", "call", "-m", "qwen-plus", "-p", "hi"], + ["fine_tunes.list", "--page_size", "5"], + ["fine-tunes", "call", "-m", "base"], + ["agentic-rl", "run", "-c", "rl.toml"], + ["whoami"], + ], + ) + def test_legacy_commands_exit_with_a_dashscope1_hint( + self, + argv, + new_app, + expert, + capsys, + ): + with pytest.raises(SystemExit) as exc_info: + main(argv) + assert exc_info.value.code == 2 + error = capsys.readouterr().err + assert "No such command" in error + assert "CLI 1.x commands have moved to 'dashscope1'" in error + assert new_app == [] + assert expert == [] + + @pytest.mark.parametrize( + "argv", + [ + ["embeddings", "create", "-i", "hello"], + ["fine-tunes", "create", "--training_file_ids", "f1"], + ["deployments", "create", "--capacity", "2"], + ], + ) + def test_v2_argv_is_not_rewritten(self, argv, new_app, expert): + main(argv) + assert new_app == [argv] + assert expert == [] + + @pytest.mark.parametrize( + "argv", + [ + ["apps", "completions", "create", "--app-id", "app-1"], + ["rl", "jobs", "get", "job-1"], + ["rl", "functions", "register", "--rollout", "pkg.mod:Rollout"], + ], + ) + def test_sdk_shaped_commands_use_v2(self, argv, new_app, expert): + main(argv) + assert new_app == [argv] + assert expert == [] diff --git a/tests/cli/test_embeddings_cmd.py b/tests/cli/test_embeddings_cmd.py new file mode 100644 index 00000000..dcbe3370 --- /dev/null +++ b/tests/cli/test_embeddings_cmd.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope embeddings`` -- create and jobs subgroup.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope import errors as sdk_errors +from dashscope.cli.app import build_app +from dashscope.resources._jobs import Job, JobList +from dashscope.resources.embeddings import Embedding, EmbeddingResponse +from dashscope.types.common import Usage + + +def _embedding_response(**overrides: Any) -> EmbeddingResponse: + data: Dict[str, Any] = { + "embeddings": [ + Embedding(index=0, embedding=[0.1, 0.2, 0.3]), + ], + "usage": Usage(input_tokens=5, total_tokens=5), + } + data.update(overrides) + return EmbeddingResponse(**data) + + +def _job(**overrides: Any) -> Job: + data: Dict[str, Any] = { + "task_id": "task-abc123", + "status": "SUCCEEDED", + "model": "text-embedding-v3", + "created_at": "2026-01-01 12:00:00", + } + data.update(overrides) + return Job(**data) + + +class _EmbeddingJobs: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def create(self, **kwargs): + return self._record("create", **kwargs) + + def fetch(self, task_id, **kwargs): + return self._record("fetch", task_id, **kwargs) + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def cancel(self, task_id, **kwargs): + return self._record("cancel", task_id, **kwargs) + + def wait(self, job, **kwargs): + self._record("wait", job, **kwargs) + return self._results.get("wait", job) + + +class _Embeddings: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + self.jobs = _EmbeddingJobs(**results.get("jobs", {})) + + def create(self, **kwargs): + self.calls.append({"method": "create", "kwargs": kwargs}) + result = self._results.get("create") + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def embeddings(make_client): + def install(**results: Any) -> _Embeddings: + resource = _Embeddings(**results) + make_client(embeddings=resource) + return resource + + return install + + +class TestCreate: + def test_create_single_text(self, invoke, embeddings): + resource = embeddings(create=_embedding_response()) + result = invoke( + [ + "embeddings", + "create", + "-m", + "text-embedding-v3", + "--input", + "hello", + ], + ) + assert result.exit_code == 0 + assert resource.calls[0]["kwargs"]["input"] == "hello" + + def test_create_table_output_summarises(self, invoke, embeddings): + embeddings(create=_embedding_response()) + result = invoke( + [ + "embeddings", + "create", + "-m", + "text-embedding-v3", + "--input", + "hello", + ], + ) + assert result.stdout.strip() == "1 embedding(s), 3 dimensions" + # The counters go to stderr, so stdout stays a one-line summary. + assert "'input_tokens': 5" in result.stderr + + def test_create_json_array_input(self, invoke, embeddings): + resource = embeddings(create=_embedding_response()) + invoke( + [ + "embeddings", + "create", + "-m", + "text-embedding-v3", + "--input", + '["hello", "world"]', + ], + ) + assert resource.calls[0]["kwargs"]["input"] == ["hello", "world"] + + def test_create_with_options(self, invoke, embeddings): + resource = embeddings(create=_embedding_response()) + invoke( + [ + "embeddings", + "create", + "-m", + "text-embedding-v3", + "--input", + "hello", + "--text-type", + "query", + "--dimension", + "512", + ], + ) + kwargs = resource.calls[0]["kwargs"] + assert kwargs["text_type"] == "query" + assert kwargs["dimension"] == 512 + + def test_create_json_output(self, invoke, embeddings): + embeddings(create=_embedding_response()) + result = invoke( + [ + "embeddings", + "create", + "-m", + "text-embedding-v3", + "--input", + "hi", + ], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert len(doc["embeddings"]) == 1 + + def test_create_requires_input(self, invoke, embeddings): + embeddings(create=_embedding_response()) + result = invoke(["embeddings", "create", "-m", "text-embedding-v3"]) + assert result.exit_code == 2 + + +class TestJobs: + def test_jobs_create(self, invoke, embeddings): + resource = embeddings(jobs={"create": _job()}) + result = invoke( + [ + "embeddings", + "jobs", + "create", + "-m", + "text-embedding-v3", + "--file-url", + "https://example.com/texts.txt", + ], + ) + assert result.exit_code == 0 + kwargs = resource.jobs.calls[0]["kwargs"] + assert kwargs["file_url"] == "https://example.com/texts.txt" + + def test_jobs_get(self, invoke, embeddings): + resource = embeddings(jobs={"fetch": _job()}) + invoke(["embeddings", "jobs", "get", "task-abc123"]) + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_list(self, invoke, embeddings): + embeddings( + jobs={ + "list": JobList( + tasks=[_job()], + page_no=1, + page_size=10, + total=1, + ), + }, + ) + result = invoke(["embeddings", "jobs", "list"]) + assert result.exit_code == 0 + assert "task-abc123" in result.stdout + + def test_jobs_list_reads_the_real_envelope_fields( + self, + invoke, + embeddings, + ): + """``JobList`` spells them ``tasks``/``total``, not ``jobs``/``total_count``.""" + embeddings(jobs={"list": JobList(tasks=[_job()], total=1)}) + result = invoke(["embeddings", "jobs", "list"]) + assert result.exit_code == 0 + assert "total 1" in result.stderr + + def test_jobs_cancel(self, invoke, embeddings): + resource = embeddings(jobs={"cancel": _job(status="CANCELED")}) + invoke(["embeddings", "jobs", "cancel", "task-abc123", "--yes"]) + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_cancel_refuses_without_a_tty(self, invoke, embeddings): + embeddings(jobs={"cancel": _job(status="CANCELED")}) + result = invoke(["embeddings", "jobs", "cancel", "task-abc123"]) + assert result.exit_code == 2 + assert "--yes" in result.stderr + + def test_jobs_wait_polls_the_id_and_nothing_else(self, invoke, embeddings): + resource = embeddings(jobs={"wait": _job()}) + result = invoke(["embeddings", "jobs", "wait", "task-abc123"]) + assert result.exit_code == 0 + assert [c["method"] for c in resource.jobs.calls] == ["wait"] + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_wait_defaults_to_an_hour(self, invoke, embeddings): + resource = embeddings(jobs={"wait": _job()}) + invoke(["embeddings", "jobs", "wait", "task-abc123"]) + assert resource.jobs.calls[0]["kwargs"] == {"timeout": 3600.0} + + def test_jobs_wait_table_shows_the_settled_task(self, invoke, embeddings): + embeddings(jobs={"wait": _job(status="SUCCEEDED")}) + result = invoke(["embeddings", "jobs", "wait", "task-abc123"]) + assert "SUCCEEDED" in result.stdout + + def test_jobs_wait_json_output_is_the_whole_job(self, invoke, embeddings): + embeddings(jobs={"wait": _job()}) + result = invoke( + ["embeddings", "jobs", "wait", "task-abc123"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["task_id"] == "task-abc123" + assert doc["status"] == "SUCCEEDED" + + def test_jobs_wait_failure_exits_nonzero(self, invoke, embeddings): + embeddings( + jobs={"wait": sdk_errors.InternalServerError("task exploded")}, + ) + result = invoke(["embeddings", "jobs", "wait", "task-abc123"]) + assert result.exit_code == 1 + assert "Error [server]: task exploded" in result.stderr + + def test_jobs_wait_requires_the_id(self, invoke, embeddings): + resource = embeddings(jobs={"wait": _job()}) + assert invoke(["embeddings", "jobs", "wait"]).exit_code == 2 + assert resource.jobs.calls == [] + + @pytest.mark.parametrize("timeout", ["0", "nan"]) + def test_jobs_wait_rejects_a_timeout_it_could_not_honour( + self, + invoke, + embeddings, + timeout, + ): + """``nan`` is false against every comparison, so the poll never ends.""" + resource = embeddings(jobs={"wait": _job()}) + result = invoke( + [ + "embeddings", + "jobs", + "wait", + "task-abc123", + "--timeout", + timeout, + ], + ) + assert result.exit_code == 2 + assert "--timeout" in result.stderr + assert resource.jobs.calls == [] diff --git a/tests/cli/test_errors.py b/tests/cli/test_errors.py new file mode 100644 index 00000000..ded44ffe --- /dev/null +++ b/tests/cli/test_errors.py @@ -0,0 +1,492 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +# pylint: disable=protected-access + +"""Exit-code tiers, classification and the root error handler.""" + +import pytest +import typer +from typer.testing import CliRunner + +from dashscope import _error_registry as registry +from dashscope import errors as sdk_errors +from dashscope.cli import errors as cli_errors +from dashscope.cli.errors import ( + AUTH, + EXIT_AUTH, + EXIT_NOT_FOUND, + EXIT_OK, + EXIT_SERVER, + EXIT_USAGE, + NETWORK, + NOTFOUND, + RATELIMIT, + SERVER, + USAGE, + CliError, + ErrorHandlingGroup, + classify, + exit_code_for, + usage_error, +) +from tests.cli.conftest import strip_ansi_codes + + +def _runner() -> CliRunner: + """Separate stdout/stderr: the CLI promises clean JSON on stdout.""" + try: + return CliRunner(mix_stderr=False) + except TypeError: # click >= 8.2 always separates the streams + return CliRunner() + + +class TestTierIsomorphism: + def test_every_category_maps_to_a_tier(self): + for category in cli_errors.CATEGORIES: + assert exit_code_for(category) in ( + EXIT_SERVER, + EXIT_USAGE, + EXIT_AUTH, + EXIT_NOT_FOUND, + ) + + def test_documented_tiers(self): + assert ( + EXIT_OK, + EXIT_SERVER, + EXIT_USAGE, + EXIT_AUTH, + EXIT_NOT_FOUND, + ) == ( + 0, + 1, + 2, + 3, + 4, + ) + assert exit_code_for(USAGE) == 2 + assert exit_code_for(AUTH) == 3 + assert exit_code_for(NOTFOUND) == 4 + for category in (SERVER, RATELIMIT, NETWORK): + assert exit_code_for(category) == 1 + + +class TestClassify: + @pytest.mark.parametrize( + "exc,category,exit_code", + [ + (sdk_errors.AuthenticationError("nope"), AUTH, 3), + (sdk_errors.PermissionDeniedError("nope"), AUTH, 3), + (sdk_errors.RateLimitError("slow down"), RATELIMIT, 1), + (sdk_errors.NotFoundError("gone"), NOTFOUND, 4), + (sdk_errors.BadRequestError("bad"), USAGE, 2), + (sdk_errors.RequestTooLargeError("big"), USAGE, 2), + (sdk_errors.InternalServerError("boom"), SERVER, 1), + (sdk_errors.ServiceUnavailableError("down"), SERVER, 1), + (sdk_errors.GatewayTimeoutError("late"), SERVER, 1), + (sdk_errors.APIStatusError("status"), SERVER, 1), + (sdk_errors.APITimeoutError("timeout"), NETWORK, 1), + (sdk_errors.APIConnectionError("conn"), NETWORK, 1), + (sdk_errors.DashScopeError("sdk"), SERVER, 1), + (sdk_errors.WsEventError("ws"), SERVER, 1), + (ValueError("bad protocol"), USAGE, 2), + (RuntimeError("unexpected"), SERVER, 1), + ], + ) + def test_categories(self, exc, category, exit_code): + assert classify(exc)[0] == category + assert exit_code_for(category) == exit_code + + def test_content_filter_inherits_bad_request_tier(self): + """The catalog names ``BadRequestError``; V2 refines it by subclassing. + + ``ContentFilterError`` is therefore *not* a row of the derived + table -- it has to reach ``usage`` through its parent, which is + what keeps the refinement catalog-legal. + """ + assert issubclass( + sdk_errors.ContentFilterError, + sdk_errors.BadRequestError, + ) + assert classify(sdk_errors.ContentFilterError("blocked"))[0] == USAGE + + def test_timeout_is_network_via_its_catalog_code(self): + # APITimeoutError subclasses APIConnectionError; both are + # SdkErrors, categorised by their catalog code rather than by + # class, so the subclassing is irrelevant to the tier. + assert sdk_errors.APITimeoutError("t").code == "sdk.Timeout" + assert isinstance( + sdk_errors.APITimeoutError("t"), + sdk_errors.APIConnectionError, + ) + assert classify(sdk_errors.APITimeoutError("t"))[0] == NETWORK + + def test_exit_code_override_wins(self): + assert CliError(USAGE, "x", exit_code=7).exit_code == 7 + + def test_message_is_passed_through_verbatim(self): + message = ( + "Model not exist [request_id=abc] & \"quoted\" 'single'" + ) + exc = sdk_errors.NotFoundError(message, request_id="abc") + diagnosis = classify(exc) + assert diagnosis.category == NOTFOUND + assert diagnosis.message == message + assert diagnosis.request_id == "abc" + + def test_diagnosis_still_unpacks_as_a_tuple(self): + category, message, solution, request_id = classify( + sdk_errors.NotFoundError("gone", request_id="r"), + ) + assert (category, message, solution, request_id) == ( + NOTFOUND, + "gone", + None, + "r", + ) + + def test_cli_error_carries_its_own_category(self): + diagnosis = classify(CliError(AUTH, "x")) + assert (diagnosis.category, diagnosis.message) == (AUTH, "x") + + def test_cli_error_rejects_unknown_category(self): + with pytest.raises(ValueError): + _ = CliError("nope", "x") + + def test_usage_error_shorthand(self): + exc = usage_error("bad flag") + assert (exc.category, exc.message, exc.exit_code) == ( + USAGE, + "bad flag", + None, + ) + + +class TestCatalogDerivation: + """The tables are read out of ``_error_registry.py``, not hand-listed. + + These are the drift guards: regenerate the catalog and one of them + fails, pointing at the single human decision (a new status, or a new + SDK code that needs a tier) rather than letting the CLI silently + mis-slot it. + """ + + def test_status_table_covers_exactly_the_catalog_statuses(self): + assert set(cli_errors._STATUS_CATEGORY) == { + entry.status_code for entry in registry.PUBLIC_ERRORS + } + + def test_every_status_maps_to_a_declared_category(self): + for category in cli_errors._STATUS_CATEGORY.values(): + assert category in cli_errors.CATEGORIES + + def test_sdk_code_table_keys_are_real_catalog_names(self): + assert set(cli_errors._SDK_CODE_CATEGORY) <= { + entry.name for entry in registry.SDK_ERRORS + } + + def test_public_table_names_every_catalog_class(self): + assert {cls for cls, _ in cli_errors._public_table()} == ( + { + getattr(sdk_errors, entry.error_code) + for entry in registry.PUBLIC_ERRORS + } + | {sdk_errors.APIStatusError} + ) + + def test_public_table_has_one_row_per_class(self): + rows = cli_errors._public_table() + assert len({cls for cls, _ in rows}) == len(rows) + + def test_base_class_is_matched_last(self): + # Every other row subclasses APIStatusError, so it must stay at + # the end of the isinstance walk or it would swallow them all. + assert cli_errors._public_table()[-1][0] is sdk_errors.APIStatusError + + @pytest.mark.parametrize( + "entry", + registry.PUBLIC_ERRORS, + ids=lambda e: e.key, + ) + def test_each_catalog_entry_lands_on_its_status_tier(self, entry): + cls = getattr(sdk_errors, entry.error_code) + assert classify(cls(entry.error_msg))[0] == ( + cli_errors._STATUS_CATEGORY[entry.status_code] + ) + + def test_undeclared_sdk_code_falls_to_server(self): + # sdk.ws.* has no tier of its own: a protocol or state violation + # is neither the caller's command line nor a credential problem. + exc = sdk_errors.sdk_error("sdk.ws.UpstreamQueueFull") + assert classify(exc)[0] == SERVER + + def test_sdk_invalid_api_key_is_auth_not_server(self): + # A generic SdkError used to reach the DashScopeError fallback and + # exit 1; the catalog name says plainly what this one is. + assert classify(sdk_errors.sdk_error("sdk.InvalidApiKey"))[0] == AUTH + assert exit_code_for(AUTH) == 3 + + def test_local_io_failure_is_usage(self): + # The entry's own solution reads "verify the file path exists and + # is readable" -- the caller's mistake, so tier 2. + exc = sdk_errors.sdk_error("sdk.IoError", {"detail": "no such file"}) + assert classify(exc)[0] == USAGE + + +class TestCatalogMessageFallback: + def test_server_wording_always_wins(self): + assert classify(sdk_errors.NotFoundError("gone")).message == "gone" + + def test_empty_message_falls_back_to_the_catalog(self): + assert classify(sdk_errors.NotFoundError("")).message == ( + registry.RESOURCE_NOT_FOUND.error_msg + ) + + def test_placeholder_is_left_verbatim(self): + # Nothing here knows the resource name; stripping the placeholder + # would hide the field the catalog declares. + assert "{resource}" in classify(sdk_errors.NotFoundError("")).message + + def test_ambiguous_entry_is_not_guessed(self): + # Five catalog rows name BadRequestError with different wording, + # so naming the class does not identify the entry. + assert classify(sdk_errors.BadRequestError("")).message == "" + + def test_sdk_error_falls_back_to_its_own_entry(self): + # _CatalogSdkError always formats its message from the entry, so + # the fallback only fires on a bare SdkError built without one. + exc = sdk_errors.SdkError("", code="sdk.ConnectionError") + assert classify(exc).message == registry.SDK_CONNECTION_ERROR.message + + +class TestCatalogSolution: + def test_sdk_errors_carry_their_entries_solution(self): + exc = sdk_errors.APIConnectionError(detail="connection refused") + assert classify(exc).solution == ( + registry.SDK_CONNECTION_ERROR.solution + ) + + def test_gateway_errors_have_no_solution_to_render(self): + # PUBLIC_ERRORS carries no solution field: the gateway's own + # message is the whole of what the CLI may say. + assert classify(sdk_errors.NotFoundError("gone")).solution is None + + def test_solution_is_rendered_on_its_own_line(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + + def raise_it(): + raise sdk_errors.APIConnectionError(detail="connection refused") + + result = _runner().invoke(_app(raise_it), ["run"]) + assert result.exit_code == 1 + lines = strip_ansi_codes(result.stderr).splitlines() + assert ( + lines[0] + == "Error [network]: Connection failed: connection refused." + ) + assert lines[1].startswith("solution: Check network connectivity") + # The service's wording and the registry's stay separable. + assert "solution:" not in lines[0] + + +def _app(body): + """An app with two commands. + + Typer collapses a one-command app into a plain ``TyperCommand`` and + ignores ``cls=``, so a single-command fixture would silently test + nothing. The real root app always has several subcommands. + """ + app = typer.Typer(cls=ErrorHandlingGroup) + + @app.command() + def run(): + body() + + @app.command() + def other(): + pass + + return app + + +class TestErrorHandlingGroup: + def test_success_exits_zero(self): + result = _runner().invoke(_app(lambda: None), ["run"]) + assert result.exit_code == 0 + + @pytest.mark.parametrize( + "exc,expected", + [ + (sdk_errors.AuthenticationError("bad key"), 3), + (sdk_errors.NotFoundError("missing"), 4), + (sdk_errors.RateLimitError("slow"), 1), + (sdk_errors.InternalServerError("boom"), 1), + (CliError(USAGE, "nope"), 2), + (ValueError("bad protocol"), 2), + (RuntimeError("unexpected"), 1), + ], + ) + def test_exit_code_by_category(self, exc, expected): + def raise_it(): + raise exc + + result = _runner().invoke(_app(raise_it), ["run"]) + assert result.exit_code == expected + + def test_stderr_line_format_and_clean_stdout(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + + def raise_it(): + raise sdk_errors.NotFoundError( + "job xyz does not exist", + request_id="req-1", + ) + + result = _runner().invoke(_app(raise_it), ["run"]) + assert result.exit_code == 4 + assert result.stdout == "" + stderr = strip_ansi_codes(result.stderr) + assert "Error [notfound]: job xyz does not exist" in stderr + assert "request_id: req-1" in stderr + + def test_server_message_is_not_reworded(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + raw = "Throttling.RateQuota: Requests rate limit exceeded, please try again." + + def raise_it(): + raise sdk_errors.RateLimitError(raw) + + result = _runner().invoke(_app(raise_it), ["run"]) + assert raw in strip_ansi_codes(result.stderr) + + def test_long_message_is_not_wrapped_to_terminal_width(self, monkeypatch): + """Rich wraps at 80 columns by default; a server message must not. + + Re-flowing the text would break anyone grepping stderr and would + hand a support ticket a message with newlines the service never + sent. + """ + monkeypatch.setenv("NO_COLOR", "1") + raw = ( + "Requests rate limit exceeded for model qwen-plus in " + "workspace ws-1234567890, please try again later. " * 3 + ).strip() + assert len(raw) > 80 + + def raise_it(): + raise sdk_errors.RateLimitError(raw) + + result = _runner().invoke(_app(raise_it), ["run"]) + stderr = strip_ansi_codes(result.stderr) + assert f"Error [ratelimit]: {raw}" in stderr + + def test_brackets_in_message_survive_markup_escaping(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + + def raise_it(): + raise sdk_errors.BadRequestError("invalid [red] param") + + result = _runner().invoke(_app(raise_it), ["run"]) + assert "invalid [red] param" in strip_ansi_codes(result.stderr) + + def test_verbose_adds_traceback(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + app = typer.Typer(cls=ErrorHandlingGroup) + + @app.callback() + def root(verbose: bool = False): + pass + + @app.command() + def run(): + raise RuntimeError("kaboom") + + quiet = _runner().invoke(app, ["run"]) + assert "Traceback" not in strip_ansi_codes(quiet.stderr) + + # The callback never ran to completion, so ctx.obj is unset and + # the handler falls back to ctx.params -- pin that path. + loud = _runner().invoke(app, ["--verbose", "run"]) + assert loud.exit_code == 1 + assert "Traceback" in strip_ansi_codes(loud.stderr) + + def test_typer_exit_is_not_converted(self): + def abort(): + raise typer.Exit(9) + + result = _runner().invoke(_app(abort), ["run"]) + assert result.exit_code == 9 + + def test_click_usage_error_passes_through(self): + app = typer.Typer(cls=ErrorHandlingGroup) + + @app.command() + def run(name: str): + typer.echo(name) + + @app.command() + def other(): + pass + + result = _runner().invoke(app, ["run"]) + assert result.exit_code == 2 + assert "Missing argument" in strip_ansi_codes(result.stderr) + + def test_keyboard_interrupt_is_not_an_error_line(self): + def interrupt(): + raise KeyboardInterrupt + + result = _runner().invoke(_app(interrupt), ["run"]) + # click turns an interrupt into its own exit code; the point is + # that the handler did not claim it as a CLI error line. + assert result.exit_code != 0 + assert "Error [" not in strip_ansi_codes(result.stderr) + + def test_context_object_is_closed_on_failure(self): + closed = [] + + class Obj: + verbose = False + + def close(self): + closed.append(True) + + app = typer.Typer(cls=ErrorHandlingGroup) + + @app.callback() + def root(ctx: typer.Context): + ctx.obj = Obj() + + @app.command() + def run(): + raise RuntimeError("kaboom") + + @app.command() + def other(): + pass + + result = _runner().invoke(app, ["run"]) + assert result.exit_code == 1 + assert closed == [True] + + def test_cls_is_actually_respected(self): + """The handler only works if typer really used our group class. + + A one-command app collapses to ``TyperCommand`` and drops + ``cls=``, which is why this builds two commands. + """ + app = _app(lambda: None) + + built = typer.main.get_command(app) + assert isinstance(built, ErrorHandlingGroup) + + def test_single_command_app_would_not_be_a_group(self): + """Pin the collapse behaviour the fixture above works around.""" + app = typer.Typer(cls=ErrorHandlingGroup) + + @app.command() + def solo(): + pass + + assert not isinstance(typer.main.get_command(app), ErrorHandlingGroup) diff --git a/tests/cli/test_files_cmd.py b/tests/cli/test_files_cmd.py new file mode 100644 index 00000000..86667824 --- /dev/null +++ b/tests/cli/test_files_cmd.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope files`` -- upload, list, get, delete.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.files import DeletionStatus, File, FileList + + +def _file(**overrides: Any) -> File: + data: Dict[str, Any] = { + "file_id": "file-abc123", + "name": "train.jsonl", + "purpose": "fine-tune", + "size": 1024, + "uploaded_at": "2026-01-01 12:00:00", + } + data.update(overrides) + return File(**data) + + +class _Files: + """Stand-in for ``client.files`` recording every call.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def upload(self, **kwargs): + return self._record("upload", **kwargs) + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def get(self, file_id, **kwargs): + return self._record("get", file_id, **kwargs) + + def delete(self, file_id, **kwargs): + return self._record("delete", file_id, **kwargs) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def files(make_client): + def install(**results: Any) -> _Files: + resource = _Files(**results) + make_client(files=resource) + return resource + + return install + + +def _as_terminal_tty(monkeypatch, answer: bool): + monkeypatch.setattr("dashscope.cli.confirm.stdin_is_tty", lambda: True) + monkeypatch.setattr( + "dashscope.cli.confirm.typer.confirm", + lambda *a, **k: answer, + ) + + +class TestUpload: + def test_upload_calls_sdk_with_path_and_purpose( + self, + invoke, + files, + tmp_path, + ): + payload = tmp_path / "train.jsonl" + payload.write_text('{"messages": []}\n', encoding="utf-8") + resource = files(upload=_file()) + result = invoke(["files", "upload", str(payload)]) + assert result.exit_code == 0 + assert resource.calls[0]["kwargs"]["file_path"] == str(payload) + assert resource.calls[0]["kwargs"]["purpose"] == "fine-tune" + + def test_upload_passes_description(self, invoke, files, tmp_path): + payload = tmp_path / "data.jsonl" + payload.write_text("{}\n", encoding="utf-8") + resource = files(upload=_file()) + invoke(["files", "upload", str(payload), "--description", "my data"]) + assert resource.calls[0]["kwargs"]["description"] == "my data" + + def test_upload_json_output(self, invoke, files, tmp_path): + payload = tmp_path / "train.jsonl" + payload.write_text("{}\n", encoding="utf-8") + files(upload=_file()) + result = invoke( + ["files", "upload", str(payload)], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + import json + + doc = json.loads(result.stdout) + assert doc["file_id"] == "file-abc123" + + +class TestList: + def test_list_renders_rows(self, invoke, files): + files( + list=FileList( + files=[_file(), _file(file_id="file-def456")], + page_no=1, + page_size=10, + total_count=2, + ), + ) + result = invoke(["files", "list"]) + assert result.exit_code == 0 + assert "file-abc123" in result.stdout + assert "file-def456" in result.stdout + + def test_list_pagination_flags(self, invoke, files): + resource = files( + list=FileList(files=[], page_no=2, page_size=5, total_count=0), + ) + invoke(["files", "list", "--page", "2", "--size", "5"]) + assert resource.calls[0]["kwargs"] == {"page_no": 2, "page_size": 5} + + def test_list_json_output(self, invoke, files): + files( + list=FileList( + files=[_file()], + page_no=1, + page_size=10, + total_count=1, + ), + ) + result = invoke(["files", "list"], global_args=["-o", "json"]) + assert result.exit_code == 0 + import json + + doc = json.loads(result.stdout) + assert len(doc["files"]) == 1 + + +class TestGet: + def test_get_by_id(self, invoke, files): + resource = files(get=_file()) + result = invoke(["files", "get", "file-abc123"]) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("file-abc123",) + + def test_get_json_output(self, invoke, files): + files(get=_file()) + result = invoke( + ["files", "get", "file-abc123"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + import json + + doc = json.loads(result.stdout) + assert doc["file_id"] == "file-abc123" + + +class TestDelete: + def test_delete_with_yes(self, invoke, files): + resource = files(delete=DeletionStatus(deleted=True)) + result = invoke(["files", "delete", "file-abc123", "--yes"]) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("file-abc123",) + + def test_delete_refuses_without_tty(self, invoke, files): + files(delete=DeletionStatus(deleted=True)) + result = invoke(["files", "delete", "file-abc123"]) + assert result.exit_code == 2 + + def test_delete_confirms_in_tty(self, invoke, files, monkeypatch): + _as_terminal_tty(monkeypatch, answer=True) + resource = files(delete=DeletionStatus(deleted=True)) + result = invoke(["files", "delete", "file-abc123"]) + assert result.exit_code == 0 + assert len(resource.calls) == 1 + + def test_delete_aborts_on_no(self, invoke, files, monkeypatch): + _as_terminal_tty(monkeypatch, answer=False) + resource = files(delete=DeletionStatus(deleted=True)) + result = invoke(["files", "delete", "file-abc123"]) + assert result.exit_code != 0 + assert len(resource.calls) == 0 diff --git a/tests/cli/test_ft_cmd.py b/tests/cli/test_ft_cmd.py new file mode 100644 index 00000000..695acb4d --- /dev/null +++ b/tests/cli/test_ft_cmd.py @@ -0,0 +1,725 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope ft`` -- the second pattern command group.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope import errors as sdk_errors +from dashscope.cli.app import build_app +from dashscope.resources.fine_tunes import ( + FineTune, + FineTuneEvent, + FineTuneList, + JobLogs, +) +from dashscope.types.common import JobStatus, Usage + + +def _job(**overrides: Any) -> FineTune: + data: Dict[str, Any] = { + "job_id": "ft-202601011234-abc1", + "status": "SUCCEEDED", + "model": "qwen-plus", + "finetuned_model": "qwen-plus-ft-abc1", + "created_at": "2026-01-01 12:34:56", + "usage": Usage(input_tokens=10, output_tokens=0, total_tokens=10), + } + data.update(overrides) + return FineTune(**data) + + +class _FineTunes: + """Stand-in for ``client.fine_tunes`` recording every call.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def create(self, **kwargs): + return self._record("create", **kwargs) + + def get(self, job_id, **kwargs): + return self._record("get", job_id, **kwargs) + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def cancel(self, job_id, **kwargs): + return self._record("cancel", job_id, **kwargs) + + def delete(self, job_id, **kwargs): + return self._record("delete", job_id, **kwargs) + + def stream_events(self, job_id, **kwargs): + self._record("stream_events", job_id, **kwargs) + return self._results["stream_events"] + + def logs(self, job_id, **kwargs): + return self._record("logs", job_id, **kwargs) + + def wait(self, job, **kwargs): + self._record("wait", job, **kwargs) + return self._results.get("wait", job) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def fine_tunes(make_client): + """Install a fake client and hand back its ``fine_tunes`` recorder.""" + + def install(**results: Any) -> _FineTunes: + resource = _FineTunes(**results) + make_client(fine_tunes=resource) + return resource + + return install + + +def _as_terminal_tty(monkeypatch, answer: bool): + monkeypatch.setattr("dashscope.cli.confirm.stdin_is_tty", lambda: True) + monkeypatch.setattr( + "dashscope.cli.confirm.typer.confirm", + lambda *a, **k: answer, + ) + + +class TestCreate: + def test_required_options_are_enforced(self, invoke, fine_tunes): + fine_tunes(create=_job()) + assert invoke(["ft", "create"]).exit_code == 2 + assert invoke(["ft", "create", "-m", "qwen-plus"]).exit_code == 2 + + def test_the_model_does_not_fall_back_to_default_model( + self, + invoke, + fine_tunes, + clean_env, + ): + """A chat default is not a fine-tune base model.""" + from dashscope.cli import config_store + + config_store.set_value("default_model", "qwen-plus") + result = invoke(["ft", "create", "-t", "file-1"]) + assert result.exit_code == 2 + + def test_ids_and_model_reach_the_sdk(self, invoke, fine_tunes): + resource = fine_tunes(create=_job()) + assert ( + invoke( + [ + "ft", + "create", + "-m", + "qwen-plus", + "-t", + "file-1", + "-t", + "file-2", + "-v", + "file-3", + ], + ).exit_code + == 0 + ) + call = resource.calls[0] + assert call["kwargs"]["model"] == "qwen-plus" + assert call["kwargs"]["training_file_ids"] == ["file-1", "file-2"] + assert call["kwargs"]["validation_file_ids"] == ["file-3"] + + def test_shortcut_flags_become_hyper_parameters(self, invoke, fine_tunes): + resource = fine_tunes(create=_job()) + invoke( + [ + "ft", + "create", + "-m", + "qwen-plus", + "-t", + "f", + "-e", + "3", + "-b", + "8", + "-l", + "0.0001", + "-p", + "0.5", + ], + ) + assert resource.calls[0]["kwargs"]["hyper_parameters"] == { + "n_epochs": 3, + "batch_size": 8, + "learning_rate": 0.0001, + "prompt_loss": 0.5, + } + + def test_explicit_hyper_parameters_pass_through(self, invoke, fine_tunes): + resource = fine_tunes(create=_job()) + invoke( + [ + "ft", + "create", + "-m", + "qwen-plus", + "-t", + "f", + "--hyper-parameters", + '{"n_epochs": 2}', + ], + ) + assert resource.calls[0]["kwargs"]["hyper_parameters"] == { + "n_epochs": 2, + } + + def test_the_two_spellings_of_one_key_are_refused( + self, + invoke, + fine_tunes, + ): + """Silently picking a winner would hide a typo in the loser.""" + resource = fine_tunes(create=_job()) + result = invoke( + [ + "ft", + "create", + "-m", + "qwen-plus", + "-t", + "f", + "--hyper-parameters", + '{"n_epochs": 2}', + "-e", + "3", + ], + ) + assert result.exit_code == 2 + assert "n_epochs" in result.stderr + assert resource.calls == [] + + def test_invalid_hyper_parameters_json_is_a_usage_error( + self, + invoke, + fine_tunes, + ): + fine_tunes(create=_job()) + result = invoke( + [ + "ft", + "create", + "-m", + "qwen-plus", + "-t", + "f", + "--hyper-parameters", + "[", + ], + ) + assert result.exit_code == 2 + assert "--hyper-parameters" in result.stderr + + def test_table_output_names_the_job(self, invoke, fine_tunes): + fine_tunes(create=_job()) + result = invoke(["ft", "create", "-m", "qwen-plus", "-t", "f"]) + assert "ft-202601011234-abc1" in result.stdout + assert "SUCCEEDED" in result.stdout + + def test_json_output_is_the_whole_job(self, invoke, fine_tunes): + fine_tunes(create=_job()) + result = invoke( + ["ft", "create", "-m", "qwen-plus", "-t", "f"], + global_args=["-o", "json"], + ) + payload = json.loads(result.stdout) + assert payload["job_id"] == "ft-202601011234-abc1" + assert payload["finetuned_model"] == "qwen-plus-ft-abc1" + + +class TestWait: + def test_wait_polls_through_the_sdk(self, invoke, fine_tunes): + resource = fine_tunes(create=_job(status="RUNNING"), wait=_job()) + assert ( + invoke( + ["ft", "create", "-m", "qwen-plus", "-t", "f", "--wait"], + ).exit_code + == 0 + ) + methods = [call["method"] for call in resource.calls] + assert methods == ["create", "wait"] + + def test_wait_renders_the_final_job_not_the_initial_one( + self, + invoke, + fine_tunes, + ): + fine_tunes( + create=_job(status="RUNNING", finetuned_model=None), + wait=_job(status="SUCCEEDED", finetuned_model="qwen-plus-ft-done"), + ) + result = invoke( + ["ft", "create", "-m", "qwen-plus", "-t", "f", "--wait"], + ) + assert "SUCCEEDED" in result.stdout + assert "qwen-plus-ft-done" in result.stdout + assert "RUNNING" not in result.stdout + + def test_without_wait_the_sdk_is_not_polled(self, invoke, fine_tunes): + resource = fine_tunes(create=_job(status="RUNNING")) + invoke(["ft", "create", "-m", "qwen-plus", "-t", "f"]) + assert [call["method"] for call in resource.calls] == ["create"] + + def test_the_timeout_is_forwarded(self, invoke, fine_tunes): + resource = fine_tunes(create=_job(), wait=_job()) + invoke( + [ + "ft", + "create", + "-m", + "qwen-plus", + "-t", + "f", + "--wait", + "--timeout", + "30", + ], + ) + wait_call = next(c for c in resource.calls if c["method"] == "wait") + assert wait_call["kwargs"]["timeout"] == 30 + + def test_a_failed_job_exits_nonzero(self, invoke, fine_tunes): + """The 1.x `_wait_for_job` printed the failure and exited 0.""" + fine_tunes( + create=_job(status="RUNNING"), + wait=sdk_errors.InternalServerError("training diverged"), + ) + result = invoke( + ["ft", "create", "-m", "qwen-plus", "-t", "f", "--wait"], + ) + assert result.exit_code == 1 + assert "Error [server]: training diverged" in result.stderr + + +class TestWaitTimeoutIsCheckedBeforeSubmitting: + """``--wait --timeout -1`` used to create the job and then give up on it.""" + + @pytest.mark.parametrize("timeout", ["-1", "0", "nan", "inf"]) + def test_no_job_is_created(self, invoke, fine_tunes, timeout): + resource = fine_tunes(create=_job(), wait=_job()) + result = invoke( + [ + "ft", + "create", + "-m", + "qwen-plus", + "-t", + "f", + "--wait", + "--timeout", + timeout, + ], + ) + assert result.exit_code == 2 + assert resource.calls == [] + assert "--timeout" in result.stderr + + def test_an_unused_timeout_is_not_newly_rejected( + self, + invoke, + fine_tunes, + ): + """``--timeout`` means nothing without ``--wait``.""" + resource = fine_tunes(create=_job()) + result = invoke( + ["ft", "create", "-m", "qwen-plus", "-t", "f", "--timeout", "-1"], + ) + assert result.exit_code == 0 + assert [call["method"] for call in resource.calls] == ["create"] + + @pytest.mark.parametrize("timeout", ["0", "nan"]) + def test_ft_wait_is_rejected_before_polling( + self, + invoke, + fine_tunes, + timeout, + ): + """``nan`` is false against every comparison, so the poll never ends.""" + resource = fine_tunes(wait=_job()) + result = invoke(["ft", "wait", "ft-1", "--timeout", timeout]) + assert result.exit_code == 2 + assert "--timeout" in result.stderr + assert resource.calls == [] + + +class TestWaitCommand: + """``ft wait``: block on a job this process did not create.""" + + def test_it_polls_the_id_and_nothing_else(self, invoke, fine_tunes): + resource = fine_tunes(wait=_job()) + assert invoke(["ft", "wait", "ft-1"]).exit_code == 0 + assert [call["method"] for call in resource.calls] == ["wait"] + assert resource.calls[0]["args"] == ("ft-1",) + + def test_the_timeout_defaults_to_an_hour(self, invoke, fine_tunes): + resource = fine_tunes(wait=_job()) + invoke(["ft", "wait", "ft-1"]) + assert resource.calls[0]["kwargs"] == {"timeout": 3600.0} + + def test_the_timeout_is_forwarded(self, invoke, fine_tunes): + resource = fine_tunes(wait=_job()) + invoke(["ft", "wait", "ft-1", "--timeout", "30"]) + assert resource.calls[0]["kwargs"] == {"timeout": 30.0} + + def test_table_shows_the_settled_job(self, invoke, fine_tunes): + fine_tunes(wait=_job(status="SUCCEEDED")) + result = invoke(["ft", "wait", "ft-1"]) + assert "SUCCEEDED" in result.stdout + + def test_json_output_is_the_whole_job(self, invoke, fine_tunes): + fine_tunes(wait=_job()) + result = invoke(["ft", "wait", "ft-1"], global_args=["-o", "json"]) + payload = json.loads(result.stdout) + assert payload["job_id"] == "ft-202601011234-abc1" + assert payload["status"] == "SUCCEEDED" + + def test_a_failed_job_exits_nonzero(self, invoke, fine_tunes): + """Same rule as `create --wait`: a failure is never exit 0.""" + fine_tunes(wait=sdk_errors.InternalServerError("training diverged")) + result = invoke(["ft", "wait", "ft-1"]) + assert result.exit_code == 1 + assert "Error [server]: training diverged" in result.stderr + + def test_a_missing_job_exits_four(self, invoke, fine_tunes): + fine_tunes(wait=sdk_errors.NotFoundError("job not found")) + assert invoke(["ft", "wait", "ft-nope"]).exit_code == 4 + + def test_the_job_id_is_required(self, invoke, fine_tunes): + resource = fine_tunes(wait=_job()) + assert invoke(["ft", "wait"]).exit_code == 2 + assert resource.calls == [] + + +class TestGet: + def test_table_shows_the_real_created_at(self, invoke, fine_tunes): + """Regression pin: the 1.x `rl get` printed ``creator`` for + ``created_at`` (``agentic_rl.py:618``).""" + fine_tunes( + get=_job(created_at="2026-01-01 12:34:56", creator="user-999"), + ) + result = invoke(["ft", "get", "ft-1"]) + assert "2026-01-01 12:34:56" in result.stdout + assert "user-999" not in result.stdout + + def test_the_job_id_is_forwarded(self, invoke, fine_tunes): + resource = fine_tunes(get=_job()) + invoke(["ft", "get", "ft-abc"]) + assert resource.calls[0]["args"] == ("ft-abc",) + + def test_a_missing_job_exits_four(self, invoke, fine_tunes): + fine_tunes(get=sdk_errors.NotFoundError("job not found")) + result = invoke(["ft", "get", "ft-nope"]) + assert result.exit_code == 4 + assert "Error [notfound]: job not found" in result.stderr + + def test_json_output_is_the_whole_job(self, invoke, fine_tunes): + fine_tunes(get=_job(creator="user-999")) + result = invoke(["ft", "get", "ft-1"], global_args=["-o", "json"]) + payload = json.loads(result.stdout) + assert payload["created_at"] == "2026-01-01 12:34:56" + # Machine output keeps every field; only the table narrows it. + assert payload["creator"] == "user-999" + + +class TestList: + @staticmethod + def _page(jobs, total=None, page_no=1, page_size=10): + return FineTuneList( + page_no=page_no, + page_size=page_size, + total_count=len(jobs) if total is None else total, + jobs=jobs, + ) + + def test_pagination_is_forwarded(self, invoke, fine_tunes): + resource = fine_tunes(list=self._page([])) + invoke(["ft", "list", "--page", "3", "--size", "5"]) + assert resource.calls[0]["kwargs"] == {"page_no": 3, "page_size": 5} + + def test_table_shows_rows_and_a_footer(self, invoke, fine_tunes): + fine_tunes( + list=self._page( + [_job(job_id="ft-a"), _job(job_id="ft-b")], + total=25, + page_no=2, + page_size=10, + ), + ) + result = invoke(["ft", "list"]) + assert "ft-a" in result.stdout and "ft-b" in result.stdout + # The footer is a diagnostic, so it stays off stdout. + assert "page 2/3, total 25" in result.stderr + assert "page 2/3" not in result.stdout + + def test_json_output_is_the_whole_envelope(self, invoke, fine_tunes): + """A caller must be able to page without a second request.""" + fine_tunes(list=self._page([_job(job_id="ft-a")], total=25)) + result = invoke(["ft", "list"], global_args=["-o", "json"]) + payload = json.loads(result.stdout) + assert payload["total_count"] == 25 + assert payload["jobs"][0]["job_id"] == "ft-a" + + +class TestStream: + @staticmethod + def _events(*outputs): + return iter([FineTuneEvent(output=o) for o in outputs]) + + def test_table_prints_text_frames_verbatim(self, invoke, fine_tunes): + fine_tunes(stream_events=self._events("epoch 1", "epoch 2")) + result = invoke(["ft", "stream", "ft-1"]) + assert result.exit_code == 0 + assert result.stdout.splitlines() == ["epoch 1", "epoch 2"] + + def test_table_compresses_dict_frames_to_one_line( + self, + invoke, + fine_tunes, + ): + fine_tunes(stream_events=self._events({"loss": 0.5, "step": 10})) + result = invoke(["ft", "stream", "ft-1"]) + lines = result.stdout.splitlines() + assert len(lines) == 1 + assert json.loads(lines[0]) == {"loss": 0.5, "step": 10} + + def test_json_emits_one_frame_per_line(self, invoke, fine_tunes): + fine_tunes(stream_events=self._events("a", {"loss": 1})) + result = invoke(["ft", "stream", "ft-1"], global_args=["-o", "json"]) + lines = [line for line in result.stdout.splitlines() if line.strip()] + assert len(lines) == 2 + assert [json.loads(line)["output"] for line in lines] == [ + "a", + {"loss": 1}, + ] + + def test_a_server_error_frame_exits_nonzero(self, invoke, fine_tunes): + """No ``except`` in the streaming path, so this cannot exit 0. + + The 1.x tree caught the error, printed it and returned -- the bug + the design doc calls out. + """ + + def events(): + yield FineTuneEvent(output="starting") + raise sdk_errors.InternalServerError("stream broke") + + fine_tunes(stream_events=events()) + result = invoke(["ft", "stream", "ft-1"]) + assert result.exit_code == 1 + assert "Error [server]: stream broke" in result.stderr + assert "starting" in result.stdout + + def test_a_missing_job_exits_four(self, invoke, fine_tunes): + fine_tunes(stream_events=sdk_errors.NotFoundError("job not found")) + result = invoke(["ft", "stream", "ft-nope"]) + assert result.exit_code == 4 + + +class TestLogs: + def test_prints_the_log_text_verbatim(self, invoke, fine_tunes): + """Raw text, not a table: a redirect must capture the trainer's own + output and nothing else.""" + resource = fine_tunes( + logs=JobLogs(job_id="ft-1", content="epoch 1\nepoch 2\n"), + ) + result = invoke(["ft", "logs", "ft-1"]) + assert result.exit_code == 0 + assert result.stdout == "epoch 1\nepoch 2\n\n" + assert resource.calls[0] == { + "method": "logs", + "args": ("ft-1",), + "kwargs": {"offset": 1, "lines": 1000}, + } + + def test_the_window_is_adjustable(self, invoke, fine_tunes): + resource = fine_tunes(logs=JobLogs(job_id="ft-1", content="tail")) + invoke(["ft", "logs", "ft-1", "--offset", "40", "--lines", "20"]) + assert resource.calls[0]["kwargs"] == {"offset": 40, "lines": 20} + + def test_json_output_keeps_the_envelope(self, invoke, fine_tunes): + fine_tunes( + logs=JobLogs( + job_id="ft-1", + content="epoch 1\n", + request_id="req-1", + ), + ) + result = invoke(["ft", "logs", "ft-1"], global_args=["-o", "json"]) + doc = json.loads(result.stdout) + assert doc["job_id"] == "ft-1" + assert doc["content"] == "epoch 1\n" + assert doc["request_id"] == "req-1" + + def test_a_line_array_is_joined_by_the_sdk(self, invoke, fine_tunes): + """The service may send lines instead of one string.""" + fine_tunes(logs=JobLogs(job_id="ft-1", content=["epoch 1", "epoch 2"])) + result = invoke(["ft", "logs", "ft-1"]) + assert result.exit_code == 0 + assert "epoch 1\nepoch 2" in result.stdout + + def test_an_empty_log_is_not_an_error(self, invoke, fine_tunes): + fine_tunes(logs=JobLogs(job_id="ft-1", content="")) + result = invoke(["ft", "logs", "ft-1"]) + assert result.exit_code == 0 + assert result.stdout == "\n" + + def test_the_job_id_is_required(self, invoke, fine_tunes): + fine_tunes(logs=JobLogs(job_id="ft-1", content="x")) + assert invoke(["ft", "logs"]).exit_code == 2 + + def test_a_missing_job_exits_four(self, invoke, fine_tunes): + fine_tunes(logs=sdk_errors.NotFoundError("job not found")) + result = invoke(["ft", "logs", "ft-nope"]) + assert result.exit_code == 4 + + +class TestDestructiveCommands: + @pytest.mark.parametrize("command", ["cancel", "delete"]) + def test_yes_skips_the_prompt(self, invoke, fine_tunes, command): + resource = fine_tunes(**{command: JobStatus(status="CANCELED")}) + result = invoke(["ft", command, "ft-1", "--yes"]) + assert result.exit_code == 0 + assert [call["method"] for call in resource.calls] == [command] + + @pytest.mark.parametrize("command", ["cancel", "delete"]) + def test_short_flag_skips_the_prompt(self, invoke, fine_tunes, command): + fine_tunes(**{command: JobStatus(status="CANCELED")}) + assert invoke(["ft", command, "ft-1", "-y"]).exit_code == 0 + + @pytest.mark.parametrize("command", ["cancel", "delete"]) + def test_no_tty_without_yes_is_refused(self, invoke, fine_tunes, command): + """A cron job that forgot ``--yes`` must fail loudly, not act.""" + resource = fine_tunes(**{command: JobStatus(status="CANCELED")}) + result = invoke(["ft", command, "ft-1"]) + assert result.exit_code == 2 + assert "--yes" in result.stderr + assert resource.calls == [] + + @pytest.mark.parametrize( + "command,answer", + [("cancel", True), ("delete", True)], + ) + def test_an_interactive_yes_proceeds( + self, + invoke, + fine_tunes, + monkeypatch, + command, + answer, + ): + _as_terminal_tty(monkeypatch, answer) + resource = fine_tunes(**{command: JobStatus(status="CANCELED")}) + assert invoke(["ft", command, "ft-1"]).exit_code == 0 + assert [call["method"] for call in resource.calls] == [command] + + @pytest.mark.parametrize("command", ["cancel", "delete"]) + def test_an_interactive_no_aborts( + self, + invoke, + fine_tunes, + monkeypatch, + command, + ): + _as_terminal_tty(monkeypatch, False) + resource = fine_tunes(**{command: JobStatus(status="CANCELED")}) + result = invoke(["ft", command, "ft-1"]) + assert result.exit_code == 2 + assert "aborted by user" in result.stderr + assert resource.calls == [] + + def test_the_result_is_rendered(self, invoke, fine_tunes): + fine_tunes(delete=JobStatus(status="DELETED")) + result = invoke( + ["ft", "delete", "ft-1", "--yes"], + global_args=["-o", "json"], + ) + assert json.loads(result.stdout)["status"] == "DELETED" + + def test_an_interactive_prompt_leaves_json_stdout_parseable( + self, + invoke, + fine_tunes, + monkeypatch, + ): + """The real click prompt, on a terminal, with ``-o json``. + + The prompt used to be written to stdout ahead of the JSON, so a + machine consumer got neither. + """ + monkeypatch.setattr( + "dashscope.cli.confirm.stdin_is_tty", + lambda: True, + ) + fine_tunes(delete=JobStatus(status="DELETED")) + result = invoke( + ["ft", "delete", "ft-1"], + global_args=["-o", "json"], + input="y\n", + ) + assert result.exit_code == 0 + assert "delete fine-tune job ft-1?" not in result.stdout + assert "delete fine-tune job ft-1?" in result.stderr + # CliRunner echoes the keystroke into stdout; a real terminal + # echoes it on the tty instead, so only the JSON is captured. + payload = result.stdout[result.stdout.index("{") :] # noqa: E203 + assert json.loads(payload)["status"] == "DELETED" + + def test_a_missing_job_exits_four(self, invoke, fine_tunes): + fine_tunes(delete=sdk_errors.NotFoundError("job not found")) + result = invoke(["ft", "delete", "ft-1", "--yes"]) + assert result.exit_code == 4 + + +class TestGroupShape: + def test_the_hidden_alias_runs_the_same_commands(self, invoke, fine_tunes): + resource = fine_tunes(get=_job()) + assert invoke(["fine-tunes", "get", "ft-1"]).exit_code == 0 + assert resource.calls[0]["method"] == "get" + + def test_the_alias_is_hidden_from_the_root_help(self, app, runner): + text = runner.invoke(app, ["--help"]).stdout + assert "ft" in text + assert "fine-tunes" not in text + + def test_bare_ft_shows_help(self, app, runner): + assert "Usage" in runner.invoke(app, ["ft"]).stdout + + def test_help_does_not_leak_a_backslash(self, app, runner): + assert "\\" not in runner.invoke(app, ["ft", "--help"]).stdout + + def test_the_router_sends_both_names_to_the_new_tree(self): + from dashscope.cli.main import NEW, classify + + assert classify(["ft", "list"]) == NEW + assert classify(["fine-tunes", "list"]) == NEW diff --git a/tests/cli/test_images_cmd.py b/tests/cli/test_images_cmd.py new file mode 100644 index 00000000..982aa607 --- /dev/null +++ b/tests/cli/test_images_cmd.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope images`` -- generate and the jobs subgroup.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope import errors as sdk_errors +from dashscope.cli.app import build_app +from dashscope.resources._jobs import Job, JobList + + +def _job(**overrides: Any) -> Job: + data: Dict[str, Any] = { + "task_id": "task-abc123", + "status": "SUCCEEDED", + "submitted_at": "2026-01-01 12:00:00", + "output": {"results": [{"url": "https://example.com/cat.png"}]}, + } + data.update(overrides) + return Job(**data) + + +class _ImageJobs: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def create(self, **kwargs): + return self._record("create", **kwargs) + + def fetch(self, task_id, **kwargs): + return self._record("fetch", task_id, **kwargs) + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def cancel(self, task_id, **kwargs): + return self._record("cancel", task_id, **kwargs) + + def wait(self, job, **kwargs): + self._record("wait", job, **kwargs) + return self._results.get("wait", job) + + +class _Images: + """Stand-in for ``client.images`` recording every call.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + self.jobs = _ImageJobs(**results.get("jobs", {})) + + def generate(self, **kwargs): + self.calls.append({"method": "generate", "kwargs": kwargs}) + result = self._results.get("generate") + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def images(make_client): + def install(**results: Any) -> _Images: + resource = _Images(**results) + make_client(images=resource) + return resource + + return install + + +class TestGenerate: + def test_generate_prints_one_url_per_line(self, invoke, images): + images( + generate=_job( + output={ + "results": [ + {"url": "https://example.com/a.png"}, + {"url": "https://example.com/b.png"}, + ], + }, + ), + ) + result = invoke( + ["images", "generate", "-m", "wan2.2-t2i-flash", "-p", "a cat"], + ) + assert result.exit_code == 0 + assert result.stdout.split() == [ + "https://example.com/a.png", + "https://example.com/b.png", + ] + + def test_generate_passes_the_payload(self, invoke, images): + resource = images(generate=_job()) + invoke( + [ + "images", + "generate", + "-m", + "wan2.2-t2i-flash", + "-p", + "a cat", + "--negative-prompt", + "blurry", + "-n", + "2", + "--size", + "1024x1024", + "--seed", + "7", + "--no-watermark", + ], + ) + kwargs = resource.calls[0]["kwargs"] + assert kwargs["model"] == "wan2.2-t2i-flash" + assert kwargs["prompt"] == "a cat" + assert kwargs["negative_prompt"] == "blurry" + assert kwargs["n"] == 2 + assert kwargs["size"] == "1024x1024" + assert kwargs["seed"] == 7 + assert kwargs["watermark"] is False + + def test_generate_collects_repeatable_reference_images( + self, + invoke, + images, + ): + resource = images(generate=_job()) + invoke( + [ + "images", + "generate", + "-m", + "wan2.6-image", + "-p", + "a cat", + "--image-url", + "https://example.com/a.png", + "--image-url", + "b.png", + ], + ) + assert resource.calls[0]["kwargs"]["image_urls"] == [ + "https://example.com/a.png", + "b.png", + ] + + def test_generate_reads_a_prompt_from_a_file( + self, + invoke, + images, + tmp_path, + ): + payload = tmp_path / "prompt.txt" + payload.write_text("a very long prompt\n", encoding="utf-8") + resource = images(generate=_job()) + invoke( + [ + "images", + "generate", + "-m", + "wan2.2-t2i-flash", + "-p", + f"@{payload}", + ], + ) + assert resource.calls[0]["kwargs"]["prompt"] == "a very long prompt\n" + + def test_generate_json_output_keeps_the_whole_job(self, invoke, images): + images(generate=_job()) + result = invoke( + ["images", "generate", "-m", "wan2.2-t2i-flash", "-p", "a cat"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["task_id"] == "task-abc123" + assert ( + doc["output"]["results"][0]["url"] == "https://example.com/cat.png" + ) + + def test_generate_requires_a_prompt(self, invoke, images): + images(generate=_job()) + result = invoke(["images", "generate", "-m", "wan2.2-t2i-flash"]) + assert result.exit_code == 2 + + def test_generate_refuses_an_empty_prompt(self, invoke, images): + images(generate=_job()) + result = invoke( + ["images", "generate", "-m", "wan2.2-t2i-flash", "-p", " "], + ) + assert result.exit_code == 2 + assert "--prompt" in result.stderr + + +class TestJobs: + def test_jobs_create_submits_without_waiting(self, invoke, images): + resource = images( + jobs={"create": _job(status="PENDING", output=None)}, + ) + result = invoke( + [ + "images", + "jobs", + "create", + "-m", + "wan2.2-t2i-flash", + "-p", + "a cat", + ], + ) + assert result.exit_code == 0 + assert "task-abc123" in result.stdout + assert [c["method"] for c in resource.jobs.calls] == ["create"] + + def test_jobs_create_wait_blocks(self, invoke, images): + resource = images( + jobs={ + "create": _job(status="PENDING", output=None), + "wait": _job(), + }, + ) + invoke( + [ + "images", + "jobs", + "create", + "-m", + "wan2.2-t2i-flash", + "-p", + "a cat", + "--wait", + "--timeout", + "30", + ], + ) + methods = [c["method"] for c in resource.jobs.calls] + assert methods == ["create", "wait"] + assert resource.jobs.calls[1]["kwargs"]["timeout"] == 30.0 + + def test_jobs_get(self, invoke, images): + resource = images(jobs={"fetch": _job()}) + invoke(["images", "jobs", "get", "task-abc123"]) + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_list(self, invoke, images): + images( + jobs={ + "list": JobList( + tasks=[_job(), _job(task_id="task-def456")], + page_no=1, + page_size=10, + total=2, + ), + }, + ) + result = invoke(["images", "jobs", "list"]) + assert result.exit_code == 0 + assert "task-abc123" in result.stdout + assert "task-def456" in result.stdout + + def test_jobs_list_pagination_and_filters(self, invoke, images): + resource = images(jobs={"list": JobList(tasks=[], total=0)}) + invoke( + [ + "images", + "jobs", + "list", + "--page", + "2", + "--size", + "5", + "--status", + "SUCCEEDED", + "--model", + "wan2.2-t2i-flash", + "--api-key-id", + "key-1", + "--region", + "cn-beijing", + ], + ) + assert resource.jobs.calls[0]["kwargs"] == { + "page_no": 2, + "page_size": 5, + "status": "SUCCEEDED", + "model_name": "wan2.2-t2i-flash", + "api_key_id": "key-1", + "region": "cn-beijing", + "start_time": None, + "end_time": None, + } + + def test_jobs_list_json_output_is_the_envelope(self, invoke, images): + images(jobs={"list": JobList(tasks=[_job()], total=1)}) + result = invoke(["images", "jobs", "list"], global_args=["-o", "json"]) + doc = json.loads(result.stdout) + assert doc["total"] == 1 + assert len(doc["tasks"]) == 1 + + def test_jobs_cancel_with_yes(self, invoke, images): + resource = images(jobs={"cancel": _job(status="CANCELED")}) + result = invoke(["images", "jobs", "cancel", "task-abc123", "--yes"]) + assert result.exit_code == 0 + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_cancel_refuses_without_a_tty(self, invoke, images): + resource = images(jobs={"cancel": _job(status="CANCELED")}) + result = invoke(["images", "jobs", "cancel", "task-abc123"]) + assert result.exit_code == 2 + assert "--yes" in result.stderr + assert resource.jobs.calls == [] + + def test_jobs_wait_polls_the_id_and_nothing_else(self, invoke, images): + resource = images(jobs={"wait": _job()}) + assert invoke(["images", "jobs", "wait", "task-abc123"]).exit_code == 0 + assert [c["method"] for c in resource.jobs.calls] == ["wait"] + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_wait_defaults_to_an_hour(self, invoke, images): + resource = images(jobs={"wait": _job()}) + invoke(["images", "jobs", "wait", "task-abc123"]) + assert resource.jobs.calls[0]["kwargs"] == {"timeout": 3600.0} + + def test_jobs_wait_prints_the_urls_like_generate_does( + self, + invoke, + images, + ): + """The point of waiting separately is usually to collect the URL.""" + images(jobs={"wait": _job()}) + result = invoke(["images", "jobs", "wait", "task-abc123"]) + assert result.stdout.strip() == "https://example.com/cat.png" + + def test_jobs_wait_json_output_is_the_whole_job(self, invoke, images): + images(jobs={"wait": _job()}) + result = invoke( + ["images", "jobs", "wait", "task-abc123"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["task_id"] == "task-abc123" + assert doc["status"] == "SUCCEEDED" + + def test_jobs_wait_failure_exits_nonzero(self, invoke, images): + images( + jobs={"wait": sdk_errors.InternalServerError("task exploded")}, + ) + result = invoke(["images", "jobs", "wait", "task-abc123"]) + assert result.exit_code == 1 + assert "Error [server]: task exploded" in result.stderr + + def test_jobs_wait_requires_the_id(self, invoke, images): + resource = images(jobs={"wait": _job()}) + assert invoke(["images", "jobs", "wait"]).exit_code == 2 + assert resource.jobs.calls == [] + + @pytest.mark.parametrize("timeout", ["0", "nan"]) + def test_jobs_wait_rejects_a_timeout_it_could_not_honour( + self, + invoke, + images, + timeout, + ): + """``nan`` is false against every comparison, so the poll never ends.""" + resource = images(jobs={"wait": _job()}) + result = invoke( + ["images", "jobs", "wait", "task-abc123", "--timeout", timeout], + ) + assert result.exit_code == 2 + assert "--timeout" in result.stderr + assert resource.jobs.calls == [] diff --git a/tests/cli/test_input.py b/tests/cli/test_input.py new file mode 100644 index 00000000..745ec17a --- /dev/null +++ b/tests/cli/test_input.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``@file`` / ``-`` / literal input parsing.""" + +import io +import json + +import pytest + +from dashscope.cli.errors import CliError +from dashscope.cli.input import ( + messages_from_prompt, + parse_json_arg, + parse_json_object_arg, + parse_messages_arg, + read_prompt_or_messages, + read_text_arg, + stdin_is_piped, +) + + +@pytest.fixture +def pipe_stdin(monkeypatch): + def install(text: str) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(text)) + + return install + + +@pytest.fixture +def tty_stdin(monkeypatch): + class Tty(io.StringIO): + def isatty(self): + return True + + monkeypatch.setattr("sys.stdin", Tty("")) + + +class TestReadTextArg: + def test_none_passes_through(self): + assert read_text_arg(None, option_name="--prompt") is None + + def test_literal(self): + assert read_text_arg("hello", option_name="--prompt") == "hello" + + def test_literal_starting_with_at_needs_a_path(self): + with pytest.raises(CliError) as info: + read_text_arg("@", option_name="--messages") + assert info.value.category == "usage" + assert "must be followed by a path" in info.value.message + + def test_at_file(self, tmp_path): + path = tmp_path / "payload.txt" + path.write_text("from file", encoding="utf-8") + assert read_text_arg(f"@{path}", option_name="--prompt") == "from file" + + def test_at_file_expands_home(self, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "p.txt").write_text("home file", encoding="utf-8") + assert read_text_arg("@~/p.txt", option_name="--prompt") == "home file" + + def test_at_missing_file_is_a_usage_error(self, tmp_path): + with pytest.raises(CliError) as info: + read_text_arg(f"@{tmp_path / 'nope.txt'}", option_name="--prompt") + assert info.value.category == "usage" + assert "file not found" in info.value.message + + def test_stdin_token(self, pipe_stdin): + pipe_stdin("piped body") + assert read_text_arg("-", option_name="--prompt") == "piped body" + + def test_empty_stdin_is_a_usage_error(self, pipe_stdin): + pipe_stdin(" \n") + with pytest.raises(CliError) as info: + read_text_arg("-", option_name="--prompt") + assert "no input received on stdin" in info.value.message + + def test_closed_stdin_is_not_a_crash(self, monkeypatch): + class Broken: + def isatty(self): + raise ValueError("I/O operation on closed file") + + def read(self): + raise ValueError("I/O operation on closed file") + + monkeypatch.setattr("sys.stdin", Broken()) + assert stdin_is_piped() is False + + +class TestParseJsonArg: + def test_object(self): + assert parse_json_arg('{"a": 1}', option_name="--tools") == {"a": 1} + + def test_none(self): + assert parse_json_arg(None, option_name="--tools") is None + + def test_from_file(self, tmp_path): + path = tmp_path / "tools.json" + path.write_text('{"type": "function"}', encoding="utf-8") + assert parse_json_arg(f"@{path}", option_name="--tools") == { + "type": "function", + } + + def test_error_names_the_source(self): + with pytest.raises(CliError) as info: + parse_json_arg("{not json", option_name="--tools") + assert "--tools" in info.value.message + assert "the given value" in info.value.message + assert "line 1 column 2" in info.value.message + + def test_object_variant_rejects_arrays(self): + with pytest.raises(CliError) as info: + parse_json_object_arg("[1]", option_name="--hyper-parameters") + assert "expected a JSON object" in info.value.message + + +class TestParseMessagesArg: + def test_json_array(self): + payload = json.dumps([{"role": "user", "content": "hi"}]) + assert parse_messages_arg(payload) == [ + {"role": "user", "content": "hi"}, + ] + + def test_openai_envelope(self): + payload = json.dumps( + {"model": "m", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert parse_messages_arg(payload) == [ + {"role": "user", "content": "hi"}, + ] + + def test_jsonl(self): + payload = ( + '{"role": "system", "content": "be brief"}\n' + '{"role": "user", "content": "hi"}\n' + "\n" + '{"role": "assistant", "content": "ok"}\n' + ) + assert [ + message["role"] for message in parse_messages_arg(payload) + ] == [ + "system", + "user", + "assistant", + ] + + def test_jsonl_error_names_the_line(self): + with pytest.raises(CliError) as info: + parse_messages_arg('{"role": "user"}\n{oops}\n') + assert "line 2" in info.value.message + + def test_scalar_payload_rejected(self): + with pytest.raises(CliError) as info: + parse_messages_arg('"just a string"') + assert "expected a message object" in info.value.message + + def test_array_entry_must_be_an_object(self): + with pytest.raises(CliError) as info: + parse_messages_arg("[1, 2]") + assert "entry 0 is not a JSON object" in info.value.message + + def test_single_object(self): + assert parse_messages_arg('{"role": "user", "content": "hi"}') == [ + {"role": "user", "content": "hi"}, + ] + + def test_none_yields_empty_list(self): + assert parse_messages_arg(None) == [] + + def test_empty_payload_is_a_usage_error(self, pipe_stdin): + pipe_stdin("") + with pytest.raises(CliError): + parse_messages_arg("-") + + +class TestMessagesFromPrompt: + def test_plain(self): + assert messages_from_prompt("hi") == [ + {"role": "user", "content": "hi"}, + ] + + def test_system_goes_first(self): + assert messages_from_prompt("hi", "be brief") == [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "hi"}, + ] + + +class TestReadPromptOrMessages: + def test_messages_wins_over_prompt(self): + result = read_prompt_or_messages( + prompt="ignored", + messages='[{"role": "user", "content": "used"}]', + ) + assert result == [{"role": "user", "content": "used"}] + + def test_prompt_with_system(self): + assert read_prompt_or_messages( + prompt="hi", + messages=None, + system="s", + ) == [ + {"role": "system", "content": "s"}, + {"role": "user", "content": "hi"}, + ] + + def test_piped_json_becomes_messages(self, pipe_stdin): + pipe_stdin('[{"role": "user", "content": "piped"}]') + assert read_prompt_or_messages(prompt=None, messages=None) == [ + {"role": "user", "content": "piped"}, + ] + + def test_piped_jsonl_becomes_messages(self, pipe_stdin): + pipe_stdin( + '{"role": "user", "content": "a"}\n' + '{"role": "user", "content": "b"}\n', + ) + result = read_prompt_or_messages(prompt=None, messages=None) + assert [message["content"] for message in result] == ["a", "b"] + + def test_piped_text_becomes_a_prompt(self, pipe_stdin): + pipe_stdin("just some text\n") + assert read_prompt_or_messages(prompt=None, messages=None) == [ + {"role": "user", "content": "just some text"}, + ] + + def test_piped_text_that_looks_like_json_still_works(self, pipe_stdin): + # A prompt that happens to open with a brace must not become a + # hard failure; it falls back to being treated as text. + pipe_stdin("{not really json") + assert read_prompt_or_messages(prompt=None, messages=None) == [ + {"role": "user", "content": "{not really json"}, + ] + + def test_no_input_is_a_usage_error(self, tty_stdin): + with pytest.raises(CliError) as info: + read_prompt_or_messages(prompt=None, messages=None) + assert info.value.category == "usage" + assert "--prompt" in info.value.message + + def test_explicit_stdin_reads_from_a_terminal(self, monkeypatch): + class Tty(io.StringIO): + def isatty(self): + return True + + monkeypatch.setattr("sys.stdin", Tty("terminal input")) + assert read_prompt_or_messages( + prompt=None, + messages=None, + explicit_stdin=True, + ) == [{"role": "user", "content": "terminal input"}] + + def test_stdin_can_be_disabled(self, pipe_stdin): + pipe_stdin("ignored") + with pytest.raises(CliError): + read_prompt_or_messages( + prompt=None, + messages=None, + allow_stdin_prompt=False, + ) diff --git a/tests/cli/test_models_cmd.py b/tests/cli/test_models_cmd.py new file mode 100644 index 00000000..b72bddfb --- /dev/null +++ b/tests/cli/test_models_cmd.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope models`` -- list and get.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.models import Model, ModelList + + +def _model(**overrides: Any) -> Model: + data: Dict[str, Any] = { + "model": "qwen-plus", + "source": "dashscope", + "provider": "alibaba", + "open_source": False, + } + data.update(overrides) + return Model(**data) + + +class _Models: + """Stand-in for ``client.models`` recording every call.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def get(self, model, **kwargs): + return self._record("get", model, **kwargs) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def models(make_client): + def install(**results: Any) -> _Models: + resource = _Models(**results) + make_client(models=resource) + return resource + + return install + + +class TestList: + def test_list_renders_rows(self, invoke, models): + models( + list=ModelList( + models=[_model(), _model(model="qwen-max")], + page_no=1, + page_size=10, + total_count=2, + ), + ) + result = invoke(["models", "list"]) + assert result.exit_code == 0 + assert "qwen-plus" in result.stdout + assert "qwen-max" in result.stdout + + def test_list_pagination_flags(self, invoke, models): + resource = models( + list=ModelList(models=[], page_no=2, page_size=5, total_count=0), + ) + invoke(["models", "list", "--page", "2", "--size", "5"]) + assert resource.calls[0]["kwargs"]["page_no"] == 2 + assert resource.calls[0]["kwargs"]["page_size"] == 5 + + def test_list_provider_filter(self, invoke, models): + resource = models( + list=ModelList(models=[], page_no=1, page_size=10, total_count=0), + ) + invoke(["models", "list", "--provider", "alibaba"]) + assert resource.calls[0]["kwargs"]["provider"] == "alibaba" + + def test_list_json_output(self, invoke, models): + models( + list=ModelList( + models=[_model()], + page_no=1, + page_size=10, + total_count=1, + ), + ) + result = invoke(["models", "list"], global_args=["-o", "json"]) + assert result.exit_code == 0 + import json + + doc = json.loads(result.stdout) + assert len(doc["models"]) == 1 + assert doc["models"][0]["model"] == "qwen-plus" + + +class TestGet: + def test_get_by_name(self, invoke, models): + resource = models(get=_model()) + result = invoke(["models", "get", "qwen-plus"]) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("qwen-plus",) + + def test_get_json_output(self, invoke, models): + models(get=_model()) + result = invoke( + ["models", "get", "qwen-plus"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + import json + + doc = json.loads(result.stdout) + assert doc["model"] == "qwen-plus" + + def test_get_not_found_exits_4(self, invoke, models): + from dashscope import errors as sdk_errors + + models( + get=sdk_errors.NotFoundError( + "Model 'no-such' not found", + request_id="req-1", + ), + ) + result = invoke(["models", "get", "no-such"]) + assert result.exit_code == 4 diff --git a/tests/cli/test_multimodal_cmd.py b/tests/cli/test_multimodal_cmd.py new file mode 100644 index 00000000..e0d13a41 --- /dev/null +++ b/tests/cli/test_multimodal_cmd.py @@ -0,0 +1,565 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope multimodal`` -- chat create and embeddings create.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace +from typing import Any, Dict, List, Optional + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.chat.completions import ChatStream +from dashscope.resources.embeddings import Embedding, EmbeddingResponse +from dashscope.types.common import Usage +from dashscope.types.completions import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessage, + Choice, + Delta, + StreamChoice, +) + + +def _completion( + content: str, + *, + usage: Optional[Dict[str, int]] = None, +) -> ChatCompletion: + return ChatCompletion( + model="qwen-vl-max", + choices=[ + Choice( + message=ChatCompletionMessage( + role="assistant", + content=content, + ), + finish_reason="stop", + ), + ], + usage=Usage(**usage) if usage else None, + ) + + +def _chunk(content: str) -> ChatCompletionChunk: + return ChatCompletionChunk( + choices=[StreamChoice(delta=Delta(role="assistant", content=content))], + ) + + +class _Inner: + def __init__(self, chunks: List[ChatCompletionChunk]) -> None: + self._chunks = chunks + self.headers: Dict[str, str] = {} + self.closed = False + + def __iter__(self): + return iter(self._chunks) + + def close(self) -> None: + self.closed = True + + +class _Chat: + """Stand-in for ``client.multimodal.chat``.""" + + def __init__(self, result_fn) -> None: + self.calls: List[Dict[str, Any]] = [] + self._result_fn = result_fn + + def create(self, **kwargs): + self.calls.append(kwargs) + return self._result_fn(kwargs) + + +class _Embeddings: + """Stand-in for ``client.multimodal.embeddings``.""" + + def __init__(self, response: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._response = response + + def create(self, **kwargs): + self.calls.append(kwargs) + if isinstance(self._response, Exception): + raise self._response + return self._response + + +def _response(count: int = 2, dims: int = 3) -> EmbeddingResponse: + return EmbeddingResponse( + embeddings=[ + Embedding(index=index, embedding=[0.1] * dims) + for index in range(count) + ], + usage=Usage(input_tokens=10, total_tokens=10), + ) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def multimodal(make_client): + """Install a fake ``client.multimodal``; returns the two recorders.""" + + def install(chat_fn=lambda kwargs: _completion("hello"), embeddings=None): + chat = _Chat(chat_fn) + emb = _Embeddings( + embeddings if embeddings is not None else _response(), + ) + make_client(multimodal=SimpleNamespace(chat=chat, embeddings=emb)) + return SimpleNamespace(chat=chat, embeddings=emb) + + return install + + +class TestChatCreate: + def test_prints_the_answer(self, invoke, multimodal): + resource = multimodal() + result = invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "what is this?", + ], + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "hello" + assert resource.chat.calls[0]["model"] == "qwen-vl-max" + + def test_falls_back_to_the_configured_default_model( + self, + invoke, + multimodal, + clean_env, + ): + from dashscope.cli import config_store + + config_store.set_value("default_model", "qwen-vl-plus") + resource = multimodal() + invoke(["multimodal", "chat", "create", "--prompt", "hi"]) + assert resource.chat.calls[0]["model"] == "qwen-vl-plus" + + def test_no_model_anywhere_is_a_usage_error(self, invoke, multimodal): + multimodal() + result = invoke(["multimodal", "chat", "create", "--prompt", "hi"]) + assert result.exit_code == 2 + assert "default_model" in result.stderr + + def test_media_parts_are_passed_through_untouched( + self, + invoke, + multimodal, + ): + resource = multimodal() + invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--messages", + '[{"role": "user", "content": ' + '[{"image": "cat.png"}, {"text": "what is this?"}]}]', + ], + ) + assert resource.chat.calls[0]["messages"] == [ + { + "role": "user", + "content": [ + {"image": "cat.png"}, + {"text": "what is this?"}, + ], + }, + ] + + def test_prompt_and_system_build_a_conversation(self, invoke, multimodal): + resource = multimodal() + invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "hi", + "--system", + "be brief", + ], + ) + assert resource.chat.calls[0]["messages"] == [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "hi"}, + ] + + def test_sampling_flags_reach_the_sdk(self, invoke, multimodal): + resource = multimodal() + invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "hi", + "--temperature", + "0.2", + "--top-p", + "0.9", + "--top-k", + "5", + "--max-tokens", + "64", + "--stop", + "END", + "--seed", + "7", + "--enable-thinking", + "--no-enable-search", + ], + ) + kwargs = resource.chat.calls[0] + assert kwargs["temperature"] == 0.2 + assert kwargs["top_p"] == 0.9 + assert kwargs["top_k"] == 5 + assert kwargs["max_tokens"] == 64 + assert kwargs["stop"] == ["END"] + assert kwargs["seed"] == 7 + assert kwargs["enable_thinking"] is True + assert kwargs["enable_search"] is False + + def test_tools_must_be_an_array(self, invoke, multimodal): + multimodal() + result = invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "hi", + "--tools", + '{"type": "function"}', + ], + ) + assert result.exit_code == 2 + assert "--tools" in result.stderr + + def test_usage_goes_to_stderr(self, invoke, multimodal): + multimodal( + chat_fn=lambda kwargs: _completion( + "hello", + usage={"input_tokens": 3, "output_tokens": 4}, + ), + ) + result = invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "hi", + ], + ) + assert result.stdout.strip() == "hello" + assert "input_tokens" in result.stderr + + def test_json_output_is_the_whole_completion(self, invoke, multimodal): + multimodal() + result = invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "hi", + ], + global_args=["-o", "json"], + ) + assert json.loads(result.stdout)["choices"][0]["message"][ + "content" + ] == ("hello") + + def test_stream_writes_incremental_text(self, invoke, multimodal): + def build(kwargs): + build.inner = _Inner([_chunk(c) for c in ("Hel", "lo")]) + return ChatStream(build.inner) + + build.inner = None + multimodal(chat_fn=build) + result = invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "hi", + "--stream", + ], + ) + assert result.exit_code == 0 + assert result.stdout == "Hello\n" + assert build.inner.closed is True + + def test_stream_json_is_one_object_per_line(self, invoke, multimodal): + multimodal( + chat_fn=lambda kwargs: ChatStream( + _Inner([_chunk("Hel"), _chunk("lo")]), + ), + ) + result = invoke( + [ + "multimodal", + "chat", + "create", + "-m", + "qwen-vl-max", + "--prompt", + "hi", + "--stream", + ], + global_args=["-o", "json"], + ) + lines = [json.loads(line) for line in result.stdout.splitlines()] + assert [line["choices"][0]["delta"]["content"] for line in lines] == [ + "Hel", + "lo", + ] + + +class TestEmbeddingsCreate: + def test_text_flag_builds_one_item(self, invoke, multimodal): + resource = multimodal() + result = invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--text", + "a cat", + ], + ) + assert result.exit_code == 0 + assert resource.embeddings.calls[0]["input"] == [{"text": "a cat"}] + + def test_repeated_flags_build_one_item_each(self, invoke, multimodal): + resource = multimodal() + invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--text", + "a cat", + "--text", + "a dog", + "--image", + "cat.png", + ], + ) + assert resource.embeddings.calls[0]["input"] == [ + {"text": "a cat"}, + {"text": "a dog"}, + {"image": "cat.png"}, + ] + + def test_text_accepts_an_at_file(self, invoke, multimodal, tmp_path): + payload = tmp_path / "text.txt" + payload.write_text("a cat\n", encoding="utf-8") + resource = multimodal() + invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--text", + f"@{payload}", + ], + ) + assert resource.embeddings.calls[0]["input"] == [{"text": "a cat\n"}] + + def test_input_json_wins_over_the_flags(self, invoke, multimodal): + resource = multimodal() + invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--text", + "ignored", + "--input", + '[{"image": "a.png", "factor": 2}]', + ], + ) + assert resource.embeddings.calls[0]["input"] == [ + {"image": "a.png", "factor": 2}, + ] + + def test_input_accepts_a_single_object(self, invoke, multimodal): + resource = multimodal() + invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--input", + '{"video": "clip.mp4"}', + ], + ) + assert resource.embeddings.calls[0]["input"] == [{"video": "clip.mp4"}] + + def test_input_rejects_a_scalar(self, invoke, multimodal): + multimodal() + result = invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--input", + '"a cat"', + ], + ) + assert result.exit_code == 2 + assert "--input" in result.stderr + + def test_input_rejects_a_non_object_entry(self, invoke, multimodal): + multimodal() + result = invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--input", + '["a cat"]', + ], + ) + assert result.exit_code == 2 + assert "entry 0" in result.stderr + + def test_nothing_to_embed_is_a_usage_error(self, invoke, multimodal): + resource = multimodal() + result = invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + ], + ) + assert result.exit_code == 2 + assert "--text" in result.stderr + assert resource.embeddings.calls == [] + + def test_parameters_reach_the_sdk(self, invoke, multimodal): + resource = multimodal() + invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--text", + "a cat", + "--dimension", + "512", + "--fps", + "2.5", + "--res-level", + "1", + "--max-video-frames", + "80", + "--enable-fusion", + "--instruct", + "retrieve", + "--parameters", + '{"output_type": "dense"}', + ], + ) + kwargs = resource.embeddings.calls[0] + assert kwargs["dimension"] == 512 + assert kwargs["fps"] == 2.5 + assert kwargs["res_level"] == 1 + assert kwargs["max_video_frames"] == 80 + assert kwargs["enable_fusion"] is True + assert kwargs["instruct"] == "retrieve" + assert kwargs["parameters"] == {"output_type": "dense"} + + def test_table_output_summarises(self, invoke, multimodal): + multimodal() + result = invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--text", + "a cat", + "--text", + "a dog", + ], + ) + assert result.stdout.strip() == "2 embedding(s), 3 dimensions" + assert "'input_tokens': 10" in result.stderr + + def test_json_output_is_the_whole_response(self, invoke, multimodal): + multimodal() + result = invoke( + [ + "multimodal", + "embeddings", + "create", + "-m", + "multimodal-embedding-v1", + "--text", + "a cat", + ], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert len(doc["embeddings"]) == 2 + assert doc["usage"]["input_tokens"] == 10 diff --git a/tests/cli/test_oss_cmd.py b/tests/cli/test_oss_cmd.py new file mode 100644 index 00000000..348ac6c7 --- /dev/null +++ b/tests/cli/test_oss_cmd.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope oss upload`` -- thin wrapper over the v2 upload helper.""" + +from __future__ import annotations + +import json + +import pytest + +from dashscope.cli.app import build_app + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def upload(monkeypatch): + calls = [] + + def fake(client, model, file_path): + calls.append((client, model, file_path)) + return "oss://bucket/input.png", {"certificate": "value"} + + monkeypatch.setattr( + "dashscope.resources._oss_upload.upload_local_file", + fake, + ) + return calls + + +def test_upload_calls_the_v2_helper(invoke, make_client, upload, tmp_path): + client = make_client() + path = tmp_path / "input.png" + path.write_bytes(b"png") + + result = invoke(["oss", "upload", "-f", str(path), "-m", "qwen-vl-max"]) + + assert result.exit_code == 0 + assert upload == [(client, "qwen-vl-max", str(path))] + assert result.stdout == "oss://bucket/input.png\n" + + +def test_upload_expands_home_and_emits_json( + invoke, + make_client, + upload, + tmp_path, + monkeypatch, +): + make_client() + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + (home / "input.png").write_bytes(b"png") + + result = invoke( + ["oss", "upload", "-f", "~/input.png", "-m", "qwen-vl-max"], + global_args=["-o", "json"], + ) + + assert result.exit_code == 0 + assert upload[0][2] == str(home / "input.png") + assert json.loads(result.stdout) == {"url": "oss://bucket/input.png"} + + +def test_missing_file_fails_before_building_a_client( + invoke, + make_client, + upload, + tmp_path, +): + make_client() + missing = tmp_path / "missing.png" + + result = invoke(["oss", "upload", "-f", str(missing), "-m", "qwen-vl-max"]) + + assert result.exit_code == 2 + assert "file not found" in result.stderr + assert make_client.calls == [] + assert upload == [] diff --git a/tests/cli/test_output.py b/tests/cli/test_output.py new file mode 100644 index 00000000..5cd280f7 --- /dev/null +++ b/tests/cli/test_output.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Output rendering: machine formats must stay machine-readable.""" + +import json + +import pytest + +from dashscope.cli.output import ( + JSON, + OUTPUT_FORMATS, + TABLE, + YAML, + Renderer, + is_machine_format, + render_text_stream, + resolve_no_color, + to_serializable, +) + + +class _Model: + """A stand-in for a pydantic response: only ``model_dump`` matters.""" + + def __init__(self, payload): + self._payload = payload + + def model_dump(self, **kwargs): + assert kwargs.get("mode") == "json" + assert kwargs.get("exclude_none") is True + return self._payload + + +class TestToSerializable: + def test_model_dump_is_used(self): + assert to_serializable(_Model({"a": 1})) == {"a": 1} + + def test_headers_are_transport_metadata(self): + model = _Model({"a": 1, "headers": {"x-request-id": "r"}}) + assert to_serializable(model) == {"a": 1} + assert to_serializable(model, verbose=True) == { + "a": 1, + "headers": {"x-request-id": "r"}, + } + + def test_headers_are_stripped_recursively(self): + payload = { + "choices": [ + { + "message": {"content": "x"}, + "headers": {"h": "1"}, + }, + ], + } + assert to_serializable(_Model(payload)) == { + "choices": [{"message": {"content": "x"}}], + } + + def test_scalars_and_containers(self): + assert to_serializable("s") == "s" + assert to_serializable(3) == 3 + assert to_serializable(None) is None + assert to_serializable({"a": [1, {"headers": 2}]}) == {"a": [1, {}]} + + def test_unknown_object_falls_back_to_str(self): + class Opaque: + def __str__(self): + return "opaque" + + assert to_serializable(Opaque()) == "opaque" + + +class TestMachineFormats: + def test_formats(self): + assert OUTPUT_FORMATS == (TABLE, JSON, YAML) + assert is_machine_format(JSON) and is_machine_format(YAML) + assert not is_machine_format(TABLE) + + def test_invalid_format_rejected(self): + with pytest.raises(ValueError): + Renderer("csv") + + def test_long_json_is_not_wrapped(self, capsys, monkeypatch): + """The whole point of bypassing Rich. + + Rich wraps at the terminal width, which would insert newlines + inside a JSON string and hand a downstream consumer invalid JSON. + """ + monkeypatch.setenv("COLUMNS", "40") + long_text = "word " * 60 + Renderer(JSON, no_color=True).print_json({"text": long_text}) + out = capsys.readouterr().out + assert json.loads(out)["text"] == long_text + # indent=2 yields exactly three physical lines: the value itself + # was never re-flowed to the terminal width. + assert len(out.strip().splitlines()) == 3 + + def test_json_is_utf8_not_escaped(self, capsys): + Renderer(JSON, no_color=True).print_json({"text": "你好"}) + assert "你好" in capsys.readouterr().out + + def test_ndjson_is_one_valid_object_per_line(self, capsys): + renderer = Renderer(JSON, no_color=True) + for index in range(3): + renderer.print_ndjson({"i": index, "text": "a" * 100}) + lines = capsys.readouterr().out.strip().splitlines() + assert len(lines) == 3 + assert [json.loads(line)["i"] for line in lines] == [0, 1, 2] + + def test_yaml(self, capsys): + import yaml + + Renderer(YAML, no_color=True).print_yaml({"a": 1, "b": [1, 2]}) + out = capsys.readouterr().out + assert yaml.safe_load(out) == {"a": 1, "b": [1, 2]} + assert not out.endswith("\n\n") + + def test_yaml_preserves_insertion_order(self, capsys): + import yaml + + Renderer(YAML, no_color=True).print_yaml({"z": 1, "a": 2}) + out = capsys.readouterr().out + assert out.index("z:") < out.index("a:") + assert list(yaml.safe_load(out)) == ["z", "a"] + + def test_stream_chunk_is_ndjson_in_both_machine_formats(self, capsys): + for fmt in (JSON, YAML): + Renderer(fmt, no_color=True).print_stream_chunk({"delta": "x"}) + lines = capsys.readouterr().out.strip().splitlines() + assert len(lines) == 2 + for line in lines: + assert json.loads(line) == {"delta": "x"} + + +class TestPrintObject: + def test_json(self, capsys): + Renderer(JSON, no_color=True).print_object(_Model({"job_id": "j1"})) + assert json.loads(capsys.readouterr().out) == {"job_id": "j1"} + + def test_yaml(self, capsys): + Renderer(YAML, no_color=True).print_object(_Model({"job_id": "j1"})) + assert "job_id: j1" in capsys.readouterr().out + + def test_table_lists_requested_columns_in_order(self, capsys, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + Renderer(TABLE, no_color=True).print_object( + _Model({"b": 2, "a": 1, "c": 3}), + columns=["a", "c"], + ) + out = capsys.readouterr().out + assert "a" in out and "c" in out + assert "b" not in out + + def test_table_without_columns_shows_everything(self, capsys): + Renderer(TABLE, no_color=True).print_object(_Model({"a": 1})) + assert "a" in capsys.readouterr().out + + +class TestPrintRows: + def test_machine_format_gets_the_envelope(self, capsys): + rows = [_Model({"job_id": "j1"})] + envelope = _Model({"fine_tunes": [{"job_id": "j1"}], "total_count": 7}) + Renderer(JSON, no_color=True).print_rows( + rows, + columns=["job_id"], + envelope=envelope, + ) + assert json.loads(capsys.readouterr().out)["total_count"] == 7 + + def test_machine_format_without_envelope_gets_the_rows(self, capsys): + Renderer(JSON, no_color=True).print_rows( + [_Model({"job_id": "j1"})], + columns=["job_id"], + ) + assert json.loads(capsys.readouterr().out) == [{"job_id": "j1"}] + + def test_table_format_shows_only_rows(self, capsys): + rows = [{"job_id": "j1", "status": "PENDING"}] + Renderer(TABLE, no_color=True).print_rows( + rows, + columns=["job_id", "status"], + envelope={"total_count": 7}, + ) + out = capsys.readouterr().out + assert "j1" in out and "PENDING" in out + assert "total_count" not in out + + def test_missing_cell_renders_as_a_dash(self, capsys): + Renderer(TABLE, no_color=True).print_rows( + [{"a": None}], + columns=["a", "b"], + ) + out = capsys.readouterr().out + assert out.count("-") == 2 + + def test_nested_cell_is_compact_json(self, capsys): + Renderer(TABLE, no_color=True).print_rows( + [{"usage": {"total_tokens": 5}}], + columns=["usage"], + ) + assert '{"total_tokens":5}' in capsys.readouterr().out + + +class TestSideChannels: + def test_echo_goes_to_stdout_unstyled(self, capsys): + Renderer(TABLE, no_color=True).echo("[red]not markup[/red]") + result = capsys.readouterr() + assert result.out == "[red]not markup[/red]\n" + assert result.err == "" + + def test_write_is_unbuffered_and_newline_free(self, capsys): + renderer = Renderer(TABLE, no_color=True) + renderer.write("a") + renderer.write("b") + assert capsys.readouterr().out == "ab" + + def test_note_goes_to_stderr(self, capsys): + Renderer(TABLE, no_color=True).note("progress") + result = capsys.readouterr() + assert result.out == "" + assert "progress" in result.err + + def test_success_is_silent_in_machine_formats(self, capsys): + for fmt in (JSON, YAML): + Renderer(fmt, no_color=True).success("done") + assert capsys.readouterr().err == "" + + def test_success_shows_in_table_format(self, capsys): + Renderer(TABLE, no_color=True).success("done") + result = capsys.readouterr() + assert "done" in result.err + assert result.out == "" + + +class _Stream: + """A stand-in for ``ChatStream``: only ``text_stream`` matters here.""" + + def __init__(self, frames, cumulative=False): + self._frames = frames + self.cumulative = cumulative + + @property + def text_stream(self): + return iter(self._frames) + + +class _FlaglessStream: + """A chat-shaped stream that does not advertise the frame shape.""" + + def __init__(self, frames): + self._frames = frames + + @property + def text_stream(self): + return iter(self._frames) + + +class TestRenderTextStream: + def test_incremental_frames_are_appended(self, capsys): + render_text_stream( + Renderer(TABLE, no_color=True), + _Stream(["Hi", "!"]), + ) + assert capsys.readouterr().out == "Hi!\n" + + def test_cumulative_snapshots_are_not_concatenated(self, capsys): + """``incremental_output=false`` yields "Hi" then "Hi!" -- appending + both prints "HiHi!".""" + render_text_stream( + Renderer(TABLE, no_color=True), + _Stream(["Hi", "Hi!", "Hi! there"], cumulative=True), + ) + assert capsys.readouterr().out == "Hi! there\n" + + def test_a_cumulative_frame_that_shrinks_writes_nothing(self, capsys): + render_text_stream( + Renderer(TABLE, no_color=True), + _Stream(["Hi there", "Hi"], cumulative=True), + ) + assert capsys.readouterr().out == "Hi there\n" + + def test_a_stream_without_the_flag_is_read_as_incremental(self, capsys): + render_text_stream( + Renderer(TABLE, no_color=True), + _FlaglessStream(["a", "b"]), + ) + assert capsys.readouterr().out == "ab\n" + + +class TestNoColor: + def test_flag(self): + assert resolve_no_color(True) is True + + def test_env(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + assert resolve_no_color(False) is True + + def test_unset(self, monkeypatch): + monkeypatch.delenv("NO_COLOR", raising=False) + assert resolve_no_color(False) is False + + def test_renderer_honours_env_by_default(self, monkeypatch): + monkeypatch.setenv("NO_COLOR", "1") + assert Renderer(TABLE).no_color is True diff --git a/tests/cli/test_package_layout.py b/tests/cli/test_package_layout.py new file mode 100644 index 00000000..e3d72fff --- /dev/null +++ b/tests/cli/test_package_layout.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Structural invariants of the independent CLI package trees. + +``dashscope.cli`` serves only v2 through ``dashscope``. The 1.x tree lives in +``dashscope.legacy_1x.cli`` and has its own ``dashscope1`` entry point. +""" + +import ast +import subprocess +import sys +from pathlib import Path + +from tests.cli.conftest import strip_ansi_codes + +PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "dashscope" / "cli" +COMMANDS_ROOT = PACKAGE_ROOT / "commands" + + +def _run_python(code: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + check=False, + ) + + +def test_cli_never_loads_the_1x_command_tree(): + """What importing ``dashscope.cli`` can and cannot cost. + + ``dashscope/__init__.py`` eagerly imports ``dashscope.client`` *and* + the whole 1.x resource stack, so pydantic, httpx and ``legacy_1x.aigc`` + are paid by any ``dashscope.*`` import; no subpackage layout avoids + that. What this package does avoid is ``dashscope.legacy_1x.cli`` -- + 18 Typer submodules plus ``set_sdk_client`` -- which is the part that + would otherwise make every ``dashscope --help`` slow and would also + mutate the 1.x global SDK identity for v2-only invocations. + """ + result = _run_python( + "import sys, dashscope.cli, dashscope.cli.context\n" + "import dashscope.cli.commands\n" + "assert 'dashscope.legacy_1x.cli' not in sys.modules\n" + "assert 'dashscope.client' in sys.modules\n", + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_v2_argv_executes_without_loading_the_legacy_cli(): + result = _run_python( + "import sys\n" + "from dashscope.cli.main import main\n" + "try:\n" + " main(['apps', 'completions', 'create', '--help'])\n" + "except SystemExit as exc:\n" + " assert exc.code == 0\n" + "assert 'dashscope.legacy_1x.cli' not in sys.modules\n", + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_legacy_argv_is_rejected_without_loading_the_legacy_cli(): + result = _run_python( + "import sys\n" + "from dashscope.cli.main import main\n" + "try:\n" + " main(['generation', 'call', '--help'])\n" + "except SystemExit as exc:\n" + " assert exc.code == 2\n" + "assert 'dashscope.legacy_1x.cli' not in sys.modules\n", + ) + assert result.returncode == 0, result.stdout + result.stderr + error = strip_ansi_codes(result.stderr) + assert "No such command 'generation'" in error + assert "moved to 'dashscope1'" in error + + +def _module_level_imports(path: Path): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name + elif isinstance(node, ast.ImportFrom): + if node.level == 0 and node.module: + yield node.module + + +def _all_imports(path: Path): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name + elif isinstance(node, ast.ImportFrom): + if node.level == 0 and node.module: + yield node.module + + +def test_v2_tree_never_imports_legacy_1x(): + offenders = [] + for path in sorted(PACKAGE_ROOT.rglob("*.py")): + for name in _all_imports(path): + if name.startswith("dashscope.legacy_1x"): + offenders.append(f"{path.relative_to(PACKAGE_ROOT)}: {name}") + assert offenders == [] + + +def test_command_modules_never_import_the_sdk_at_module_scope(): + """The iron rule: SDK access goes through ``ctx.obj.client``. + + Enforced by reading the AST rather than by convention, because a + single stray top-level ``from dashscope import Client`` in any future + command module would make every ``--help`` pay for pydantic and httpx. + + ``dashscope.cli`` itself is allowed: importing the package runs an + ``__init__.py`` that is required to be empty (see + :func:`test_package_init_stays_lazy`). + """ + offenders = [] + for path in sorted(COMMANDS_ROOT.glob("*.py")): + for name in _module_level_imports(path): + if name == "dashscope" or ( + name.startswith("dashscope.") + and name != "dashscope.cli" + and not name.startswith("dashscope.cli.") + ): + offenders.append(f"{path.name}: {name}") + assert offenders == [] + + +def test_console_script_targets_resolve(): + """Both console scripts resolve to independent CLI implementations.""" + from importlib import import_module + + v2_module = import_module("dashscope.cli.main") + legacy_module = import_module("dashscope.legacy_1x.cli") + assert callable(v2_module.main) + assert callable(legacy_module.main) + + +def test_legacy_tree_uses_the_dashscope1_name(): + from importlib import import_module + + module = import_module("dashscope.legacy_1x.cli") + assert module.app.info.name == "dashscope1" + + +def test_package_init_stays_lazy(): + """``__init__.py`` imports nothing at all. + + It runs for shell completion and ``--help``, so anything reaching + back into ``dashscope`` would drag the SDK (or the 1.x tree) in. + """ + offenders = [ + name + for name in _module_level_imports(PACKAGE_ROOT / "__init__.py") + if name == "dashscope" or name.startswith("dashscope.") + ] + assert offenders == [] diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py new file mode 100644 index 00000000..064b454c --- /dev/null +++ b/tests/cli/test_progress.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``--wait`` delegates to the SDK's poller instead of reimplementing it.""" + +import io +from types import SimpleNamespace + +import pytest + +from dashscope.cli.errors import CliError +from dashscope.cli.output import JSON, Renderer +from dashscope.cli.progress import validate_wait_timeout, wait_job + + +class _RecordingWait: + """Stand-in for ``client.fine_tunes`` capturing the ``wait`` call.""" + + def __init__(self, result=None, exc=None): + self.calls = [] + self._result = result + self._exc = exc + + def wait(self, job, **kwargs): + self.calls.append((job, kwargs)) + if self._exc is not None: + raise self._exc + return self._result + + +def _client(waiter): + return SimpleNamespace(fine_tunes=waiter) + + +class _TtyStderr(io.StringIO): + def isatty(self): + return True + + +# Both fixtures return an installer rather than patching directly: pytest +# re-applies its own capture around the *call* phase, which would undo a +# sys.stderr patch made during fixture setup. +@pytest.fixture +def tty_stderr(monkeypatch): + def install() -> _TtyStderr: + stream = _TtyStderr() + monkeypatch.setattr("sys.stderr", stream) + return stream + + return install + + +@pytest.fixture +def quiet_stderr(monkeypatch): + def install() -> io.StringIO: + stream = io.StringIO() + monkeypatch.setattr("sys.stderr", stream) + return stream + + return install + + +class TestDelegation: + def test_returns_what_wait_returns(self): + job = SimpleNamespace(job_id="job-1") + waiter = _RecordingWait(result=job) + assert wait_job(_client(waiter), "job-1", timeout=5) is job + + def test_job_object_or_id_both_work(self): + job = SimpleNamespace(job_id="job-1") + waiter = _RecordingWait(result=job) + wait_job(_client(waiter), job, timeout=5) + assert waiter.calls[0][0] is job + + def test_timeout_is_forwarded(self): + waiter = _RecordingWait(result=None) + wait_job(_client(waiter), "job-1", timeout=42) + assert waiter.calls[0][1]["timeout"] == 42 + + def test_timeout_is_omitted_when_unset(self): + """So the SDK's own default applies rather than a CLI-invented one.""" + waiter = _RecordingWait(result=None) + wait_job(_client(waiter), "job-1") + assert "timeout" not in waiter.calls[0][1] + + def test_failure_propagates(self): + """The CLI must not turn a failed job into a success exit code.""" + boom = RuntimeError("job failed") + waiter = _RecordingWait(exc=boom) + with pytest.raises(RuntimeError) as info: + wait_job(_client(waiter), "job-1", timeout=5) + assert info.value is boom + + +class TestProgressVisibility: + def test_no_renderer_means_no_sleep_hook(self): + waiter = _RecordingWait(result=None) + wait_job(_client(waiter), "job-1", timeout=5) + assert "_sleep" not in waiter.calls[0][1] + + def test_machine_format_stays_silent(self, tty_stderr): + """A spinner would corrupt a JSON stream.""" + stream = tty_stderr() + waiter = _RecordingWait(result=None) + wait_job( + _client(waiter), + "job-1", + timeout=5, + renderer=Renderer(JSON), + ) + assert "_sleep" not in waiter.calls[0][1] + assert stream.getvalue() == "" + + def test_table_format_without_a_terminal_stays_silent(self, quiet_stderr): + quiet_stderr() + waiter = _RecordingWait(result=None) + wait_job( + _client(waiter), + "job-1", + timeout=5, + renderer=Renderer("table"), + ) + assert "_sleep" not in waiter.calls[0][1] + + def test_table_format_on_a_terminal_installs_the_hook(self, tty_stderr): + tty_stderr() + waiter = _RecordingWait(result=None) + wait_job( + _client(waiter), + "job-1", + timeout=5, + renderer=Renderer("table"), + ) + assert callable(waiter.calls[0][1]["_sleep"]) + + +class TestSleepHook: + def test_sleeps_for_the_requested_interval(self, tty_stderr, monkeypatch): + tty_stderr() + slept = [] + monkeypatch.setattr("dashscope.cli.progress.time.sleep", slept.append) + waiter = _RecordingWait(result=None) + wait_job( + _client(waiter), + "job-1", + timeout=5, + renderer=Renderer("table"), + ) + waiter.calls[0][1]["_sleep"](1.5) + assert slept == [1.5] + + def test_mentions_the_job_id(self, tty_stderr, monkeypatch): + stream = tty_stderr() + monkeypatch.setattr( + "dashscope.cli.progress.time.sleep", + lambda s: None, + ) + waiter = _RecordingWait(result=None) + wait_job( + _client(waiter), + SimpleNamespace(job_id="ft-20260101"), + timeout=5, + renderer=Renderer("table"), + ) + waiter.calls[0][1]["_sleep"](0) + assert "ft-20260101" in stream.getvalue() + + def test_repeated_polls_do_not_stack_the_suffix( + self, + tty_stderr, + monkeypatch, + ): + stream = tty_stderr() + monkeypatch.setattr( + "dashscope.cli.progress.time.sleep", + lambda s: None, + ) + waiter = _RecordingWait(result=None) + wait_job( + _client(waiter), + "job-1", + timeout=5, + renderer=Renderer("table"), + ) + sleep = waiter.calls[0][1]["_sleep"] + sleep(0) + sleep(0) + text = stream.getvalue() + assert text.count("elapsed) waiting for") == 0 + + def test_custom_label(self, tty_stderr, monkeypatch): + stream = tty_stderr() + monkeypatch.setattr( + "dashscope.cli.progress.time.sleep", + lambda s: None, + ) + waiter = _RecordingWait(result=None) + wait_job( + _client(waiter), + "dep-1", + timeout=5, + renderer=Renderer("table"), + label="deployment", + ) + waiter.calls[0][1]["_sleep"](0) + assert "deployment dep-1" in stream.getvalue() + + +class TestValidateWaitTimeout: + """A timeout the poller could never honour must fail before submitting.""" + + @pytest.mark.parametrize("value", [None, 0.5, 1, 3600.0, 1e9]) + def test_accepts_a_usable_timeout(self, value): + validate_wait_timeout(value) + + @pytest.mark.parametrize("value", [-1, -0.5, 0, 0.0]) + def test_rejects_a_non_positive_timeout(self, value): + with pytest.raises(CliError) as info: + validate_wait_timeout(value) + assert info.value.category == "usage" + assert "--timeout" in info.value.message + + def test_rejects_nan(self): + """nan is false against every comparison, so the poll never ends.""" + with pytest.raises(CliError) as info: + validate_wait_timeout(float("nan")) + assert "finite" in info.value.message + + def test_rejects_infinity(self): + with pytest.raises(CliError): + validate_wait_timeout(float("inf")) + + def test_wait_job_checks_it_too(self): + """The submit sites check first; this is the net underneath them.""" + waiter = _RecordingWait(result=None) + with pytest.raises(CliError): + wait_job(_client(waiter), "ft-1", timeout=float("nan")) + assert waiter.calls == [] diff --git a/tests/cli/test_rerank_cmd.py b/tests/cli/test_rerank_cmd.py new file mode 100644 index 00000000..c27d99b8 --- /dev/null +++ b/tests/cli/test_rerank_cmd.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope rerank`` -- create.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.rerank import RerankResponse, RerankResult +from dashscope.types.common import Usage + + +def _rerank_response(**overrides: Any) -> RerankResponse: + data: Dict[str, Any] = { + "results": [ + RerankResult(index=0, relevance_score=0.95), + RerankResult(index=1, relevance_score=0.80), + ], + "usage": Usage(total_tokens=20), + } + data.update(overrides) + return RerankResponse(**data) + + +class _Rerank: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def create(self, **kwargs): + self.calls.append({"method": "create", "kwargs": kwargs}) + result = self._results.get("create") + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def rerank(make_client): + def install(**results: Any) -> _Rerank: + resource = _Rerank(**results) + make_client(rerank=resource) + return resource + + return install + + +class TestCreate: + def test_create_with_documents_json(self, invoke, rerank): + resource = rerank(create=_rerank_response()) + result = invoke( + [ + "rerank", + "create", + "-m", + "gte-rerank", + "-q", + "what is AI", + "--documents", + '["AI is...", "ML is..."]', + ], + ) + assert result.exit_code == 0 + assert resource.calls[0]["kwargs"]["documents"] == [ + "AI is...", + "ML is...", + ] + + def test_create_with_repeatable_document(self, invoke, rerank): + resource = rerank(create=_rerank_response()) + invoke( + [ + "rerank", + "create", + "-m", + "gte-rerank", + "-q", + "query", + "-d", + "doc1", + "-d", + "doc2", + ], + ) + assert resource.calls[0]["kwargs"]["documents"] == ["doc1", "doc2"] + + def test_create_with_top_n(self, invoke, rerank): + resource = rerank(create=_rerank_response()) + invoke( + [ + "rerank", + "create", + "-m", + "gte-rerank", + "-q", + "query", + "-d", + "doc", + "--top-n", + "5", + ], + ) + assert resource.calls[0]["kwargs"]["top_n"] == 5 + + def test_create_json_output(self, invoke, rerank): + rerank(create=_rerank_response()) + result = invoke( + [ + "rerank", + "create", + "-m", + "gte-rerank", + "-q", + "query", + "-d", + "doc", + ], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + import json + + doc = json.loads(result.stdout) + assert len(doc["results"]) == 2 + + def test_create_requires_query(self, invoke, rerank): + rerank(create=_rerank_response()) + result = invoke(["rerank", "create", "-m", "gte-rerank", "-d", "doc"]) + assert result.exit_code != 0 + + def test_create_requires_documents(self, invoke, rerank): + rerank(create=_rerank_response()) + result = invoke( + ["rerank", "create", "-m", "gte-rerank", "-q", "query"], + ) + assert result.exit_code == 2 + assert "no documents" in result.stderr diff --git a/tests/cli/test_rl_cmd.py b/tests/cli/test_rl_cmd.py new file mode 100644 index 00000000..18e0ae77 --- /dev/null +++ b/tests/cli/test_rl_cmd.py @@ -0,0 +1,516 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope rl`` -- the jobs and functions subgroups.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope import errors as sdk_errors +from dashscope.cli.app import build_app +from dashscope.resources.fine_tunes import FineTune, FineTuneList, JobLogs +from dashscope.resources.reinforcement_learning import ( + RegisteredFunctions, + UploadedData, +) +from dashscope.types.common import JobStatus + + +def _job(**overrides: Any) -> FineTune: + data: Dict[str, Any] = { + "job_id": "rl-job-1", + "job_name": "my-rl-run", + "status": "SUCCEEDED", + "model": "qwen-plus", + "created_at": "2026-01-01 12:00:00", + } + data.update(overrides) + return FineTune(**data) + + +class _RlJobs: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def create(self, **kwargs): + return self._record("create", **kwargs) + + def fetch(self, job_id, **kwargs): + return self._record("fetch", job_id, **kwargs) + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def logs(self, job_id, **kwargs): + return self._record("logs", job_id, **kwargs) + + def cancel(self, job_id, **kwargs): + return self._record("cancel", job_id, **kwargs) + + def delete(self, job_id, **kwargs): + return self._record("delete", job_id, **kwargs) + + def wait(self, job, **kwargs): + self._record("wait", job, **kwargs) + return self._results.get("wait", job) + + +class _Rl: + """Stand-in for ``client.rl`` recording every call.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + self.jobs = _RlJobs(**results.get("jobs", {})) + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def register_functions(self, **kwargs): + return self._record("register_functions", **kwargs) + + def test_functions(self, **kwargs): + return self._record("test_functions", **kwargs) + + def upload_data(self, *args, **kwargs): + return self._record("upload_data", *args, **kwargs) + + def query_function_instance_logs(self, instance_id, **kwargs): + return self._record( + "query_function_instance_logs", + instance_id, + **kwargs, + ) + + def query_all_function_instance_logs(self, instance_id, **kwargs): + return self._record( + "query_all_function_instance_logs", + instance_id, + **kwargs, + ) + + def delete_function_instance(self, instance_id, **kwargs): + return self._record("delete_function_instance", instance_id, **kwargs) + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def rl(make_client): + def install(**results: Any) -> _Rl: + resource = _Rl(**results) + make_client(rl=resource) + return resource + + return install + + +def _as_terminal_tty(monkeypatch, answer: bool): + monkeypatch.setattr("dashscope.cli.confirm.stdin_is_tty", lambda: True) + monkeypatch.setattr( + "dashscope.cli.confirm.typer.confirm", + lambda *a, **k: answer, + ) + + +class TestJobsCreate: + def test_create_submits_without_waiting(self, invoke, rl): + resource = rl(jobs={"create": _job(status="PENDING")}) + result = invoke(["rl", "jobs", "create", "--config", "rl.yaml"]) + assert result.exit_code == 0 + assert resource.jobs.calls[0]["kwargs"]["config_path"] == "rl.yaml" + assert [c["method"] for c in resource.jobs.calls] == ["create"] + + def test_create_forwards_config_overrides(self, invoke, rl): + resource = rl(jobs={"create": _job()}) + invoke( + [ + "rl", + "jobs", + "create", + "--config", + "rl.yaml", + "--job-name", + "run-7", + "--set", + "epochs=3", + "--set", + 'files=["a.jsonl","b.jsonl"]', + "--set", + "note=plain string", + ], + ) + kwargs = resource.jobs.calls[0]["kwargs"] + assert kwargs["job_name"] == "run-7" + assert kwargs["epochs"] == 3 + assert kwargs["files"] == ["a.jsonl", "b.jsonl"] + assert kwargs["note"] == "plain string" + + def test_create_rejects_a_malformed_override(self, invoke, rl): + rl(jobs={"create": _job()}) + result = invoke(["rl", "jobs", "create", "--set", "epochs"]) + assert result.exit_code == 2 + assert "key=value" in result.stderr + + def test_create_wait_blocks_on_the_rl_waiter(self, invoke, rl): + resource = rl( + jobs={"create": _job(status="PENDING"), "wait": _job()}, + ) + invoke(["rl", "jobs", "create", "--wait", "--timeout", "30"]) + methods = [c["method"] for c in resource.jobs.calls] + assert methods == ["create", "wait"] + assert resource.jobs.calls[1]["kwargs"]["timeout"] == 30.0 + + def test_create_json_output(self, invoke, rl): + rl(jobs={"create": _job()}) + result = invoke( + ["rl", "jobs", "create"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["job_id"] == "rl-job-1" + + +class TestJobsRead: + def test_get(self, invoke, rl): + resource = rl(jobs={"fetch": _job()}) + result = invoke(["rl", "jobs", "get", "rl-job-1"]) + assert result.exit_code == 0 + assert resource.jobs.calls[0]["args"] == ("rl-job-1",) + assert "SUCCEEDED" in result.stdout + + def test_list(self, invoke, rl): + rl( + jobs={ + "list": FineTuneList( + jobs=[_job(), _job(job_id="rl-job-2")], + page_no=1, + page_size=10, + total_count=2, + ), + }, + ) + result = invoke(["rl", "jobs", "list"]) + assert result.exit_code == 0 + assert "rl-job-1" in result.stdout + assert "rl-job-2" in result.stdout + + def test_list_pagination_flags(self, invoke, rl): + resource = rl( + jobs={"list": FineTuneList(jobs=[], page_no=2, page_size=5)}, + ) + invoke(["rl", "jobs", "list", "--page", "2", "--size", "5"]) + assert resource.jobs.calls[0]["kwargs"] == { + "page_no": 2, + "page_size": 5, + } + + def test_logs_print_the_content(self, invoke, rl): + resource = rl( + jobs={ + "logs": JobLogs( + job_id="rl-job-1", + content="line one\nline two", + ), + }, + ) + result = invoke(["rl", "jobs", "logs", "rl-job-1", "--lines", "50"]) + assert result.exit_code == 0 + assert resource.jobs.calls[0]["kwargs"] == {"offset": 1, "lines": 50} + assert "line one" in result.stdout + + def test_logs_json_output(self, invoke, rl): + rl(jobs={"logs": JobLogs(job_id="rl-job-1", content="hello")}) + result = invoke( + ["rl", "jobs", "logs", "rl-job-1"], + global_args=["-o", "json"], + ) + assert json.loads(result.stdout)["content"] == "hello" + + def test_wait_polls_the_id_and_nothing_else(self, invoke, rl): + resource = rl(jobs={"wait": _job()}) + result = invoke(["rl", "jobs", "wait", "rl-job-1"]) + assert result.exit_code == 0 + assert [c["method"] for c in resource.jobs.calls] == ["wait"] + assert resource.jobs.calls[0]["args"] == ("rl-job-1",) + + def test_wait_defaults_to_an_hour(self, invoke, rl): + resource = rl(jobs={"wait": _job()}) + invoke(["rl", "jobs", "wait", "rl-job-1"]) + assert resource.jobs.calls[0]["kwargs"] == {"timeout": 3600.0} + + def test_wait_table_shows_the_settled_job(self, invoke, rl): + rl(jobs={"wait": _job(status="SUCCEEDED")}) + result = invoke(["rl", "jobs", "wait", "rl-job-1"]) + assert "SUCCEEDED" in result.stdout + + def test_wait_json_output_is_the_whole_job(self, invoke, rl): + rl(jobs={"wait": _job()}) + result = invoke( + ["rl", "jobs", "wait", "rl-job-1"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["job_id"] == "rl-job-1" + assert doc["status"] == "SUCCEEDED" + + def test_wait_failure_exits_nonzero(self, invoke, rl): + rl(jobs={"wait": sdk_errors.InternalServerError("training diverged")}) + result = invoke(["rl", "jobs", "wait", "rl-job-1"]) + assert result.exit_code == 1 + assert "Error [server]: training diverged" in result.stderr + + def test_wait_requires_the_id(self, invoke, rl): + resource = rl(jobs={"wait": _job()}) + assert invoke(["rl", "jobs", "wait"]).exit_code == 2 + assert resource.jobs.calls == [] + + @pytest.mark.parametrize("timeout", ["0", "nan"]) + def test_wait_rejects_a_timeout_it_could_not_honour( + self, + invoke, + rl, + timeout, + ): + """``nan`` is false against every comparison, so the poll never ends.""" + resource = rl(jobs={"wait": _job()}) + result = invoke( + ["rl", "jobs", "wait", "rl-job-1", "--timeout", timeout], + ) + assert result.exit_code == 2 + assert "--timeout" in result.stderr + assert resource.jobs.calls == [] + + +class TestJobsDestructive: + def test_delete_with_yes(self, invoke, rl): + resource = rl(jobs={"delete": JobStatus()}) + result = invoke(["rl", "jobs", "delete", "rl-job-1", "--yes"]) + assert result.exit_code == 0 + assert resource.jobs.calls[0]["args"] == ("rl-job-1",) + + def test_delete_refuses_without_a_tty(self, invoke, rl): + resource = rl(jobs={"delete": JobStatus()}) + result = invoke(["rl", "jobs", "delete", "rl-job-1"]) + assert result.exit_code == 2 + assert "--yes" in result.stderr + assert resource.jobs.calls == [] + + def test_delete_confirms_in_a_tty(self, invoke, rl, monkeypatch): + _as_terminal_tty(monkeypatch, answer=True) + resource = rl(jobs={"delete": JobStatus()}) + result = invoke(["rl", "jobs", "delete", "rl-job-1"]) + assert result.exit_code == 0 + assert len(resource.jobs.calls) == 1 + + def test_delete_aborts_on_no(self, invoke, rl, monkeypatch): + _as_terminal_tty(monkeypatch, answer=False) + resource = rl(jobs={"delete": JobStatus()}) + result = invoke(["rl", "jobs", "delete", "rl-job-1"]) + assert result.exit_code != 0 + assert resource.jobs.calls == [] + + def test_cancel_with_yes(self, invoke, rl): + resource = rl(jobs={"cancel": JobStatus()}) + result = invoke(["rl", "jobs", "cancel", "rl-job-1", "--yes"]) + assert result.exit_code == 0 + assert resource.jobs.calls[0]["args"] == ("rl-job-1",) + + def test_cancel_refuses_without_a_tty(self, invoke, rl): + resource = rl(jobs={"cancel": JobStatus()}) + result = invoke(["rl", "jobs", "cancel", "rl-job-1"]) + assert result.exit_code == 2 + assert resource.jobs.calls == [] + + +class TestFunctions: + def test_register_passes_the_classpaths(self, invoke, rl): + resource = rl( + register_functions=RegisteredFunctions( + rollout_entity_ids=["e1"], + rollout_instance_ids=["i1"], + ), + ) + result = invoke( + [ + "rl", + "functions", + "register", + "--rollout", + "mypkg.rollout:MyRollout", + "--reward", + "mypkg.reward:MyReward", + "--workspace-dir", + "/tmp/ws", + "--no-lazy-load", + ], + ) + assert result.exit_code == 0 + kwargs = resource.calls[0]["kwargs"] + assert kwargs["rollout_classpaths"] == ["mypkg.rollout:MyRollout"] + assert kwargs["reward_classpaths"] == ["mypkg.reward:MyReward"] + assert kwargs["group_reward_classpaths"] == [] + assert kwargs["workspace_dir"] == "/tmp/ws" + assert kwargs["lazy_load"] is False + assert "i1" in result.stdout + + def test_register_requires_a_function(self, invoke, rl): + resource = rl(register_functions=RegisteredFunctions()) + result = invoke(["rl", "functions", "register"]) + assert result.exit_code == 2 + assert "--rollout" in result.stderr + assert resource.calls == [] + + def test_test_passes_the_payload(self, invoke, rl): + resource = rl(test_functions={"score": 1.0}) + result = invoke( + [ + "rl", + "functions", + "test", + "--instance-id", + "i1", + "--type", + "reward", + "--input", + '{"answer": "42"}', + ], + ) + assert result.exit_code == 0 + kwargs = resource.calls[0]["kwargs"] + assert kwargs["instance_id"] == "i1" + assert kwargs["func_type"] == "reward" + assert kwargs["input_data"] == {"answer": "42"} + + def test_test_rejects_a_non_object_input(self, invoke, rl): + rl(test_functions={}) + result = invoke( + [ + "rl", + "functions", + "test", + "--instance-id", + "i1", + "--type", + "reward", + "--input", + "[1,2]", + ], + ) + assert result.exit_code == 2 + + def test_upload_data_passes_both_file_lists(self, invoke, rl): + resource = rl( + upload_data=UploadedData( + training_file_ids=["f1"], + validation_file_ids=["f2"], + ), + ) + result = invoke( + [ + "rl", + "functions", + "upload-data", + "--training-file", + "train.jsonl", + "--validation-file", + "val.jsonl", + ], + ) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == (["train.jsonl"], ["val.jsonl"]) + assert "f1" in result.stdout + + def test_upload_data_requires_a_training_file(self, invoke, rl): + rl(upload_data=UploadedData()) + result = invoke(["rl", "functions", "upload-data"]) + assert result.exit_code == 2 + + def test_logs_reads_one_page(self, invoke, rl): + resource = rl( + query_function_instance_logs={"logs": ["a"], "total": 1}, + ) + result = invoke( + ["rl", "functions", "logs", "i1", "--page", "2", "--size", "5"], + ) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("i1",) + assert resource.calls[0]["kwargs"]["page_number"] == 2 + assert resource.calls[0]["kwargs"]["page_size"] == 5 + + def test_logs_all_pages_prints_one_line_each(self, invoke, rl): + resource = rl( + query_all_function_instance_logs=["first line", "second line"], + ) + result = invoke(["rl", "functions", "logs", "i1", "--all"]) + assert result.exit_code == 0 + assert ( + resource.calls[0]["method"] == "query_all_function_instance_logs" + ) + assert result.stdout.splitlines() == ["first line", "second line"] + + def test_logs_all_pages_is_json_for_machines(self, invoke, rl): + rl(query_all_function_instance_logs=["first line", "second line"]) + result = invoke( + ["rl", "functions", "logs", "i1", "--all"], + global_args=["-o", "json"], + ) + assert json.loads(result.stdout) == ["first line", "second line"] + + def test_logs_passes_keywords(self, invoke, rl): + resource = rl(query_function_instance_logs={}) + invoke( + [ + "rl", + "functions", + "logs", + "i1", + "--keyword", + "error", + "--keyword", + "traceback", + ], + ) + assert resource.calls[0]["kwargs"]["keywords"] == [ + "error", + "traceback", + ] + + def test_delete_with_yes(self, invoke, rl): + resource = rl(delete_function_instance={"deleted": True}) + result = invoke(["rl", "functions", "delete", "i1", "--yes"]) + assert result.exit_code == 0 + assert resource.calls[0]["args"] == ("i1",) + + def test_delete_refuses_without_a_tty(self, invoke, rl): + resource = rl(delete_function_instance={"deleted": True}) + result = invoke(["rl", "functions", "delete", "i1"]) + assert result.exit_code == 2 + assert "--yes" in result.stderr + assert resource.calls == [] diff --git a/tests/cli/test_sdk.py b/tests/cli/test_sdk.py new file mode 100644 index 00000000..b9c18b1b --- /dev/null +++ b/tests/cli/test_sdk.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``build_client``: which override is a constructor argument and which is not. + +``-c`` accepts every key a config file accepts, but the config file is +not a mirror of ``Client.__init__`` -- the connection-pool keys are +runtime settings with no constructor parameter. Treating the key list as +a whitelist of arguments turned all three into ``TypeError`` at exit 1. +""" + +from __future__ import annotations + +import pytest + +from dashscope import _config +from dashscope.cli.errors import CliError +from dashscope.cli.sdk import build_client + + +@pytest.fixture(autouse=True) +def settings(): + """Hand back the process-wide singleton, cleared on both sides. + + It sits above the environment in the SDK's resolution chain, so a + value another test module left behind would make these assertions + pass for the wrong reason. + """ + _config.settings.clear() + yield _config.settings + _config.settings.clear() + + +@pytest.fixture +def build(): + built = [] + + def run(**overrides): + client = build_client(overrides) + built.append(client) + return client + + yield run + for client in built: + client.close() + + +class TestPoolSettings: + def test_they_reach_the_resolved_config(self, build): + client = build( + workspace="ws-test", + http_max_connections=7, + http_max_keepalive_connections=3, + http_keepalive_expiry=1.5, + ) + assert client.config.http_max_connections == 7 + assert client.config.http_max_keepalive_connections == 3 + assert client.config.http_keepalive_expiry == 1.5 + + def test_they_go_through_the_runtime_settings(self, build, settings): + """``resolve_config`` hard-codes None for these, so this is the + only channel a single invocation has.""" + build(workspace="ws-test", http_max_connections=7) + assert settings.http_max_connections == 7 + + @pytest.mark.parametrize( + "key,value", + [ + ("http_max_connections", 0), + ("http_max_keepalive_connections", -1), + ("http_keepalive_expiry", -1.0), + ], + ) + def test_an_invalid_value_is_refused_before_the_client_exists( + self, + build, + key, + value, + ): + with pytest.raises(ValueError): + build(**{key: value}) + + +class TestClientArguments: + def test_they_still_pass_through(self, build): + client = build( + api_key="sk-1", + base_url="https://example.test", + workspace="ws-test", + timeout=9.0, + ) + assert client.config.api_key == "sk-1" + assert client.config.timeout == 9.0 + + def test_both_kinds_coexist(self, build): + client = build( + api_key="sk-1", + workspace="ws-test", + http_max_connections=7, + ) + assert client.config.api_key == "sk-1" + assert client.config.http_max_connections == 7 + + def test_an_unknown_key_is_a_usage_error(self, build): + """The CLI's own allow-list catches this first; the guard is for + the day the two lists drift, which is what caused the bug.""" + with pytest.raises(CliError) as info: + build(nope=1) + assert info.value.category == "usage" + assert "nope" in info.value.message diff --git a/tests/cli/test_tingwu_cmd.py b/tests/cli/test_tingwu_cmd.py new file mode 100644 index 00000000..60144247 --- /dev/null +++ b/tests/cli/test_tingwu_cmd.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope tingwu`` -- the offline task.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.tingwu import TingWuResult + + +def _result(**overrides: Any) -> TingWuResult: + data: Dict[str, Any] = { + "output": { + "TaskId": "task-1", + "Result": {"Transcription": "hello"}, + }, + "usage": {"input_tokens": 12}, + "request_id": "req-1", + } + data.update(overrides) + return TingWuResult(**data) + + +class _TingWu: + """Stand-in for ``client.tingwu``.""" + + def __init__(self, result: Any = None) -> None: + self.calls: List[Dict[str, Any]] = [] + self._result = result if result is not None else _result() + + def call(self, **kwargs): + self.calls.append(kwargs) + return self._result + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def tingwu(make_client): + def install(result: Any = None) -> _TingWu: + resource = _TingWu(result) + make_client(tingwu=resource) + return resource + + return install + + +class TestCall: + def test_prints_the_parsed_output(self, invoke, tingwu): + resource = tingwu() + result = invoke( + [ + "tingwu", + "call", + "-m", + "tingwu-turbo", + "--file-url", + "https://example.com/a.mp3", + ], + ) + assert result.exit_code == 0 + assert "task-1" in result.stdout + assert resource.calls[0]["model"] == "tingwu-turbo" + assert resource.calls[0]["input"] == { + "fileUrl": "https://example.com/a.mp3", + } + + def test_the_dedicated_flags_win_over_the_json(self, invoke, tingwu): + resource = tingwu() + invoke( + [ + "tingwu", + "call", + "-m", + "tingwu-turbo", + "--input", + '{"fileUrl": "https://example.com/old.mp3", "TaskKey": "k"}', + "--file-url", + "https://example.com/new.mp3", + "--app-id", + "app-1", + ], + ) + assert resource.calls[0]["input"] == { + "TaskKey": "k", + "fileUrl": "https://example.com/new.mp3", + "appid": "app-1", + } + + def test_reads_the_payload_from_a_file(self, invoke, tingwu, tmp_path): + payload = tmp_path / "task.json" + payload.write_text( + json.dumps({"fileUrl": "https://example.com/a.mp3"}), + encoding="utf-8", + ) + resource = tingwu() + invoke( + ["tingwu", "call", "-m", "tingwu-turbo", "--input", f"@{payload}"], + ) + assert resource.calls[0]["input"] == { + "fileUrl": "https://example.com/a.mp3", + } + + def test_passes_the_extra_parameters(self, invoke, tingwu): + resource = tingwu() + invoke( + [ + "tingwu", + "call", + "-m", + "tingwu-turbo", + "--app-id", + "app-1", + "--parameters", + '{"transcription": {"diarization": true}}', + ], + ) + assert resource.calls[0]["parameters"] == { + "transcription": {"diarization": True}, + } + + def test_the_model_is_required(self, invoke, tingwu): + tingwu() + assert invoke(["tingwu", "call", "--app-id", "app-1"]).exit_code == 2 + + def test_an_empty_payload_is_a_usage_error(self, invoke, tingwu): + resource = tingwu() + result = invoke(["tingwu", "call", "-m", "tingwu-turbo"]) + assert result.exit_code == 2 + assert "nothing to run" in result.stderr + assert resource.calls == [] + + def test_json_output_is_the_whole_response(self, invoke, tingwu): + tingwu() + result = invoke( + ["tingwu", "call", "-m", "tingwu-turbo", "--app-id", "app-1"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["output"]["TaskId"] == "task-1" + assert doc["request_id"] == "req-1" + + def test_the_usage_note_stays_off_stdout(self, invoke, tingwu): + tingwu() + result = invoke( + ["tingwu", "call", "-m", "tingwu-turbo", "--app-id", "app-1"], + ) + assert "usage:" in result.stderr + assert "usage" not in result.stdout + + +class TestGroupShape: + def test_bare_tingwu_shows_help(self, app, runner): + assert "Usage" in runner.invoke(app, ["tingwu"]).stdout + + def test_the_realtime_session_is_not_exposed(self, app): + """A duplex socket is not a shell shape; only ``call`` is here.""" + import typer + + group = typer.main.get_command(app).commands["tingwu"] + assert list(group.commands) == ["call"] diff --git a/tests/cli/test_tokenization_cmd.py b/tests/cli/test_tokenization_cmd.py new file mode 100644 index 00000000..ae4dc556 --- /dev/null +++ b/tests/cli/test_tokenization_cmd.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope tokenization`` -- count.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from dashscope.cli.app import build_app +from dashscope.resources.tokenization import TokenCount +from dashscope.types.common import Usage + + +def _token_count(**overrides: Any) -> TokenCount: + data: Dict[str, Any] = { + "usage": Usage(input_tokens=10, total_tokens=10), + "token_ids": [1, 2, 3], + "tokens": ["hel", "lo", "!"], + } + data.update(overrides) + return TokenCount(**data) + + +class _Tokenization: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def count(self, **kwargs): + self.calls.append({"method": "count", "kwargs": kwargs}) + result = self._results.get("count") + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def tokenization(make_client): + def install(**results: Any) -> _Tokenization: + resource = _Tokenization(**results) + make_client(tokenization=resource) + return resource + + return install + + +class TestCount: + def test_count_with_prompt(self, invoke, tokenization): + resource = tokenization(count=_token_count()) + result = invoke( + [ + "tokenization", + "count", + "-m", + "qwen-plus", + "--prompt", + "hello world", + ], + ) + assert result.exit_code == 0 + assert resource.calls[0]["kwargs"]["prompt"] == "hello world" + + def test_count_with_messages(self, invoke, tokenization): + resource = tokenization(count=_token_count()) + invoke( + [ + "tokenization", + "count", + "-m", + "qwen-plus", + "--messages", + '[{"role":"user","content":"hi"}]', + ], + ) + assert resource.calls[0]["kwargs"]["messages"] == [ + {"role": "user", "content": "hi"}, + ] + + def test_count_table_output(self, invoke, tokenization): + tokenization(count=_token_count()) + result = invoke( + ["tokenization", "count", "-m", "qwen-plus", "--prompt", "hi"], + ) + assert result.exit_code == 0 + assert "input_tokens: 10" in result.stdout + + def test_count_json_output(self, invoke, tokenization): + tokenization(count=_token_count()) + result = invoke( + ["tokenization", "count", "-m", "qwen-plus", "--prompt", "hi"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + import json + + doc = json.loads(result.stdout) + assert doc["usage"]["input_tokens"] == 10 + + def test_count_requires_input(self, invoke, tokenization): + tokenization(count=_token_count()) + result = invoke(["tokenization", "count", "-m", "qwen-plus"]) + assert result.exit_code == 2 + assert "no input" in result.stderr diff --git a/tests/cli/test_videos_cmd.py b/tests/cli/test_videos_cmd.py new file mode 100644 index 00000000..258d37cf --- /dev/null +++ b/tests/cli/test_videos_cmd.py @@ -0,0 +1,436 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``dashscope videos`` -- generate and the jobs subgroup.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import pytest + +from dashscope import errors as sdk_errors +from dashscope.cli.app import build_app +from dashscope.resources._jobs import Job, JobList + + +def _job(**overrides: Any) -> Job: + data: Dict[str, Any] = { + "task_id": "task-abc123", + "status": "SUCCEEDED", + "submitted_at": "2026-01-01 12:00:00", + "output": {"video_url": "https://example.com/cat.mp4"}, + } + data.update(overrides) + return Job(**data) + + +class _VideoJobs: + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + + def _record(self, name: str, *args, **kwargs): + self.calls.append({"method": name, "args": args, "kwargs": kwargs}) + result = self._results.get(name) + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + def create(self, **kwargs): + return self._record("create", **kwargs) + + def fetch(self, task_id, **kwargs): + return self._record("fetch", task_id, **kwargs) + + def list(self, **kwargs): + return self._record("list", **kwargs) + + def cancel(self, task_id, **kwargs): + return self._record("cancel", task_id, **kwargs) + + def wait(self, job, **kwargs): + self._record("wait", job, **kwargs) + return self._results.get("wait", job) + + +class _Videos: + """Stand-in for ``client.videos`` recording every call.""" + + def __init__(self, **results: Any) -> None: + self.calls: List[Dict[str, Any]] = [] + self._results = results + self.jobs = _VideoJobs(**results.get("jobs", {})) + + def generate(self, **kwargs): + self.calls.append({"method": "generate", "kwargs": kwargs}) + result = self._results.get("generate") + if isinstance(result, Exception): + raise result + return result() if callable(result) else result + + +@pytest.fixture +def app(clean_env): + return build_app() + + +@pytest.fixture +def invoke(app, runner): + def run(args, *, global_args=(), **kwargs): + return runner.invoke(app, [*global_args, *args], **kwargs) + + return run + + +@pytest.fixture +def videos(make_client): + def install(**results: Any) -> _Videos: + resource = _Videos(**results) + make_client(videos=resource) + return resource + + return install + + +class TestGenerate: + def test_generate_prints_the_video_url(self, invoke, videos): + videos(generate=_job()) + result = invoke( + ["videos", "generate", "-m", "wan2.2-t2v-plus", "-p", "a cat"], + ) + assert result.exit_code == 0 + assert result.stdout.strip() == "https://example.com/cat.mp4" + + def test_generate_falls_back_to_result_url(self, invoke, videos): + videos( + generate=_job( + output={"results": [{"url": "https://example.com/a.mp4"}]}, + ), + ) + result = invoke( + ["videos", "generate", "-m", "wan2.2-t2v-plus", "-p", "a cat"], + ) + assert result.stdout.strip() == "https://example.com/a.mp4" + + def test_generate_passes_the_payload(self, invoke, videos): + resource = videos(generate=_job()) + invoke( + [ + "videos", + "generate", + "-m", + "wan2.2-t2v-plus", + "-p", + "a cat", + "--duration", + "5", + "--resolution", + "1080P", + "--ratio", + "16:9", + "--seed", + "7", + "--no-watermark", + "--prompt-extend", + ], + ) + kwargs = resource.calls[0]["kwargs"] + assert kwargs["model"] == "wan2.2-t2v-plus" + assert kwargs["prompt"] == "a cat" + assert kwargs["duration"] == 5 + assert kwargs["resolution"] == "1080P" + assert kwargs["ratio"] == "16:9" + assert kwargs["seed"] == 7 + assert kwargs["watermark"] is False + assert kwargs["prompt_extend"] is True + + def test_generate_defaults_to_an_hour_of_waiting(self, invoke, videos): + resource = videos(generate=_job()) + invoke(["videos", "generate", "-m", "wan2.2-t2v-plus", "-p", "a cat"]) + assert resource.calls[0]["kwargs"]["timeout"] == 3600.0 + + def test_generate_accepts_an_image_url_alone(self, invoke, videos): + resource = videos(generate=_job()) + result = invoke( + [ + "videos", + "generate", + "-m", + "wan2.2-i2v", + "--image-url", + "https://example.com/a.png", + ], + ) + assert result.exit_code == 0 + assert resource.calls[0]["kwargs"]["image_url"] == ( + "https://example.com/a.png" + ) + + def test_generate_passes_media_through(self, invoke, videos): + resource = videos(generate=_job()) + invoke( + [ + "videos", + "generate", + "-m", + "wan2.7-i2v", + "--media", + '[{"first_frame": "a.png"}, {"last_frame": "b.png"}]', + ], + ) + assert resource.calls[0]["kwargs"]["media"] == [ + {"first_frame": "a.png"}, + {"last_frame": "b.png"}, + ] + + def test_generate_rejects_media_that_is_not_an_array(self, invoke, videos): + videos(generate=_job()) + result = invoke( + [ + "videos", + "generate", + "-m", + "wan2.7-i2v", + "--media", + '{"first_frame": "a.png"}', + ], + ) + assert result.exit_code == 2 + assert "--media" in result.stderr + + def test_generate_rejects_a_media_entry_that_is_not_an_object( + self, + invoke, + videos, + ): + videos(generate=_job()) + result = invoke( + ["videos", "generate", "-m", "wan2.7-i2v", "--media", '["a.png"]'], + ) + assert result.exit_code == 2 + assert "entry 0" in result.stderr + + def test_generate_needs_something_to_generate_from(self, invoke, videos): + resource = videos(generate=_job()) + result = invoke(["videos", "generate", "-m", "wan2.2-t2v-plus"]) + assert result.exit_code == 2 + assert "--prompt" in result.stderr + assert resource.calls == [] + + def test_generate_json_output_keeps_the_whole_job(self, invoke, videos): + videos(generate=_job()) + result = invoke( + ["videos", "generate", "-m", "wan2.2-t2v-plus", "-p", "a cat"], + global_args=["-o", "json"], + ) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["task_id"] == "task-abc123" + assert doc["output"]["video_url"] == "https://example.com/cat.mp4" + + def test_generate_reports_a_still_running_job_by_its_state( + self, + invoke, + videos, + ): + videos(generate=_job(status="RUNNING", output=None)) + result = invoke( + ["videos", "generate", "-m", "wan2.2-t2v-plus", "-p", "a cat"], + ) + assert result.exit_code == 0 + assert "RUNNING" in result.stdout + + +class TestJobs: + def test_jobs_create_submits_without_waiting(self, invoke, videos): + resource = videos( + jobs={"create": _job(status="PENDING", output=None)}, + ) + result = invoke( + [ + "videos", + "jobs", + "create", + "-m", + "wan2.2-t2v-plus", + "-p", + "a cat", + ], + ) + assert result.exit_code == 0 + assert "task-abc123" in result.stdout + assert [c["method"] for c in resource.jobs.calls] == ["create"] + + def test_jobs_create_wait_blocks(self, invoke, videos): + resource = videos( + jobs={ + "create": _job(status="PENDING", output=None), + "wait": _job(), + }, + ) + result = invoke( + [ + "videos", + "jobs", + "create", + "-m", + "wan2.2-t2v-plus", + "-p", + "a cat", + "--wait", + "--timeout", + "30", + ], + ) + methods = [c["method"] for c in resource.jobs.calls] + assert methods == ["create", "wait"] + assert resource.jobs.calls[1]["kwargs"]["timeout"] == 30.0 + assert result.stdout.strip() == "https://example.com/cat.mp4" + + def test_jobs_create_needs_something_to_generate_from( + self, + invoke, + videos, + ): + resource = videos(jobs={"create": _job()}) + result = invoke(["videos", "jobs", "create", "-m", "wan2.2-t2v-plus"]) + assert result.exit_code == 2 + assert resource.jobs.calls == [] + + def test_jobs_get(self, invoke, videos): + resource = videos(jobs={"fetch": _job()}) + invoke(["videos", "jobs", "get", "task-abc123"]) + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_list(self, invoke, videos): + videos( + jobs={ + "list": JobList( + tasks=[_job(), _job(task_id="task-def456")], + page_no=1, + page_size=10, + total=2, + ), + }, + ) + result = invoke(["videos", "jobs", "list"]) + assert result.exit_code == 0 + assert "task-abc123" in result.stdout + assert "task-def456" in result.stdout + assert "total 2" in result.stderr + + def test_jobs_list_reads_the_real_envelope_fields(self, invoke, videos): + videos(jobs={"list": JobList(tasks=[_job()], total=1, page_size=10)}) + result = invoke(["videos", "jobs", "list"]) + assert "total 1" in result.stderr + + def test_jobs_list_pagination_and_filters(self, invoke, videos): + resource = videos(jobs={"list": JobList(tasks=[], total=0)}) + invoke( + [ + "videos", + "jobs", + "list", + "--page", + "2", + "--size", + "5", + "--status", + "SUCCEEDED", + "--model", + "wan2.2-t2v-plus", + "--start-time", + "2026-01-01", + "--end-time", + "2026-02-01", + "--api-key-id", + "key-1", + "--region", + "cn-beijing", + ], + ) + assert resource.jobs.calls[0]["kwargs"] == { + "page_no": 2, + "page_size": 5, + "status": "SUCCEEDED", + "model_name": "wan2.2-t2v-plus", + "api_key_id": "key-1", + "region": "cn-beijing", + "start_time": "2026-01-01", + "end_time": "2026-02-01", + } + + def test_jobs_list_json_output_is_the_envelope(self, invoke, videos): + videos(jobs={"list": JobList(tasks=[_job()], total=1)}) + result = invoke(["videos", "jobs", "list"], global_args=["-o", "json"]) + doc = json.loads(result.stdout) + assert doc["total"] == 1 + assert len(doc["tasks"]) == 1 + + def test_jobs_cancel_with_yes(self, invoke, videos): + resource = videos(jobs={"cancel": _job(status="CANCELED")}) + result = invoke(["videos", "jobs", "cancel", "task-abc123", "--yes"]) + assert result.exit_code == 0 + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_cancel_refuses_without_a_tty(self, invoke, videos): + resource = videos(jobs={"cancel": _job(status="CANCELED")}) + result = invoke(["videos", "jobs", "cancel", "task-abc123"]) + assert result.exit_code == 2 + assert "--yes" in result.stderr + assert resource.jobs.calls == [] + + def test_jobs_wait_polls_the_id_and_nothing_else(self, invoke, videos): + resource = videos(jobs={"wait": _job()}) + assert invoke(["videos", "jobs", "wait", "task-abc123"]).exit_code == 0 + assert [c["method"] for c in resource.jobs.calls] == ["wait"] + assert resource.jobs.calls[0]["args"] == ("task-abc123",) + + def test_jobs_wait_defaults_to_an_hour(self, invoke, videos): + resource = videos(jobs={"wait": _job()}) + invoke(["videos", "jobs", "wait", "task-abc123"]) + assert resource.jobs.calls[0]["kwargs"] == {"timeout": 3600.0} + + def test_jobs_wait_prints_the_url_like_generate_does(self, invoke, videos): + """`url=$(dashscope videos jobs wait ...)` is the whole point.""" + videos(jobs={"wait": _job()}) + result = invoke(["videos", "jobs", "wait", "task-abc123"]) + assert result.stdout.strip() == "https://example.com/cat.mp4" + + def test_jobs_wait_json_output_is_the_whole_job(self, invoke, videos): + videos(jobs={"wait": _job()}) + result = invoke( + ["videos", "jobs", "wait", "task-abc123"], + global_args=["-o", "json"], + ) + doc = json.loads(result.stdout) + assert doc["task_id"] == "task-abc123" + assert doc["output"]["video_url"] == "https://example.com/cat.mp4" + + def test_jobs_wait_failure_exits_nonzero(self, invoke, videos): + videos(jobs={"wait": sdk_errors.InternalServerError("task exploded")}) + result = invoke(["videos", "jobs", "wait", "task-abc123"]) + assert result.exit_code == 1 + assert "Error [server]: task exploded" in result.stderr + + def test_jobs_wait_requires_the_id(self, invoke, videos): + resource = videos(jobs={"wait": _job()}) + assert invoke(["videos", "jobs", "wait"]).exit_code == 2 + assert resource.jobs.calls == [] + + @pytest.mark.parametrize("timeout", ["0", "nan"]) + def test_jobs_wait_rejects_a_timeout_it_could_not_honour( + self, + invoke, + videos, + timeout, + ): + """``nan`` is false against every comparison, so the poll never ends.""" + resource = videos(jobs={"wait": _job()}) + result = invoke( + ["videos", "jobs", "wait", "task-abc123", "--timeout", timeout], + ) + assert result.exit_code == 2 + assert "--timeout" in result.stderr + assert resource.jobs.calls == [] diff --git a/tests/legacy_1x/legacy/conftest.py b/tests/legacy_1x/legacy/conftest.py index 675bf3a8..c3852696 100644 --- a/tests/legacy_1x/legacy/conftest.py +++ b/tests/legacy_1x/legacy/conftest.py @@ -2,13 +2,18 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import multiprocessing -import time import pytest import dashscope 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 +from tests.legacy_1x.unit.mock_server import ( + HTTP_SERVER_PORT, + create_mock_server, + run_server, + stop_process, + wait_for_port, +) @pytest.fixture(scope="session", autouse=True) @@ -40,13 +45,16 @@ def http_server(request): print("starting legacy server!!!!!!!!!") proc = multiprocessing.Process(target=run_server) proc.start() - time.sleep(2) def stop_server(): - proc.terminate() + stop_process(proc) print("Stopping legacy server") + # Registered before the readiness poll: wait_for_port raises on + # timeout, and a finalizer that never got registered leaves a + # non-daemon child for multiprocessing's atexit join to hang on. request.addfinalizer(stop_server) + wait_for_port(HTTP_SERVER_PORT) return proc diff --git a/tests/legacy_1x/unit/conftest.py b/tests/legacy_1x/unit/conftest.py index 644dc3be..3f698b19 100644 --- a/tests/legacy_1x/unit/conftest.py +++ b/tests/legacy_1x/unit/conftest.py @@ -2,14 +2,19 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import multiprocessing -import time import pytest 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 +from tests.legacy_1x.unit.mock_server import ( + HTTP_SERVER_PORT, + stop_process, + create_mock_server, + run_server, + wait_for_port, +) @pytest.fixture(autouse=True) @@ -46,13 +51,16 @@ def http_server(request): # Create the app inside the child process to avoid pickling AppRunner proc = multiprocessing.Process(target=run_server) proc.start() - time.sleep(2) def stop_server(): - proc.terminate() + stop_process(proc) print("Stopping server") + # Registered before the readiness poll: wait_for_port raises on + # timeout, and a finalizer that never got registered leaves a + # non-daemon child for multiprocessing's atexit join to hang on. request.addfinalizer(stop_server) + wait_for_port(HTTP_SERVER_PORT) return proc diff --git a/tests/legacy_1x/unit/mock_server.py b/tests/legacy_1x/unit/mock_server.py index c6b91104..ede59aeb 100644 --- a/tests/legacy_1x/unit/mock_server.py +++ b/tests/legacy_1x/unit/mock_server.py @@ -4,6 +4,8 @@ import asyncio import json import multiprocessing +import socket +import time from hashlib import md5 from http import HTTPStatus from typing import Tuple @@ -37,6 +39,11 @@ WebSocketTaskProcessor, ) +# The ports the two mock servers bind. ``mock_request_base.py`` points the 1.x +# SDK at the same numbers, so changing one means changing the other. +MOCK_SERVER_PORT = 8089 +HTTP_SERVER_PORT = 8080 + def validate_data_inspection_parameter(request: aiohttp.request): if ( @@ -582,7 +589,7 @@ def run_server(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(runner.setup()) - site = web.TCPSite(runner, "0.0.0.0", 8080) + site = web.TCPSite(runner, "0.0.0.0", HTTP_SERVER_PORT) loop.run_until_complete(site.start()) loop.run_forever() @@ -960,13 +967,50 @@ def run_mock_server(requests, responses): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(runner.setup()) - site = web.TCPSite(runner, "0.0.0.0", 8089) + site = web.TCPSite(runner, "0.0.0.0", MOCK_SERVER_PORT) loop.run_until_complete(site.start()) loop.run_forever() print("Server started!!!!!!!!!!!") +def wait_for_port(port: int, timeout: float = 60.0) -> None: + """Block until something accepts a connection on *port*. + + The servers run in a ``multiprocessing`` child, and on macOS that means + ``spawn``: the child re-imports the whole ``dashscope`` package before it + can bind. How long that takes varies with machine load, so the fixed + sleep this replaced raced it -- under a full test run the child sometimes + bound after the sleep had already elapsed, and every test in that class + failed with ConnectionRefused. Which class lost changed from run to run. + """ + deadline = time.monotonic() + timeout + while True: + with socket.socket() as probe: + probe.settimeout(0.25) + if probe.connect_ex(("127.0.0.1", port)) == 0: + return + if time.monotonic() >= deadline: + raise RuntimeError( + f"mock server on port {port} did not start within {timeout}s", + ) + time.sleep(0.05) + + +def stop_process(proc, timeout: float = 10.0) -> None: + """Terminate a mock-server child and wait for it to actually exit. + + Joining is what makes the port reusable: every class-scoped server binds + the same one, so a child still shutting down would let the next class's + readiness poll succeed against a server that is on its way out. + """ + proc.terminate() + proc.join(timeout) + if proc.is_alive(): + proc.kill() + proc.join(timeout) + + def create_mock_server(request): mock_web_server = MockServer() proc = multiprocessing.Process( # pylint: disable=redefined-outer-name @@ -977,15 +1021,16 @@ def create_mock_server(request): ), ) proc.start() - import time - - time.sleep(2) def stop_server(): - proc.terminate() + stop_process(proc) print("Mock server stopped") + # Registered before the readiness poll, not after: wait_for_port raises + # on timeout, and a finalizer that was never registered leaves a + # non-daemon child behind for multiprocessing's atexit join to hang on. request.addfinalizer(stop_server) + wait_for_port(MOCK_SERVER_PORT) return mock_web_server diff --git a/tests/legacy_1x/unit/test_cli_agent_route.py b/tests/legacy_1x/unit/test_cli_agent_route.py index e5200335..49ec7270 100644 --- a/tests/legacy_1x/unit/test_cli_agent_route.py +++ b/tests/legacy_1x/unit/test_cli_agent_route.py @@ -23,6 +23,7 @@ import pytest import dashscope +from dashscope.acli import expert from dashscope.legacy_1x import cli @@ -75,7 +76,7 @@ class Recorder: def print(self, *args, **kwargs): lines.append(" ".join(str(a) for a in args)) - monkeypatch.setattr(cli, "err_console", Recorder()) + monkeypatch.setattr(expert, "err_console", Recorder()) return lines @@ -85,20 +86,20 @@ def test_removes_managed_files(self, fake_config_dir): skills.mkdir() managed_skill = skills / "diagnose.md" managed_skill.write_text( - f"skill body\n\n{cli._MD_MARKER}\n", + f"skill body\n\n{expert._MD_MARKER}\n", encoding="utf-8", ) refs = fake_config_dir / "references" refs.mkdir() managed_ref = refs / "python-sdk.md" managed_ref.write_text( - f"old index\n\n{cli._MD_MARKER}\n", + f"old index\n\n{expert._MD_MARKER}\n", encoding="utf-8", ) build = fake_config_dir / "build_sdk_index.py" - build.write_text(f"# script\n{cli._PY_MARKER}\n", encoding="utf-8") + build.write_text(f"# script\n{expert._PY_MARKER}\n", encoding="utf-8") - cli._cleanup_legacy_expert_sync() + expert._cleanup_legacy_expert_sync() assert not managed_skill.exists() assert not managed_ref.exists() @@ -116,17 +117,29 @@ def test_user_files_untouched(self, fake_config_dir): build = fake_config_dir / "build_sdk_index.py" build.write_text("# my own script\n", encoding="utf-8") - cli._cleanup_legacy_expert_sync() + expert._cleanup_legacy_expert_sync() assert user_skill.read_text(encoding="utf-8") == "my own skill" assert user_ref.read_text(encoding="utf-8") == "my own notes" assert build.read_text(encoding="utf-8") == "# my own script\n" def test_missing_dirs_are_noop(self, fake_config_dir): - cli._cleanup_legacy_expert_sync() # no skills/references dirs at all + expert._cleanup_legacy_expert_sync() # no skills/references dirs at all class TestRouteToExpert: + def test_legacy_entry_delegates_to_the_shared_route(self, monkeypatch): + calls = [] + monkeypatch.setattr( + expert, + "_route_to_expert", + lambda command, tui=None: calls.append((command, tui)), + ) + + cli._route_to_expert("hello", tui=False) + + assert calls == [("hello", False)] + def test_interactive_run_offers_example_and_runs_agent( self, fake_config_dir, @@ -138,13 +151,13 @@ def test_interactive_run_offers_example_and_runs_agent( _make_tty(monkeypatch) offered: list = [] monkeypatch.setattr( - cli, + expert, "_maybe_offer_example_download", lambda: offered.append(1), ) monkeypatch.setattr(dashscope, "api_key", "sk-test") - cli._route_to_expert(None) + expert._route_to_expert(None) assert offered == [1] assert captured_run["app_name"] == "DashScope SDK Expert" @@ -160,13 +173,13 @@ def test_one_shot_skips_offer_and_passes_command( ): called: list = [] monkeypatch.setattr( - cli, + expert, "_maybe_offer_example_download", lambda: called.append(1), ) monkeypatch.setattr(dashscope, "api_key", None) - cli._route_to_expert("Generation.call 怎么用?") + expert._route_to_expert("Generation.call 怎么用?") assert not called assert captured_run["command"] == "Generation.call 怎么用?" @@ -178,13 +191,17 @@ def test_cleanup_runs_before_agent( captured_run, monkeypatch, ): - monkeypatch.setattr(cli, "_maybe_offer_example_download", lambda: None) + monkeypatch.setattr( + expert, + "_maybe_offer_example_download", + lambda: None, + ) skills = fake_config_dir / "skills" skills.mkdir() managed = skills / "diagnose.md" - managed.write_text(f"x\n\n{cli._MD_MARKER}\n", encoding="utf-8") + managed.write_text(f"x\n\n{expert._MD_MARKER}\n", encoding="utf-8") - cli._route_to_expert("hi") + expert._route_to_expert("hi") assert not managed.exists() assert captured_run["command"] == "hi" @@ -195,10 +212,14 @@ def test_piped_stdin_becomes_a_one_shot_prompt( captured_run, monkeypatch, ): - monkeypatch.setattr(cli, "_maybe_offer_example_download", lambda: None) + monkeypatch.setattr( + expert, + "_maybe_offer_example_download", + lambda: None, + ) monkeypatch.setattr(sys, "stdin", NonTtyStdin(" 怎么用 Generation \n")) - cli._route_to_expert(None) + expert._route_to_expert(None) assert captured_run["command"] == "怎么用 Generation" @@ -218,7 +239,7 @@ def test_empty_pipe_names_the_expert_keyword( monkeypatch.setattr(sys, "stdin", NonTtyStdin(" \n")) with pytest.raises(SystemExit) as exc: - cli._route_to_expert(None) + expert._route_to_expert(None) assert exc.value.code == 2 assert not captured_run @@ -235,7 +256,7 @@ def test_unreadable_stdin_refuses_the_same_way( monkeypatch.setattr(sys, "stdin", NonTtyStdin(None)) with pytest.raises(SystemExit) as exc: - cli._route_to_expert(None) + expert._route_to_expert(None) assert exc.value.code == 2 assert 'dashscope expert "your question"' in "\n".join(captured_err) @@ -257,7 +278,7 @@ def test_accept_downloads_example( calls.append, ) - cli._maybe_offer_example_download() + expert._maybe_offer_example_download() assert calls == [["download", "dashscope-sdk-expert"]] assert not ( @@ -279,7 +300,7 @@ def test_decline_writes_marker_and_skips_download( calls.append, ) - cli._maybe_offer_example_download() + expert._maybe_offer_example_download() assert not calls marker = tmp_path / ".acli" / ".dashscope-example-declined" @@ -303,7 +324,7 @@ def fail_input(prompt=""): raise AssertionError("should not prompt") monkeypatch.setattr("builtins.input", fail_input) - cli._maybe_offer_example_download() + expert._maybe_offer_example_download() def test_existing_workspace_acli_skips_offer( self, @@ -319,7 +340,7 @@ def fail_input(prompt=""): raise AssertionError("should not prompt") monkeypatch.setattr("builtins.input", fail_input) - cli._maybe_offer_example_download() + expert._maybe_offer_example_download() def test_non_tty_skips_offer( self, @@ -334,7 +355,7 @@ def fail_input(prompt=""): raise AssertionError("should not prompt") monkeypatch.setattr("builtins.input", fail_input) - cli._maybe_offer_example_download() + expert._maybe_offer_example_download() class TestMainRouting: diff --git a/tests/legacy_1x/unit/test_layer_decoupling.py b/tests/legacy_1x/unit/test_layer_decoupling.py index c884a1f6..c4784c37 100644 --- a/tests/legacy_1x/unit/test_layer_decoupling.py +++ b/tests/legacy_1x/unit/test_layer_decoupling.py @@ -269,7 +269,7 @@ def test_one_version_reaches_every_identity_string(monkeypatch): 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}" + assert http.default_user_agent() == f"dashscope-sdk-python/{version}" studio = importlib.import_module("dashscope.agentstudio.client") agent = studio._user_agent("https://agentstudio.invalid") diff --git a/tests/legacy_1x/unit/test_mock_server.py b/tests/legacy_1x/unit/test_mock_server.py new file mode 100644 index 00000000..9520c9b5 --- /dev/null +++ b/tests/legacy_1x/unit/test_mock_server.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The mock-server fixtures must never leave a child process behind. + +``create_mock_server`` starts a non-daemon ``multiprocessing`` child, and +``multiprocessing``'s atexit handler joins every one of them. A child +nobody terminated therefore hangs the whole pytest run at exit -- after +the results have been printed, so it looks like a stuck terminal rather +than a failure. The session-scoped ``http_server`` fixtures in the two +conftest files start theirs the same way. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from tests.legacy_1x.unit import mock_server as servers + + +class _FakeProcess: + """Stands in for ``multiprocessing.Process`` so nothing is spawned.""" + + def __init__(self) -> None: + self.started = False + self.terminated = False + + def start(self) -> None: + self.started = True + + def is_alive(self) -> bool: + return self.started and not self.terminated + + def terminate(self) -> None: + self.terminated = True + + def kill(self) -> None: + self.terminated = True + + def join(self, timeout: float = None) -> None: + return None + + +@pytest.fixture +def harness(monkeypatch): + """Fake out the process and the readiness poll, recording the order.""" + spawned = [] + events = [] + finalizers = [] + + def fake_process(target=None, args=()): + process = _FakeProcess() + spawned.append(process) + return process + + def addfinalizer(function): + finalizers.append(function) + events.append("addfinalizer") + + monkeypatch.setattr(servers.multiprocessing, "Process", fake_process) + monkeypatch.setattr( + servers, + "stop_process", + lambda proc, timeout=10.0: ( + events.append("stop_process"), + proc.terminate(), + ), + ) + return SimpleNamespace( + spawned=spawned, + events=events, + finalizers=finalizers, + request=SimpleNamespace(addfinalizer=addfinalizer), + never_ready=lambda port, timeout=60.0: ( + events.append("wait_for_port"), + (_ for _ in ()).throw(RuntimeError("never came up")), + ), + ready=lambda port, timeout=60.0: events.append("wait_for_port"), + monkeypatch=monkeypatch, + ) + + +class TestCreateMockServer: + def test_cleanup_is_registered_before_the_readiness_poll(self, harness): + """The bug: ``wait_for_port`` raised, so ``addfinalizer`` never ran + and the child was left for the atexit join to hang on.""" + harness.monkeypatch.setattr( + servers, + "wait_for_port", + harness.never_ready, + ) + with pytest.raises(RuntimeError): + servers.create_mock_server(harness.request) + + assert harness.spawned[0].started + assert harness.events.index("addfinalizer") < harness.events.index( + "wait_for_port", + ) + harness.finalizers[0]() + assert "stop_process" in harness.events + assert not harness.spawned[0].is_alive() + + def test_a_ready_server_is_returned_with_one_finalizer(self, harness): + harness.monkeypatch.setattr(servers, "wait_for_port", harness.ready) + server = servers.create_mock_server(harness.request) + assert hasattr(server, "requests") + assert len(harness.finalizers) == 1 + harness.finalizers[0]() + assert not harness.spawned[0].is_alive() diff --git a/tests/test_chat.py b/tests/test_chat.py index 359d641a..33865265 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1654,3 +1654,27 @@ def handler(request): ] assert resp.output_text == "ok" await client.aclose() + + +@pytest.mark.parametrize("merge", [False, True]) +@pytest.mark.parametrize("server_cumulative", [False, True]) +def test_async_stream_cumulative_matches_sync(merge, server_cumulative): + # ``cumulative`` drives terminal rendering on both stream types, so the + # async twin must report the same value the sync one does. + from dashscope.resources.chat.completions import ( + AsyncChatStream, + ChatStream, + ) + + sync = ChatStream( + None, + merge_incremental=merge, + server_cumulative=server_cumulative, + ) + async_stream = AsyncChatStream( + None, + merge_incremental=merge, + server_cumulative=server_cumulative, + ) + assert async_stream.cumulative == sync.cumulative + assert async_stream.cumulative == (merge or server_cumulative) diff --git a/tests/test_legacy_import_aliases.py b/tests/test_legacy_import_aliases.py index 9b69e605..7832bca7 100644 --- a/tests/test_legacy_import_aliases.py +++ b/tests/test_legacy_import_aliases.py @@ -43,7 +43,7 @@ def test_all_legacy_module_aliases_in_clean_process(): import dashscope packages = ( - 'aigc', 'api_entities', 'app', 'assistants', 'audio', 'cli', + 'aigc', 'api_entities', 'app', 'assistants', 'audio', 'common', 'embeddings', 'io', 'multimodal', 'nlp', 'protocol', 'rerank', 'threads', 'tokenizers', 'utils', ) @@ -209,4 +209,4 @@ def test_plain_import_preserves_cli_and_assistants_laziness(): def test_historical_cli_module_entrypoint(): - _run_python("-m", "dashscope.cli", "--help") + _run_python("-m", "dashscope.legacy_1x.cli", "--help") diff --git a/tests/test_oss_upload.py b/tests/test_oss_upload.py index 49eed1ec..6b42d205 100644 --- a/tests/test_oss_upload.py +++ b/tests/test_oss_upload.py @@ -85,7 +85,14 @@ def handler(request): text="AccessDenied", ) - def patched(upload_host, form, file_name, file_path, transport=None): + def patched( + upload_host, + form, + file_name, + file_path, + transport=None, + user_agent=None, + ): seen["form"] = form seen["file_name"] = file_name return real_post( @@ -94,6 +101,7 @@ def patched(upload_host, form, file_name, file_path, transport=None): file_name, file_path, transport=httpx.MockTransport(handler), + user_agent=user_agent, ) monkeypatch.setattr(_oss_upload, "_post_to_oss", patched) @@ -292,7 +300,27 @@ def test_oss_post_carries_no_dashscope_credential(tmp_path, monkeypatch): 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/") + assert user_agent.startswith("dashscope-sdk-python/") + + +def test_oss_post_keeps_the_configured_user_agent_suffix( + tmp_path, + monkeypatch, +): + # The CLI appends its own suffix to the client identity; the OSS hop + # must not drop it back to the bare SDK default. + seen = _capture_oss(monkeypatch) + calls = [] + client = _make_client( + _policy_handler(calls), + user_agent_suffix="dashscope-cli/2.0", + ) + + _oss_upload.upload_local_file(client, "qwen-vl-max", _png(tmp_path)) + + user_agent = seen["headers"].get("User-Agent", "") + assert user_agent.startswith("dashscope-sdk-python/") + assert "dashscope-cli/2.0" in user_agent def test_oss_post_failure_maps_to_a_dashscope_error(tmp_path, monkeypatch): diff --git a/tests/test_user_agent_suffix.py b/tests/test_user_agent_suffix.py new file mode 100644 index 00000000..1d386509 --- /dev/null +++ b/tests/test_user_agent_suffix.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``Client(user_agent_suffix=...)`` reaches HTTP and websocket alike. + +The suffix is how the service attributes traffic to a surface (the +bundled CLI tags itself ``dashscope-cli``), so a channel that silently +drops it would make that surface's volume unmeasurable. These tests pin +both channels plus the ``with_options`` inheritance, and in particular +the pool-sharing case: a derived client reusing its parent's httpx pool +must still send its *own* agent. +""" + +# pylint: disable=protected-access,redefined-outer-name + +from __future__ import annotations + +import httpx +import pytest + +from dashscope.client import AsyncClient, Client +from dashscope.resources.audio.asr import Recognition +from dashscope.resources.audio.tts import SynthesisSession +from dashscope.resources.tingwu import TingWuRealtime +from dashscope.transport.http import default_user_agent +from dashscope.transport.ws import with_client_user_agent +from dashscope.version import __version__ + +BASE_URL = "https://api.test.local" +BASE_AGENT = f"dashscope-sdk-python/{__version__}" + + +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 _ok(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": []}, request=request) + + +class TestDefaultUserAgent: + def test_without_suffix_is_unchanged(self): + """The no-argument form must stay byte-identical for 1.x parity.""" + assert default_user_agent() == BASE_AGENT + + def test_suffix_appends_as_space_separated_token(self): + assert default_user_agent("dashscope-cli") == ( + f"{BASE_AGENT} dashscope-cli" + ) + + def test_empty_suffix_is_ignored(self): + assert default_user_agent("") == BASE_AGENT + + +class TestHttpChannel: + def test_request_carries_suffixed_agent(self): + seen = [] + + def handler(request): + seen.append(request.headers.get("user-agent")) + return _ok(request) + + client = _make_client(handler, user_agent_suffix="dashscope-cli") + try: + client.transport.request("GET", "/models") + finally: + client.close() + assert seen == [f"{BASE_AGENT} dashscope-cli"] + + def test_request_without_suffix_carries_base_agent(self): + seen = [] + + def handler(request): + seen.append(request.headers.get("user-agent")) + return _ok(request) + + client = _make_client(handler) + try: + client.transport.request("GET", "/models") + finally: + client.close() + assert seen == [BASE_AGENT] + + def test_transport_exposes_its_agent(self): + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + assert client.transport.user_agent == ( + f"{BASE_AGENT} dashscope-cli" + ) + assert client.user_agent_suffix == "dashscope-cli" + finally: + client.close() + + async def test_async_client_parity(self): + seen = [] + + def handler(request): + seen.append(request.headers.get("user-agent")) + return _ok(request) + + client = _make_async_client(handler, user_agent_suffix="dashscope-cli") + try: + await client.transport.request("GET", "/models") + assert client.user_agent_suffix == "dashscope-cli" + assert client.transport.user_agent == ( + f"{BASE_AGENT} dashscope-cli" + ) + finally: + await client.aclose() + assert seen == [f"{BASE_AGENT} dashscope-cli"] + + +class TestWithOptions: + def test_suffix_is_inherited(self): + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + derived = client.with_options() + assert derived.user_agent_suffix == "dashscope-cli" + assert derived.transport.user_agent == ( + f"{BASE_AGENT} dashscope-cli" + ) + finally: + client.close() + + def test_suffix_can_be_overridden(self): + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + derived = client.with_options(user_agent_suffix="notebook") + assert derived.user_agent_suffix == "notebook" + assert derived.transport.user_agent == f"{BASE_AGENT} notebook" + finally: + client.close() + + def test_override_can_clear_the_suffix(self): + """An explicit empty suffix drops the tag rather than inheriting.""" + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + derived = client.with_options(user_agent_suffix="") + assert derived.transport.user_agent == BASE_AGENT + finally: + client.close() + + def test_shared_pool_still_sends_the_derived_agent(self): + """Regression pin: the agent is applied per request, not per pool. + + ``with_options`` shares the parent's httpx client when no + connection-level option changed, and the httpx client carries its + own default headers. A naive reader may conclude the derived + client therefore sends the parent's agent and "fix" it by + splitting pools. It does not: ``_request_headers`` re-applies the + transport's defaults on every request, overriding the pool's + case-insensitively. Splitting pools here would cost connection + reuse for nothing. + """ + seen = [] + + def handler(request): + seen.append(request.headers.get("user-agent")) + return _ok(request) + + client = _make_client(handler, user_agent_suffix="dashscope-cli") + try: + derived = client.with_options(user_agent_suffix="notebook") + assert ( + derived.transport.http_client is client.transport.http_client + ) + client.transport.request("GET", "/models") + derived.transport.request("GET", "/models") + finally: + client.close() + assert seen == [ + f"{BASE_AGENT} dashscope-cli", + f"{BASE_AGENT} notebook", + ] + + async def test_async_suffix_is_inherited(self): + client = _make_async_client(_ok, user_agent_suffix="dashscope-cli") + try: + derived = client.with_options() + assert derived.user_agent_suffix == "dashscope-cli" + derived = client.with_options(user_agent_suffix="notebook") + assert derived.transport.user_agent == f"{BASE_AGENT} notebook" + finally: + await client.aclose() + + +class TestWithClientUserAgent: + class _Stub: + def __init__(self, agent): + self.transport = type("T", (), {"user_agent": agent})() + + def test_seeds_agent_when_absent(self): + client = self._Stub(f"{BASE_AGENT} dashscope-cli") + assert with_client_user_agent(client, None) == { + "user-agent": f"{BASE_AGENT} dashscope-cli", + } + + def test_caller_header_wins(self): + client = self._Stub(f"{BASE_AGENT} dashscope-cli") + assert with_client_user_agent(client, {"user-agent": "mine"}) == { + "user-agent": "mine", + } + + def test_caller_header_wins_under_any_casing(self): + """Both spellings must not survive: that would send the header twice.""" + client = self._Stub(f"{BASE_AGENT} dashscope-cli") + merged = with_client_user_agent(client, {"User-Agent": "mine"}) + assert merged == {"User-Agent": "mine"} + assert [k.lower() for k in merged].count("user-agent") == 1 + + def test_other_headers_are_preserved(self): + client = self._Stub(BASE_AGENT) + merged = with_client_user_agent(client, {"X-Trace": "1"}) + assert merged == {"X-Trace": "1", "user-agent": BASE_AGENT} + + def test_client_without_transport_is_passed_through(self): + assert with_client_user_agent(object(), {"X-Trace": "1"}) == { + "X-Trace": "1", + } + + def test_input_mapping_is_not_mutated(self): + client = self._Stub(BASE_AGENT) + original = {"X-Trace": "1"} + with_client_user_agent(client, original) + assert original == {"X-Trace": "1"} + + +class TestWebsocketChannel: + def test_ws_connection_seeds_the_client_agent(self, monkeypatch): + captured = {} + + def fake_connection(**kwargs): + captured.update(kwargs) + return object() + + monkeypatch.setattr("dashscope.client.WsConnection", fake_connection) + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + client.ws_connection(url="wss://ws.test.local/inference") + finally: + client.close() + assert captured["headers"]["user-agent"] == ( + f"{BASE_AGENT} dashscope-cli" + ) + + def test_ws_connection_caller_header_wins(self, monkeypatch): + captured = {} + + def fake_connection(**kwargs): + captured.update(kwargs) + return object() + + monkeypatch.setattr("dashscope.client.WsConnection", fake_connection) + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + client.ws_connection( + url="wss://ws.test.local/inference", + headers={"User-Agent": "mine"}, + ) + finally: + client.close() + headers = captured["headers"] + assert headers == {"User-Agent": "mine"} + + @pytest.mark.parametrize( + "build", + [ + lambda client: SynthesisSession(client, model="cosyvoice-v1"), + lambda client: Recognition(client, model="paraformer-realtime"), + lambda client: TingWuRealtime( + client, + model="tingwu-realtime", + app_id="app-1", + ), + ], + ids=["tts", "asr", "tingwu"], + ) + def test_audio_sessions_seed_the_client_agent(self, build): + """Each realtime session opens its own socket off the transport.""" + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + session = build(client) + assert session._session_kwargs["headers"]["user-agent"] == ( + f"{BASE_AGENT} dashscope-cli" + ) + finally: + client.close() + + @pytest.mark.parametrize( + "build", + [ + lambda client: SynthesisSession( + client, + model="cosyvoice-v1", + headers={"user-agent": "mine"}, + ), + lambda client: Recognition( + client, + model="paraformer-realtime", + headers={"user-agent": "mine"}, + ), + ], + ids=["tts", "asr"], + ) + def test_audio_sessions_let_caller_headers_win(self, build): + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + session = build(client) + assert session._session_kwargs["headers"] == { + "user-agent": "mine", + } + finally: + client.close() + + def test_realtime_session_inherits_the_client_agent(self): + from dashscope.resources.audio.realtime import RealtimeSession + + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + session = RealtimeSession( + client, + model="qwen-omni-turbo-realtime", + ws_url="wss://ws.test.local/realtime", + ) + assert session._headers["user-agent"] == ( + f"{BASE_AGENT} dashscope-cli" + ) + finally: + client.close() + + def test_realtime_session_caller_header_wins(self): + from dashscope.resources.audio.realtime import RealtimeSession + + client = _make_client(_ok, user_agent_suffix="dashscope-cli") + try: + session = RealtimeSession( + client, + model="qwen-omni-turbo-realtime", + ws_url="wss://ws.test.local/realtime", + headers={"User-Agent": "mine"}, + ) + agents = [ + value + for key, value in session._headers.items() + if key.lower() == "user-agent" + ] + assert agents == ["mine"] + finally: + client.close()