From 279985a424e6bc42cdf4cbc6e8168c3ce0cd91bc Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Tue, 3 Nov 2026 15:10:52 +0800 Subject: [PATCH 01/13] feat(cli): Add CLI v2 core architecture and chat commands - Add CLI v2 core architecture and root Typer app configuration - Implement chat create command with support for streaming and non-streaming text generation - Add python -m dashscope.cli2 module entry point - Provide CI testing utility examples and local mock gateway - Optimize command module structure with lazy SDK imports to improve help text loading speed - Fix missing client user-agent in ASR module WebSocket requests --- dashscope/cli2/__init__.py | 13 + dashscope/cli2/__main__.py | 12 + dashscope/cli2/app.py | 198 ++++++ dashscope/cli2/commands/__init__.py | 15 + dashscope/cli2/commands/chat.py | 188 ++++++ dashscope/cli2/commands/config.py | 84 +++ dashscope/cli2/commands/ft.py | 272 ++++++++ dashscope/cli2/config_store.py | 465 ++++++++++++++ dashscope/cli2/confirm.py | 45 ++ dashscope/cli2/context.py | 210 ++++++ dashscope/cli2/errors.py | 473 ++++++++++++++ dashscope/cli2/input.py | 238 +++++++ dashscope/cli2/main.py | 233 +++++++ dashscope/cli2/output.py | 255 ++++++++ dashscope/cli2/progress.py | 92 +++ dashscope/cli2/sdk.py | 33 + dashscope/client.py | 44 +- dashscope/resources/audio/asr.py | 9 +- dashscope/resources/audio/realtime.py | 14 +- dashscope/resources/audio/tts.py | 8 +- dashscope/resources/tingwu.py | 7 +- dashscope/transport/http.py | 26 +- dashscope/transport/ws.py | 20 + examples/05_cli/README.md | 350 ++++++++++ examples/05_cli/README_zh.md | 350 ++++++++++ examples/05_cli/cli_ci_harness.py | 779 +++++++++++++++++++++++ examples/05_cli/cli_output_and_errors.py | 607 ++++++++++++++++++ examples/README.md | 20 +- examples/README_zh.md | 20 +- setup.py | 2 +- tests/cli2/__init__.py | 2 + tests/cli2/conftest.py | 124 ++++ tests/cli2/test_app.py | 293 +++++++++ tests/cli2/test_chat_cmd.py | 576 +++++++++++++++++ tests/cli2/test_config_store.py | 311 +++++++++ tests/cli2/test_confirm.py | 111 ++++ tests/cli2/test_context.py | 224 +++++++ tests/cli2/test_dispatch.py | 272 ++++++++ tests/cli2/test_errors.py | 489 ++++++++++++++ tests/cli2/test_ft_cmd.py | 535 ++++++++++++++++ tests/cli2/test_input.py | 248 ++++++++ tests/cli2/test_output.py | 248 ++++++++ tests/cli2/test_package_layout.py | 120 ++++ tests/cli2/test_progress.py | 204 ++++++ tests/legacy_1x/legacy/conftest.py | 13 +- tests/legacy_1x/unit/conftest.py | 13 +- tests/legacy_1x/unit/mock_server.py | 54 +- tests/test_user_agent_suffix.py | 358 +++++++++++ 48 files changed, 9246 insertions(+), 31 deletions(-) create mode 100644 dashscope/cli2/__init__.py create mode 100644 dashscope/cli2/__main__.py create mode 100644 dashscope/cli2/app.py create mode 100644 dashscope/cli2/commands/__init__.py create mode 100644 dashscope/cli2/commands/chat.py create mode 100644 dashscope/cli2/commands/config.py create mode 100644 dashscope/cli2/commands/ft.py create mode 100644 dashscope/cli2/config_store.py create mode 100644 dashscope/cli2/confirm.py create mode 100644 dashscope/cli2/context.py create mode 100644 dashscope/cli2/errors.py create mode 100644 dashscope/cli2/input.py create mode 100644 dashscope/cli2/main.py create mode 100644 dashscope/cli2/output.py create mode 100644 dashscope/cli2/progress.py create mode 100644 dashscope/cli2/sdk.py create mode 100644 examples/05_cli/README.md create mode 100644 examples/05_cli/README_zh.md create mode 100644 examples/05_cli/cli_ci_harness.py create mode 100644 examples/05_cli/cli_output_and_errors.py create mode 100644 tests/cli2/__init__.py create mode 100644 tests/cli2/conftest.py create mode 100644 tests/cli2/test_app.py create mode 100644 tests/cli2/test_chat_cmd.py create mode 100644 tests/cli2/test_config_store.py create mode 100644 tests/cli2/test_confirm.py create mode 100644 tests/cli2/test_context.py create mode 100644 tests/cli2/test_dispatch.py create mode 100644 tests/cli2/test_errors.py create mode 100644 tests/cli2/test_ft_cmd.py create mode 100644 tests/cli2/test_input.py create mode 100644 tests/cli2/test_output.py create mode 100644 tests/cli2/test_package_layout.py create mode 100644 tests/cli2/test_progress.py create mode 100644 tests/test_user_agent_suffix.py diff --git a/dashscope/cli2/__init__.py b/dashscope/cli2/__init__.py new file mode 100644 index 00000000..c9c5ccbb --- /dev/null +++ b/dashscope/cli2/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""The dashscope CLI (v2). + +Deliberately empty. ``import dashscope.cli2`` 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.cli2.main` +is a submodule, and binding the same name on the package would make +``from dashscope.cli2 import main`` resolve to a function or a module +depending on import order. The console script names the module directly +(``dashscope.cli2.main:main``). +""" diff --git a/dashscope/cli2/__main__.py b/dashscope/cli2/__main__.py new file mode 100644 index 00000000..63292f15 --- /dev/null +++ b/dashscope/cli2/__main__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``python -m dashscope.cli2``. + +The 1.x tree owns ``python -m dashscope.cli`` and must keep it, so the v2 +entry point is spelled with the same ``2`` the package uses. +""" + +from dashscope.cli2.main import main + +if __name__ == "__main__": + main() diff --git a/dashscope/cli2/app.py b/dashscope/cli2/app.py new file mode 100644 index 00000000..54c3012e --- /dev/null +++ b/dashscope/cli2/app.py @@ -0,0 +1,198 @@ +# -*- 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 must precede the +subcommand:: + + dashscope -k sk-xxx --output json chat create -m qwen-plus --prompt hi + +The 1.x tree tolerated a trailing ``-k`` by surgically rewriting argv. +That tolerance is deliberately *not* reimplemented here: it only ever +applied to the 1.x parser, and ``main.py`` still hands those invocations +to it untouched. +""" + +from __future__ import annotations + +from typing import List, Optional + +import typer + +from dashscope.cli2.context import CliContext, GlobalOptions +from dashscope.cli2.errors import ( + NO_DEPRECATION_ENV, + ErrorHandlingGroup, + usage_error, +) +from dashscope.cli2.main import LEGACY_DEPRECATED, LEGACY_UNMIGRATED +from dashscope.cli2.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: + """Static list of the 1.x commands that still work through this entry. + + They are delegated to rather than mounted, so Typer cannot enumerate + them -- and mounting them would mean importing all eighteen 1.x + command modules just to render ``--help``. Generated from the same + constants the router uses, so the list cannot drift from the routing. + + Written as prose because Rich re-flows the epilog to the terminal + width; hand-placed line breaks would not survive. + """ + deprecated = ", ".join(sorted(LEGACY_DEPRECATED)) + unmigrated = ", ".join(sorted(LEGACY_UNMIGRATED)) + return ( + "Legacy 1.x commands still run through this entry point. " + f"Deprecated: {deprecated}. " + f"Awaiting a v2 replacement: {unmigrated}. " + "`expert` forwards to the bundled agent, and a bare `dashscope` " + "opens it interactively. " + f"Set {NO_DEPRECATION_ENV}=1 to silence the deprecation notice." + ) + + +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.cli2.commands import chat as chat_command + from dashscope.cli2.commands import config as config_command + from dashscope.cli2.commands import ft as ft_command + + 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(config_command.build_app(), name="config") + + return app diff --git a/dashscope/cli2/commands/__init__.py b/dashscope/cli2/commands/__init__.py new file mode 100644 index 00000000..f4d8f6f8 --- /dev/null +++ b/dashscope/cli2/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/cli2/commands/chat.py b/dashscope/cli2/commands/chat.py new file mode 100644 index 00000000..554a6e43 --- /dev/null +++ b/dashscope/cli2/commands/chat.py @@ -0,0 +1,188 @@ +# -*- 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.cli2.errors import usage_error +from dashscope.cli2.input import ( + parse_json_arg, + parse_json_object_arg, + read_prompt_or_messages, +) + +_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 piped +stdin -- in that order of preference. 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 _resolve_model(ctx: typer.Context, model: Optional[str]) -> str: + resolved = model or ctx.obj.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 + + +def _parse_tools(raw: Optional[str]) -> Optional[List[Dict[str, Any]]]: + 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 _render_completion(renderer, result) -> None: + """Non-streaming output: 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_stream(renderer, stream) -> None: + """Streaming output. + + 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: + for text in stream.text_stream: + renderer.write(text) + renderer.echo() + return + for chunk in stream: + renderer.print_stream_chunk(chunk) + + +def build_app() -> typer.Typer: + app = typer.Typer(help=_HELP, no_args_is_help=True) + + @app.command("create") + def create( + 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"), + 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 chat completion request.""" + obj = ctx.obj + renderer = obj.renderer + conversation = read_prompt_or_messages( + prompt=prompt, + messages=messages, + system=system, + ) + kwargs: Dict[str, Any] = { + "model": _resolve_model(ctx, model), + "messages": conversation, + "tools": _parse_tools(tools), + "temperature": temperature, + "top_p": top_p, + "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", + ), + } + # 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_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/cli2/commands/config.py b/dashscope/cli2/commands/config.py new file mode 100644 index 00000000..fb563c86 --- /dev/null +++ b/dashscope/cli2/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.cli2 import config_store +from dashscope.cli2.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/cli2/commands/ft.py b/dashscope/cli2/commands/ft.py new file mode 100644 index 00000000..9b95b277 --- /dev/null +++ b/dashscope/cli2/commands/ft.py @@ -0,0 +1,272 @@ +# -*- 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 + +import json +from typing import Any, Dict, List, Optional + +import typer + +from dashscope.cli2.confirm import confirm_or_die +from dashscope.cli2.errors import usage_error +from dashscope.cli2.input import parse_json_object_arg +from dashscope.cli2.progress import 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_event(renderer, event) -> None: + """One SSE frame. + + No ``except`` here or in the caller: ``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 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 + 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, + ) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(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) + if renderer.table_mode: + renderer.print_object(job, columns=_JOB_COLUMNS) + else: + renderer.print_object(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_event(renderer, event) + + @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/cli2/config_store.py b/dashscope/cli2/config_store.py new file mode 100644 index 00000000..30eaa644 --- /dev/null +++ b/dashscope/cli2/config_store.py @@ -0,0 +1,465 @@ +# -*- 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. + +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 +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from dashscope.cli2.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.cli2.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_connections", + "http_max_keepalive_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 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 + + +def _find_key_line( + lines: List[str], + section: str, + key: str, +) -> Optional[int]: + """Index of ``key =`` inside *section*, or None.""" + current = "" + for index, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + current = stripped[1:-1].strip() + continue + if current != section: + continue + if stripped.startswith("#") or "=" not in stripped: + continue + name = stripped.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, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + name = stripped[1:-1].strip() + 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, line in enumerate(lines) + if line.strip().startswith("[") and line.strip().endswith("]") + ), + 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) + + _write(target, lines, original) + return bare, value + + +def _write(target: Path, lines: List[str], original: Optional[str]) -> None: + content = "\n".join(lines) + if content and not content.endswith("\n"): + content += "\n" + try: + 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 + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(target.name + ".tmp") + try: + temporary.write_text(content, encoding="utf-8") + if original is not None and target.is_file(): + os.chmod(temporary, stat.S_IMODE(target.stat().st_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/cli2/confirm.py b/dashscope/cli2/confirm.py new file mode 100644 index 00000000..c44e1465 --- /dev/null +++ b/dashscope/cli2/confirm.py @@ -0,0 +1,45 @@ +# -*- 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.cli2.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}", + ) + if not typer.confirm(prompt, default=False): + raise usage_error("aborted by user") diff --git a/dashscope/cli2/context.py b/dashscope/cli2/context.py new file mode 100644 index 00000000..fbb324ef --- /dev/null +++ b/dashscope/cli2/context.py @@ -0,0 +1,210 @@ +# -*- 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.cli2 import config_store, sdk +from dashscope.cli2.errors import usage_error +from dashscope.cli2.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 + + # -- 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.cli2.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/cli2/errors.py b/dashscope/cli2/errors.py new file mode 100644 index 00000000..26c44433 --- /dev/null +++ b/dashscope/cli2/errors.py @@ -0,0 +1,473 @@ +# -*- 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/cli2/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, 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, +} + +# Environment kill switch for the deprecation notice the entry point +# prints before delegating to the 1.x command tree. +NO_DEPRECATION_ENV = "DASHSCOPE_CLI_NO_DEPRECATION" + + +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. ``exit_code`` overrides the category's + default tier when a command needs to say something more specific. + """ + + def __init__( + self, + category: str, + message: str, + *, + 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.exit_code = exit_code + + +def usage_error(message: str) -> CliError: + """Shorthand for the most common CLI-side failure.""" + return CliError(USAGE, message) + + +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) + + 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")), + ) + + +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 + diagnosis = classify(exc) + print_error( + diagnosis, + console=_error_console(), + verbose=_verbose_from(ctx), + exc=exc, + ) + obj = getattr(ctx, "obj", None) + if obj is not None: + close = getattr(obj, "close", None) + if callable(close): + try: + close() + except Exception: # pylint: disable=broad-except + pass + override = getattr(exc, "exit_code", None) + sys.exit( + override + if override is not None + else exit_code_for(diagnosis.category), + ) + + +def _click_passthrough() -> Tuple[type[BaseException], ...]: + 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) + return tuple(classes) + + +_CLICK_PASSTHROUGH: Tuple[type[BaseException], ...] = _click_passthrough() diff --git a/dashscope/cli2/input.py b/dashscope/cli2/input.py new file mode 100644 index 00000000..ab57a1a6 --- /dev/null +++ b/dashscope/cli2/input.py @@ -0,0 +1,238 @@ +# -*- 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.cli2.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 _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, +) -> Any: + """Resolve the conversation for a generate-style command. + + Precedence: ``--messages`` > ``--prompt`` > piped 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. + """ + 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 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/cli2/main.py b/dashscope/cli2/main.py new file mode 100644 index 00000000..751ca5bd --- /dev/null +++ b/dashscope/cli2/main.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Console-script entry point: route between the v2 and 1.x command trees. + +Both trees answer to ``dashscope``. Rather than mounting the 1.x Typer +sub-apps inside the new root -- which would import all eighteen of their +modules just to render ``--help``, or reach into ``typer.core`` internals +that the ``typer>=0.9`` pin does not guarantee -- this module *delegates*: +the 1.x import is paid only when a 1.x command is actually run, and its +argv quirks (``fine_tunes.call`` spellings, a trailing ``-k``, +``--page-size`` → ``--size``) keep working because the 1.x ``main()`` +still does its own parsing. + +The cost is that Typer cannot enumerate the legacy groups in ``--help``; +``app.py`` prints a static list generated from the same constants below. +""" + +from __future__ import annotations + +import os +import sys +from typing import List, Optional, Sequence, Tuple + +import click + +from dashscope.cli2.errors import NO_DEPRECATION_ENV + +NEW = "new" +LEGACY = "legacy" + +# Names this entry point serves itself. ``fine-tunes`` is the hidden alias +# the 1.x tree already used for ``ft``, so muscle memory keeps working. +NEW_COMMANDS = frozenset({"chat", "ft", "fine-tunes", "config", "version"}) + +# 1.x-only command groups. They have no v2 equivalent yet and are on their +# way out, so invoking one says so. +LEGACY_DEPRECATED = frozenset( + { + "application", + "agentic-rl", + "generation", + "image-generation", + "image-synthesis", + "multimodal-conversation", + "multimodal-embedding", + "speech-synthesis", + "transcription", + "video-synthesis", + }, +) + +# Names the v2 tree will take over later (design doc §4.4/§4.5). Until the +# corresponding command group lands they route to 1.x silently: warning +# about a command that has no replacement yet would only be noise. +LEGACY_UNMIGRATED = frozenset( + { + "auth", + "deployments", + "embeddings", + "files", + "models", + "oss", + "rerank", + "rl", + "tokenization", + }, +) + +# Mirrors ``legacy_1x/cli/__init__.py``'s ``_TOP_LEVEL_COMMANDS``. Not +# imported from there, because importing it is exactly what routing away +# from the 1.x tree is meant to avoid. +LEGACY_ALL = LEGACY_DEPRECATED | LEGACY_UNMIGRATED | {"expert"} + +# Flags that opt into the bundled agent's interactive modes. They are +# stripped by the 1.x ``main()``, and only it knows what to do with them. +_EXPERT_MODE_FLAGS = ("--cli", "--tui") + +# Root options that consume the token after them. Without this the +# scanner would read the *value* as the command -- `dashscope -o json +# generation create` would look like an invocation of `json`. +# Mirrors app.py's callback; tests/cli2/test_app.py pins the two together. +_VALUE_TAKING_OPTIONS = frozenset( + { + "-k", + "--api-key", + "--protocol", + "--base-url", + "--workspace", + "--region", + "--timeout", + "-o", + "--output", + "-c", + }, +) + +# Option spellings that consume the following token as their value in the +# 1.x tree, which tolerates a *trailing* ``-k``. Kept separate from the +# above because they drive the bare-``dashscope`` decision below. +_VALUE_OPTIONS = ("-k", "--api-key") + + +def _first_command_token(argv: Sequence[str]) -> Optional[str]: + """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 arg + return None + + +def _is_only_api_key_options(argv: Sequence[str]) -> bool: + """True when every token is a ``-k``/``--api-key`` pair. + + That is the case the 1.x entry point treats as a bare ``dashscope`` + (it strips the pair and then finds nothing left), so it must keep + opening the interactive agent rather than landing in the v2 tree. + """ + index = 0 + while index < len(argv): + arg = argv[index] + if arg in _VALUE_OPTIONS: + if index + 1 >= len(argv): + return False + index += 2 + continue + if arg.startswith("--api-key="): + index += 1 + continue + return False + return True + + +def _classify_without_command(argv: Sequence[str]) -> Tuple[str, bool]: + """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, False + if not argv or _is_only_api_key_options(argv): + return LEGACY, False + # 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, False + + +def _known_token_target(token: str) -> Optional[Tuple[str, bool]]: + """Route a command token the router recognizes, else ``None``.""" + if token in NEW_COMMANDS: + return NEW, False + if token in LEGACY_DEPRECATED: + return LEGACY, True + if token in LEGACY_UNMIGRATED: + return LEGACY, False + return None + + +def classify(argv: Sequence[str]) -> Tuple[str, bool]: + """Decide which tree serves *argv*. Returns ``(target, is_deprecated)``.""" + if any(arg in _EXPERT_MODE_FLAGS for arg in argv): + return LEGACY, False + + token = _first_command_token(argv) + if token is None: + return _classify_without_command(argv) + if token == "expert" or "." in token: + # `expert` forwards to the bundled agent; the dotted spellings are + # the pre-Typer ``fine_tunes.call`` style the 1.x tree translates. + return LEGACY, False + + known = _known_token_target(token) + if known is not None: + return known + # Unknown or misspelled: the v2 tree produces a real usage error + # instead of the 1.x tree's, so the exit code matches the new tiers. + return NEW, False + + +def _deprecation_notice(command: str) -> str: + return ( + f"Note: `dashscope {command}` is served by the 1.x command tree, " + f"which is deprecated and will be removed.\n" + f" Run `dashscope --help` for the v2 commands. Set " + f"{NO_DEPRECATION_ENV}=1 to silence this notice." + ) + + +def _run_legacy(args: Optional[Sequence[str]]) -> None: + """Hand the invocation to the 1.x entry point. + + The 1.x ``main()`` parses ``sys.argv`` itself and depends on seeing it + pristine -- it rewrites argv for dotted command spellings and extracts + a trailing ``-k``. So the console-script path passes nothing and lets + it read the real argv. Only an explicit ``args`` (a programmatic or + test call) needs sys.argv aligned to it. + """ + if args is not None: + sys.argv = [sys.argv[0] if sys.argv else "dashscope", *args] + from dashscope.legacy_1x.cli import main as legacy_main + + legacy_main() + + +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) + target, deprecated = classify(argv) + + if target == LEGACY: + if deprecated and not os.environ.get(NO_DEPRECATION_ENV): + click.echo( + _deprecation_notice(_first_command_token(argv)), + err=True, + ) + _run_legacy(argv if args is not None else None) + return + + from dashscope.cli2.app import build_app + + build_app()(argv) diff --git a/dashscope/cli2/output.py b/dashscope/cli2/output.py new file mode 100644 index 00000000..853df36f --- /dev/null +++ b/dashscope/cli2/output.py @@ -0,0 +1,255 @@ +# -*- 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), 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 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 _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/cli2/progress.py b/dashscope/cli2/progress.py new file mode 100644 index 00000000..248e57b5 --- /dev/null +++ b/dashscope/cli2/progress.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Waiting on long-running jobs without reimplementing the polling. + +``client.fine_tunes.wait`` already does exponential backoff, enforces the +timeout, and raises 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. +""" + +from __future__ import annotations + +import sys +import time +from typing import Any, Callable, Optional + +from rich.status import Status + +from dashscope.cli2.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 wait_job( + client: Any, + job: Any, + *, + timeout: Optional[float] = None, + renderer: Optional[Renderer] = None, + label: str = "fine-tune job", +) -> 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 = 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/cli2/sdk.py b/dashscope/cli2/sdk.py new file mode 100644 index 00000000..4f94e193 --- /dev/null +++ b/dashscope/cli2/sdk.py @@ -0,0 +1,33 @@ +# -*- 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, Mapping + +# 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" + + +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. + """ + # pylint: disable=import-outside-toplevel + from dashscope import Client + + return Client(user_agent_suffix=USER_AGENT_SUFFIX, **dict(overrides)) 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/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/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..12a44a58 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-v2-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/examples/05_cli/README.md b/examples/05_cli/README.md new file mode 100644 index 00000000..9900f531 --- /dev/null +++ b/examples/05_cli/README.md @@ -0,0 +1,350 @@ +# 05 Command Line + +> **English** | [中文](README_zh.md) + +The other tiers drive the SDK from Python. This one drives it from a **shell**: the `dashscope` command is the same capabilities behind a different contract, and the contract is what makes it scriptable. Three output formats, a layered exit code, one fixed stderr error line, four configuration layers with a documented precedence, and a refusal to guess when a destructive command meets a terminal-less CI job. Every guarantee below is a promise a build script can rely on — and every one of them is invisible if you only ever read the Python examples. + +| File | What it shows | Network | +| --- | --- | --- | +| [`cli_output_and_errors.py`](./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, `@file` / stdin / `--messages` input shapes, and all four failure tiers | No | +| [`cli_ci_harness.py`](./cli_ci_harness.py) | The contract from the outside: a stdlib mock gateway on an ephemeral port, driving the **real CLI in a real subprocess** — parse `-o json`, branch and retry on exit codes, consume an NDJSON stream line by line, isolate configuration per job, and survive the destructive-command prompt | No (offline mock by default; `CLI_HARNESS_LIVE=1` hits the real gateway, read-only scenarios only) | + +Read them in that order. The first one shows *what* comes out of each channel; the second shows *how a pipeline should consume it*. + +--- + +## Install and first call + +The package registers a console script, so after `pip install -U dashscope` there is a `dashscope` command on your `PATH`: + +```bash +dashscope --help +dashscope version +``` + +From a source checkout where the script is not installed, the module entry point is identical: + +```bash +python3 -m dashscope.cli2 --help +python3 -m dashscope.cli2 version +``` + +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 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 list` | Page through jobs: `--page`, `--size` | +| `dashscope ft stream` | Follow a job's server-side event feed until the server closes it | +| `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 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 same entry point still runs the 1.x commands, so old shell scripts keep working. `main.py` sorts argv into three buckets: the v2 tree above, a **deprecated** set (`generation`, `application`, `image-synthesis`, `multimodal-conversation`, `speech-synthesis`, `transcription`, `video-synthesis`, `image-generation`, `multimodal-embedding`, `agentic-rl`) that prints a notice before delegating, and an **awaiting-v2** set (`auth`, `deployments`, `embeddings`, `files`, `models`, `oss`, `rerank`, `rl`, `tokenization`) that delegates silently. Set `DASHSCOPE_CLI_NO_DEPRECATION=1` to silence the notice in a build log. A bare `dashscope` with no subcommand opens the bundled `expert` agent interactively. + +--- + +## 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 (`cli2/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" +``` + +**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, no deprecation notice: + +```bash +export NO_COLOR=1 DASHSCOPE_CLI_NO_DEPRECATION=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=. python3 examples/05_cli/cli_output_and_errors.py +PYTHONPATH=. python3 examples/05_cli/cli_ci_harness.py +``` + +(`PYTHONPATH=.` is only needed when the package is not pip-installed. If it is, `python3 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 python3 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. + +--- + +## A pit you will step into without reading the source + +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 `ft cancel` and `ft delete`, 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` does. 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. + +See also [`../README.md`](../README.md) for how this tier fits the rest, and [`../02_advanced_engineering/local_mock_debugging.py`](../02_advanced_engineering/local_mock_debugging.py) for the SDK-side equivalent of the mock gateway the harness builds here. diff --git a/examples/05_cli/README_zh.md b/examples/05_cli/README_zh.md new file mode 100644 index 00000000..e2b444c8 --- /dev/null +++ b/examples/05_cli/README_zh.md @@ -0,0 +1,350 @@ +# 05 命令行 + +> [English](README.md) | **中文** + +其他各层都是从 Python 里驱动 SDK,这一层是从 **shell** 里驱动它:`dashscope` 命令是同一批能力的另一个契约面,而「可脚本化」正是这个契约带来的。三档输出格式、分层退出码、固定的一行 stderr 错误、四层配置及其明确的优先级、以及在无终端的 CI 作业里遇到破坏性命令时拒绝替你猜。下面每一条保证都是构建脚本可以依赖的承诺——而如果你只读过 Python 示例,这些保证一条都看不见。 + +| 文件 | 演示内容 | 联网 | +| --- | --- | --- | +| [`cli_output_and_errors.py`](./cli_output_and_errors.py) | 从内部看契约:一个假 client,用 `typer.testing.CliRunner` 在进程内驱动,把 table / json / yaml 三档、NDJSON 流式、`@file` / stdin / `--messages` 三种输入形态,以及四类失败档位的 stdout / stderr / 退出码并排打印出来 | 否 | +| [`cli_ci_harness.py`](./cli_ci_harness.py) | 从外部看契约:用标准库在临时端口起一个 mock 网关,驱动**真实子进程里的真实 CLI**——解析 `-o json`、按退出码分支与重试、逐行消费 NDJSON 流、按作业隔离配置,并闯过破坏性命令的确认关 | 否(默认离线 mock;`CLI_HARNESS_LIVE=1` 打真实网关,仅只读场景) | + +建议按这个顺序读。第一个演示每个通道**吐出什么**,第二个演示**流水线该怎么消费它**。 + +--- + +## 安装与第一次调用 + +包里注册了 console script,所以 `pip install -U dashscope` 之后 `PATH` 上就有一个 `dashscope` 命令: + +```bash +dashscope --help +dashscope version +``` + +在没装 console script 的源码检出里,模块入口完全等价: + +```bash +python3 -m dashscope.cli2 --help +python3 -m dashscope.cli2 version +``` + +密钥配一次,两种方式都行: + +```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 chat create` | 一次 chat 补全:`--prompt` / `--messages`、`--stream`、`--tools`、采样参数,其余走 `--parameters` | +| `dashscope ft create` | 起一个微调作业;`--wait` 带 spinner 阻塞到终态,`--timeout` 限定等待时长 | +| `dashscope ft get` | 查看单个作业 | +| `dashscope ft list` | 分页列出作业:`--page`、`--size` | +| `dashscope ft stream` | 跟随服务端事件流,直到服务端关闭它 | +| `dashscope ft cancel` | 取消运行中的作业。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope ft delete` | 删除作业及其产出模型。**破坏性**——会确认,CI 里需要 `--yes` | +| `dashscope config set` | 往 `~/.dashscope/config.toml` 写一个键 | +| `dashscope config get` | 读回一个键(api_key 默认打码,除非 `--reveal`) | +| `dashscope config list` | 每个键、它的生效值,以及这个值来自哪一层 | + +同一个入口仍然能跑 1.x 命令,所以老的 shell 脚本不会断。`main.py` 把 argv 分成三类:上面这棵 v2 命令树、一组**已废弃**命令(`generation`、`application`、`image-synthesis`、`multimodal-conversation`、`speech-synthesis`、`transcription`、`video-synthesis`、`image-generation`、`multimodal-embedding`、`agentic-rl`,委派前先打印提示),以及一组**等待 v2 替代**的命令(`auth`、`deployments`、`embeddings`、`files`、`models`、`oss`、`rerank`、`rl`、`tokenization`,静默委派)。设 `DASHSCOPE_CLI_NO_DEPRECATION=1` 可以在构建日志里静音这条提示。不带子命令的裸 `dashscope` 会交互式打开内置的 `expert` agent。 + +--- + +## 输出格式 + +`-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(`cli2/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" +``` + +**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 DASHSCOPE_CLI_NO_DEPRECATION=1 +``` + +`NO_COLOR=1` 和 `--no-color` 是一回事。Rich 本来就能识别管道,所以这一条主要是给那种「先分配伪终端再把它倒进日志」的 runner 用的。 + +--- + +## 运行示例 + +两个都离线、确定性,都不需要密钥。在仓库根目录、且检出可导入的情况下: + +```bash +PYTHONPATH=. python3 examples/05_cli/cli_output_and_errors.py +PYTHONPATH=. python3 examples/05_cli/cli_ci_harness.py +``` + +(只有包没被 pip 安装时才需要 `PYTHONPATH=.`。装了的话 `python3 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 python3 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。你的流水线需要在 `ft cancel` 和 `ft delete` 上加 `--yes`,而且必须是显式加——因为重点就在于这个命令不肯替你做决定。 +4. **`ft create -m` 永不回落到 `default_model`。**只有 `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。 + +另见 [`../README_zh.md`](../README_zh.md) 了解这一层在整体中的位置,以及 [`../02_advanced_engineering/local_mock_debugging.py`](../02_advanced_engineering/local_mock_debugging.py)——那是这里的 mock 网关在 SDK 侧的对应物。 diff --git a/examples/05_cli/cli_ci_harness.py b/examples/05_cli/cli_ci_harness.py new file mode 100644 index 00000000..395cc0de --- /dev/null +++ b/examples/05_cli/cli_ci_harness.py @@ -0,0 +1,779 @@ +# -*- 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):: + + python3 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 \ + python3 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/cli2/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.cli2`` 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.cli2", *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_CLI_NO_DEPRECATION=1 to silence", + "the notice printed when a 1.x command is delegated to the legacy", + "tree, and 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: 05_cli/README.md +# ------------------------------------------------------------- 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..e110c914 --- /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.cli2.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/cli2/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:: + + python3 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.cli2 import sdk as cli_sdk +from dashscope.cli2.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/cli2/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.cli2.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: 05_cli/README.md +# SDK-side offline debugging: 02_advanced_engineering/local_mock_debugging.py +# ------------------------------------------------------------- diff --git a/examples/README.md b/examples/README.md index 8e1f1141..ffcc4444 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 @@ -59,7 +61,7 @@ What the tiers mean (an advancement path from "quickref" to "production blueprin python3 examples/01_atomic_capabilities/text_generation/chat.py ``` -> **Runs offline (no API key needed)**: `02_advanced_engineering/local_mock_debugging.py`, `02_advanced_engineering/circuit_breaker_retry.py`, `03_agent_recipes/mcp_server_demo/client_demo.py`, `04_real_world_scenarios/production_ready_rag_harness.py`, `04_real_world_scenarios/realtime_audio_meeting_harness.py` and `04_real_world_scenarios/ticket_triage_harness.py` use mock transports / scripted seams / subprocesses — fully offline and deterministic, ideal for a first hands-on look at the call shapes. +> **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,6 +140,19 @@ 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 | + +The command surface itself (`chat create`, `ft create|get|list|stream|cancel|delete`, `config set|get|list`, `version`), the full exit-code and precedence tables, and copy-pasteable shell recipes live in the tier's own README. + +See [`05_cli/README.md`](./05_cli/README.md). + ## legacy_1x — 1.x module-level API (reference only) The following use the old 1.x style (`dashscope.Generation`, `dashscope.MultiModalConversation`, etc.). The internal subdirectory layout is kept exactly as-is and the content is untouched. New projects should use the 2.0 tiered examples above. diff --git a/examples/README_zh.md b/examples/README_zh.md index 44ddb9fd..c8c087c7 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 模块级风格,子目录布局原样保留,仅供参考;新项目请优先使用上面五层。 ## 准备工作 @@ -59,7 +61,7 @@ examples/ python3 examples/01_atomic_capabilities/text_generation/chat.py ``` -> **离线运行(无需 API key)**:`02_advanced_engineering/local_mock_debugging.py`、`02_advanced_engineering/circuit_breaker_retry.py`、`03_agent_recipes/mcp_server_demo/client_demo.py`、`04_real_world_scenarios/production_ready_rag_harness.py`、`04_real_world_scenarios/realtime_audio_meeting_harness.py` 和 `04_real_world_scenarios/ticket_triage_harness.py` 使用 mock transport / 脚本化接缝 / 子进程——完全离线且输出确定,适合先动手感受调用形态。 +> **离线运行(无需 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,6 +140,19 @@ 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` | 否 | + +命令面本身(`chat create`、`ft create|get|list|stream|cancel|delete`、`config set|get|list`、`version`)、完整的退出码与优先级表格,以及可直接复制的 shell 配方,都在这一层自己的 README 里。 + +详见 [`05_cli/README.md`](./05_cli/README.md)。 + ## legacy_1x —— 1.x 模块级 API(仅供参考) 以下使用旧的 1.x 风格(`dashscope.Generation`、`dashscope.MultiModalConversation` 等)。内部子目录布局原样保留、内容不做改动。新项目请使用上面的 2.0 分层示例。 diff --git a/setup.py b/setup.py index d9a94d60..74e85c20 100644 --- a/setup.py +++ b/setup.py @@ -142,6 +142,6 @@ def run(self): zip_safe=False, cmdclass=cmdclass, entry_points={ - "console_scripts": ["dashscope = dashscope.legacy_1x.cli:main"], + "console_scripts": ["dashscope = dashscope.cli2.main:main"], }, ) diff --git a/tests/cli2/__init__.py b/tests/cli2/__init__.py new file mode 100644 index 00000000..cc26a06f --- /dev/null +++ b/tests/cli2/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. diff --git a/tests/cli2/conftest.py b/tests/cli2/conftest.py new file mode 100644 index 00000000..f69fcc91 --- /dev/null +++ b/tests/cli2/conftest.py @@ -0,0 +1,124 @@ +# -*- 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", + "DASHSCOPE_CLI_NO_DEPRECATION", + "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.cli2.sdk.build_client", build) + return client + + install.calls = calls + return install diff --git a/tests/cli2/test_app.py b/tests/cli2/test_app.py new file mode 100644 index 00000000..2a55fc60 --- /dev/null +++ b/tests/cli2/test_app.py @@ -0,0 +1,293 @@ +# -*- 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.cli2.app import build_app +from dashscope.cli2.errors import ErrorHandlingGroup +from dashscope.cli2.main import _VALUE_TAKING_OPTIONS +from dashscope.version import __version__ + + +def _runner() -> CliRunner: + return CliRunner(mix_stderr=False) + + +@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_both_the_v2_and_the_1x_commands(self, app): + result = _invoke(app, ["--help"]) + assert result.exit_code == 0 + text = result.stdout + assert "version" in text and "config" in text + # Delegated commands cannot be enumerated by typer, so the epilog + # carries them; assert one from each set. + assert "generation" in text + 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/cli2/test_chat_cmd.py b/tests/cli2/test_chat_cmd.py new file mode 100644 index 00000000..953f0b1e --- /dev/null +++ b/tests/cli2/test_chat_cmd.py @@ -0,0 +1,576 @@ +# -*- 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.cli2.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.cli2 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.cli2 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_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_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/cli2/test_config_store.py b/tests/cli2/test_config_store.py new file mode 100644 index 00000000..7fc79fb3 --- /dev/null +++ b/tests/cli2/test_config_store.py @@ -0,0 +1,311 @@ +# -*- 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.cli2.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.cli2.errors import CliError + + +@pytest.fixture +def config(clean_env): + return clean_env["config"] + + +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"), + ("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_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 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 (config.parent / (config.name + ".tmp")).exists() + + 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_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/cli2/test_confirm.py b/tests/cli2/test_confirm.py new file mode 100644 index 00000000..d92bb24b --- /dev/null +++ b/tests/cli2/test_confirm.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Confirmation gate for destructive commands.""" + +import io + +import pytest + +from dashscope.cli2.confirm import confirm_or_die, stdin_is_tty +from dashscope.cli2.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.cli2.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_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/cli2/test_context.py b/tests/cli2/test_context.py new file mode 100644 index 00000000..037584f9 --- /dev/null +++ b/tests/cli2/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.cli2.config_store import set_value +from dashscope.cli2.context import CliContext, CliDefaults, GlobalOptions +from dashscope.cli2.errors import CliError +from dashscope.cli2.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.cli2.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/cli2/test_dispatch.py b/tests/cli2/test_dispatch.py new file mode 100644 index 00000000..b1278c84 --- /dev/null +++ b/tests/cli2/test_dispatch.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Routing between the v2 and 1.x command trees.""" + +import sys +from types import ModuleType + +import pytest + +from dashscope.cli2.errors import NO_DEPRECATION_ENV +from dashscope.cli2.main import ( + LEGACY, + LEGACY_DEPRECATED, + LEGACY_UNMIGRATED, + NEW, + NEW_COMMANDS, + classify, + main, +) + + +class TestClassify: + @pytest.mark.parametrize( + "argv", + [ + ["chat", "create", "-m", "qwen-plus"], + ["ft", "list"], + ["fine-tunes", "get", "job-1"], + ["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"], + # A misspelled command must reach the v2 parser so the exit + # code matches the new tiers (2), not the 1.x tree's. + ["chatz", "create"], + ["auth_x"], + # A stray option with no command is a command-line mistake. + ["-k"], + ["--verbose"], + # --help with no command describes the tree being written + # against from here on. + ["--help"], + ["-h"], + ["-k", "sk-1", "--help"], + ], + ) + def test_new_tree(self, argv): + assert classify(argv) == (NEW, False) + + @pytest.mark.parametrize( + "argv", + [ + [], # bare `dashscope` → TUI + ["-k", "sk-1"], # 1.x strips it, then TUI + ["--api-key=sk-1"], + ["expert", "what", "is", "lora"], + ["expert", "--help"], + ["fine_tunes.list", "--page-size", "5"], + ["generation.call"], + ["oss.upload"], + ["files", "list"], + ["models", "list"], + ["auth", "whoami"], + ["deployments", "list"], + ["embeddings", "create"], + ["rerank", "create"], + ["tokenization", "create"], + ["rl", "list"], + ["--cli"], + ["--tui"], + ["--cli", "generation", "create"], + ], + ) + def test_legacy_tree(self, argv): + assert classify(argv)[0] == LEGACY + + @pytest.mark.parametrize("name", sorted(LEGACY_DEPRECATED)) + def test_deprecated_names_warn(self, name): + assert classify([name, "list"]) == (LEGACY, True) + + @pytest.mark.parametrize("name", sorted(LEGACY_UNMIGRATED)) + def test_unmigrated_names_are_silent(self, name): + assert classify([name, "list"]) == (LEGACY, False) + + def test_expert_is_never_deprecation_warned(self): + assert classify(["expert", "hi"]) == (LEGACY, False) + + def test_global_options_do_not_hide_the_command(self): + assert ( + classify(["-k", "sk-1", "--output", "json", "ft", "list"])[0] + == NEW + ) + assert classify(["-k", "sk-1", "generation", "create"]) == ( + LEGACY, + True, + ) + + def test_value_of_a_global_option_is_not_read_as_the_command(self): + """``-o json`` must not make ``json`` look like the command.""" + assert classify(["-o", "json", "generation", "create"]) == ( + LEGACY, + True, + ) + assert classify(["--output", "json", "ft", "list"]) == (NEW, False) + assert classify(["-c", "output=json", "generation", "create"]) == ( + LEGACY, + True, + ) + assert classify(["--timeout", "30", "files", "list"])[0] == LEGACY + + def test_self_contained_option_forms(self): + assert classify(["--api-key=sk-1", "generation", "create"]) == ( + LEGACY, + True, + ) + assert classify(["--output=json", "ft", "list"]) == (NEW, False) + + def test_name_sets_do_not_overlap(self): + assert NEW_COMMANDS.isdisjoint(LEGACY_DEPRECATED) + assert NEW_COMMANDS.isdisjoint(LEGACY_UNMIGRATED) + assert LEGACY_DEPRECATED.isdisjoint(LEGACY_UNMIGRATED) + + def test_ft_and_its_alias_moved_together(self): + """`ft` and hidden `fine-tunes` must land in the same tree.""" + assert ( + classify(["ft", "list"])[0] + == classify(["fine-tunes", "list"])[0] + == NEW + ) + + +@pytest.fixture +def legacy_main(monkeypatch): + """Replace the 1.x entry point with a recorder. + + Inserted into ``sys.modules`` rather than patched on the real module + so the test never pays for importing eighteen Typer apps. + """ + calls = [] + + def fake_main(): + calls.append(list(sys.argv)) + + module = ModuleType("dashscope.legacy_1x.cli") + module.main = fake_main + monkeypatch.setitem(sys.modules, "dashscope.legacy_1x.cli", 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.cli2.app.build_app", fake_build_app) + return calls + + +class TestMainDispatch: + def test_new_route_builds_and_calls_the_app(self, new_app, legacy_main): + main(["version"]) + assert new_app == [["version"]] + assert legacy_main == [] + + def test_legacy_route_delegates(self, new_app, legacy_main): + main(["files", "list"]) + assert legacy_main == [[sys.argv[0], "files", "list"]] + assert new_app == [] + + def test_legacy_route_receives_the_argv_it_was_given( + self, + new_app, + legacy_main, + monkeypatch, + ): + """The 1.x parser reads sys.argv, so an explicit args list must be + published there or it would dispatch on the real command line.""" + monkeypatch.setattr(sys, "argv", ["dashscope", "stale", "args"]) + main(["files", "list"]) + assert legacy_main == [["dashscope", "files", "list"]] + + def test_bare_invocation_goes_to_the_agent(self, new_app, legacy_main): + main([]) + assert legacy_main != [] + assert new_app == [] + + def test_deprecation_notice_for_deprecated_commands( + self, + capsys, + new_app, + legacy_main, + monkeypatch, + ): + monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) + main(["generation", "create"]) + err = capsys.readouterr().err + assert "deprecated" in err + assert "dashscope generation" in err + assert NO_DEPRECATION_ENV in err + + def test_notice_names_the_command_even_after_global_options( + self, + capsys, + new_app, + legacy_main, + monkeypatch, + ): + monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) + main(["-k", "sk-1", "generation", "create"]) + assert "dashscope generation" in capsys.readouterr().err + + def test_no_notice_for_unmigrated_commands( + self, + capsys, + new_app, + legacy_main, + monkeypatch, + ): + monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) + main(["files", "list"]) + assert capsys.readouterr().err == "" + + def test_notice_can_be_silenced( + self, + capsys, + new_app, + legacy_main, + monkeypatch, + ): + monkeypatch.setenv(NO_DEPRECATION_ENV, "1") + main(["generation", "create"]) + assert capsys.readouterr().err == "" + assert legacy_main != [] + + def test_notice_goes_to_stderr_not_stdout( + self, + capsys, + new_app, + legacy_main, + monkeypatch, + ): + monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) + main(["generation", "create"]) + assert capsys.readouterr().out == "" + + def test_reads_sys_argv_when_called_with_no_arguments( + self, + new_app, + legacy_main, + monkeypatch, + ): + monkeypatch.setattr(sys, "argv", ["dashscope", "version"]) + main() + assert new_app == [["version"]] + + def test_console_script_reads_pristine_argv( + self, + new_app, + legacy_main, + monkeypatch, + ): + """The 1.x tree rewrites argv itself; it must see the original.""" + monkeypatch.setattr(sys, "argv", ["dashscope", "files", "list"]) + main() + assert legacy_main == [["dashscope", "files", "list"]] diff --git a/tests/cli2/test_errors.py b/tests/cli2/test_errors.py new file mode 100644 index 00000000..c5a8ba8f --- /dev/null +++ b/tests/cli2/test_errors.py @@ -0,0 +1,489 @@ +# -*- 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.cli2 import errors as cli_errors +from dashscope.cli2.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.cli2.conftest import strip_ansi_codes + + +def _runner() -> CliRunner: + """Separate stdout/stderr: the CLI promises clean JSON on stdout.""" + return CliRunner(mix_stderr=False) + + +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/cli2/test_ft_cmd.py b/tests/cli2/test_ft_cmd.py new file mode 100644 index 00000000..2cbc1a80 --- /dev/null +++ b/tests/cli2/test_ft_cmd.py @@ -0,0 +1,535 @@ +# -*- 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.cli2.app import build_app +from dashscope.resources.fine_tunes import ( + FineTune, + FineTuneEvent, + FineTuneList, +) +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 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.cli2.confirm.stdin_is_tty", lambda: True) + monkeypatch.setattr( + "dashscope.cli2.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.cli2 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 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 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_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.cli2.main import classify + + assert classify(["ft", "list"]) == ("new", False) + assert classify(["fine-tunes", "list"]) == ("new", False) diff --git a/tests/cli2/test_input.py b/tests/cli2/test_input.py new file mode 100644 index 00000000..6e369a3f --- /dev/null +++ b/tests/cli2/test_input.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""``@file`` / ``-`` / literal input parsing.""" + +import io +import json + +import pytest + +from dashscope.cli2.errors import CliError +from dashscope.cli2.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_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/cli2/test_output.py b/tests/cli2/test_output.py new file mode 100644 index 00000000..9a54aa71 --- /dev/null +++ b/tests/cli2/test_output.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Output rendering: machine formats must stay machine-readable.""" + +import json + +import pytest + +from dashscope.cli2.output import ( + JSON, + OUTPUT_FORMATS, + TABLE, + YAML, + Renderer, + is_machine_format, + 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 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/cli2/test_package_layout.py b/tests/cli2/test_package_layout.py new file mode 100644 index 00000000..7657ad45 --- /dev/null +++ b/tests/cli2/test_package_layout.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Structural invariants of the CLI package layout. + +``dashscope.cli`` is not free: it is a frozen public alias for the 1.x +tree, installed by ``dashscope/legacy_1x/_shim.py``'s meta-path finder and +pinned by ``tests/test_legacy_import_aliases.py`` (which also runs +``python -m dashscope.cli --help`` and expects the 1.x CLI). The v2 CLI +therefore lives in ``dashscope.cli2``. These tests keep that split from +being "tidied" back into a collision. +""" + +import ast +import subprocess +import sys +from pathlib import Path + +PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "dashscope" / "cli2" +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_cli2_never_loads_the_1x_command_tree(): + """What the split can and cannot buy. + + ``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 the split 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.cli2, dashscope.cli2.context\n" + "import dashscope.cli2.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_legacy_dashscope_cli_still_aliases_the_1x_tree(): + result = _run_python( + "import importlib\n" + "old = importlib.import_module('dashscope.cli')\n" + "canonical = importlib.import_module('dashscope.legacy_1x.cli')\n" + "assert old is canonical\n" + "assert old.__file__.endswith('legacy_1x/cli/__init__.py'), old.__file__\n", + ) + assert result.returncode == 0, result.stdout + result.stderr + + +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 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.cli2`` 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.cli2" + and not name.startswith("dashscope.cli2.") + ): + offenders.append(f"{path.name}: {name}") + assert offenders == [] + + +def test_console_script_target_resolves(): + """``setup.py`` points at ``dashscope.cli2.main:main``. + + ``main`` there is a *module*, so the package must not bind the same + name to a function: whichever import ran first would win, and + ``from dashscope.cli2 import main`` would mean two different things. + """ + from importlib import import_module + + module = import_module("dashscope.cli2.main") + assert callable(module.main) + + +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/cli2/test_progress.py b/tests/cli2/test_progress.py new file mode 100644 index 00000000..a26de2c1 --- /dev/null +++ b/tests/cli2/test_progress.py @@ -0,0 +1,204 @@ +# -*- 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.cli2.output import JSON, Renderer +from dashscope.cli2.progress import 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.cli2.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.cli2.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.cli2.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.cli2.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() diff --git a/tests/legacy_1x/legacy/conftest.py b/tests/legacy_1x/legacy/conftest.py index 675bf3a8..6351d532 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,10 +45,10 @@ def http_server(request): print("starting legacy server!!!!!!!!!") proc = multiprocessing.Process(target=run_server) proc.start() - time.sleep(2) + wait_for_port(HTTP_SERVER_PORT) def stop_server(): - proc.terminate() + stop_process(proc) print("Stopping legacy server") request.addfinalizer(stop_server) diff --git a/tests/legacy_1x/unit/conftest.py b/tests/legacy_1x/unit/conftest.py index 644dc3be..ca0d020f 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,10 +51,10 @@ 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) + wait_for_port(HTTP_SERVER_PORT) def stop_server(): - proc.terminate() + stop_process(proc) print("Stopping server") request.addfinalizer(stop_server) diff --git a/tests/legacy_1x/unit/mock_server.py b/tests/legacy_1x/unit/mock_server.py index c6b91104..2756abf2 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,12 +1021,10 @@ def create_mock_server(request): ), ) proc.start() - import time - - time.sleep(2) + wait_for_port(MOCK_SERVER_PORT) def stop_server(): - proc.terminate() + stop_process(proc) print("Mock server stopped") request.addfinalizer(stop_server) diff --git a/tests/test_user_agent_suffix.py b/tests/test_user_agent_suffix.py new file mode 100644 index 00000000..2d88d993 --- /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-v2-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() From 6c289488c1c22a77940a9c1bfefbfa2767f2be59 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Tue, 3 Nov 2026 15:24:39 +0800 Subject: [PATCH 02/13] fix(tests): Compatibility with click 8.2 CliRunner parameter changes - Catch TypeError exception during CliRunner instantiation - Adapt to click 8.2+ default stream separation behavior - Update _runner function in test_app.py - Update _runner function in test_errors.py --- tests/cli2/test_app.py | 5 ++++- tests/cli2/test_errors.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/cli2/test_app.py b/tests/cli2/test_app.py index 2a55fc60..a04aed26 100644 --- a/tests/cli2/test_app.py +++ b/tests/cli2/test_app.py @@ -15,7 +15,10 @@ def _runner() -> CliRunner: - return CliRunner(mix_stderr=False) + try: + return CliRunner(mix_stderr=False) + except TypeError: # click >= 8.2 always separates the streams + return CliRunner() @pytest.fixture diff --git a/tests/cli2/test_errors.py b/tests/cli2/test_errors.py index c5a8ba8f..cf3fc6fe 100644 --- a/tests/cli2/test_errors.py +++ b/tests/cli2/test_errors.py @@ -35,7 +35,10 @@ def _runner() -> CliRunner: """Separate stdout/stderr: the CLI promises clean JSON on stdout.""" - return CliRunner(mix_stderr=False) + try: + return CliRunner(mix_stderr=False) + except TypeError: # click >= 8.2 always separates the streams + return CliRunner() class TestTierIsomorphism: From 2e0225e252de785f13f2118db1f42c87d154a913 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Tue, 3 Nov 2026 15:54:56 +0800 Subject: [PATCH 03/13] fix(cli): Fix typer compatibility layer exceptions being misclassified - Add detailed docstring to _click_passthrough function - Explain the reason for collecting both real click and typer exception classes - Add capture logic for exceptions in typer's internal _click compatibility layer - Extract relevant exception classes from typer._click.exceptions - Prevent typer's missing argument exceptions from being misclassified as server errors - Handle import errors when older typer versions do not provide the _click module --- dashscope/cli2/errors.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/dashscope/cli2/errors.py b/dashscope/cli2/errors.py index 26c44433..3b4dce30 100644 --- a/dashscope/cli2/errors.py +++ b/dashscope/cli2/errors.py @@ -459,6 +459,18 @@ def invoke(self, ctx: typer.Context): 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] @@ -467,6 +479,18 @@ def _click_passthrough() -> Tuple[type[BaseException], ...]: 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) From b35842311266867c366d48efb0189ade252e99b9 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Thu, 5 Nov 2026 10:01:08 +0800 Subject: [PATCH 04/13] =?UTF-8?q?feat(cli):=20expand=20v2=20CLI=20with=20f?= =?UTF-8?q?ull=20command=20coverage=20and=20rename=20cli2=20=E2=86=92=20cl?= =?UTF-8?q?i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the v2 package directory from cli2 to cli, making it the primary command tree. Add 16 new resource commands (files, models, images, videos, embeddings, multimodal, rerank, tokenization, apps, deployments, rl, audio, audio-customization, tingwu, decision, agentstudio) alongside the existing chat/ft/config commands. Key changes: - Rename dashscope/cli2 → dashscope/cli and tests/cli2 → tests/cli - Bump version to 2.0.4 - Refactor main.py dispatch logic with consolidated command routing - Expand config_store.py with TOML line-editor writer (no new deps) - Overhaul error handling, output formatting, and progress display - Enhance ft command with extended fine-tuning workflows - Update README and CLI examples (en/zh) with new command docs - Add comprehensive test coverage for all new commands - Clean up legacy import aliases --- README.md | 24 ++ README_zh.md | 24 ++ dashscope/__init__.py | 1 - dashscope/{cli2 => cli}/__init__.py | 8 +- dashscope/cli/__main__.py | 8 + dashscope/{cli2 => cli}/app.py | 79 +++-- dashscope/{cli2 => cli}/commands/__init__.py | 0 dashscope/{cli2 => cli}/commands/chat.py | 69 +--- dashscope/{cli2 => cli}/commands/config.py | 4 +- dashscope/{cli2 => cli}/commands/ft.py | 100 ++++-- dashscope/{cli2 => cli}/config_store.py | 198 +++++++++-- dashscope/{cli2 => cli}/confirm.py | 6 +- dashscope/{cli2 => cli}/context.py | 22 +- dashscope/{cli2 => cli}/errors.py | 85 +++-- dashscope/{cli2 => cli}/input.py | 18 +- dashscope/cli/main.py | 332 ++++++++++++++++++ dashscope/{cli2 => cli}/output.py | 87 ++++- dashscope/{cli2 => cli}/progress.py | 50 ++- dashscope/cli/sdk.py | 78 ++++ dashscope/cli2/__main__.py | 12 - dashscope/cli2/main.py | 233 ------------ dashscope/cli2/sdk.py | 33 -- dashscope/legacy_1x/_shim.py | 1 - dashscope/resources/chat/completions.py | 13 +- dashscope/version.py | 2 +- .../mcp_server_demo/README.md | 6 +- .../mcp_server_demo/client_demo.py | 2 +- examples/04_real_world_scenarios/README.md | 14 +- examples/04_real_world_scenarios/README_zh.md | 14 +- examples/05_cli/README.md | 192 +++++++++- examples/05_cli/README_zh.md | 192 +++++++++- examples/05_cli/cli_ci_harness.py | 16 +- examples/05_cli/cli_output_and_errors.py | 14 +- examples/README.md | 6 +- examples/README_zh.md | 6 +- setup.py | 5 +- tests/{cli2 => cli}/__init__.py | 0 tests/{cli2 => cli}/conftest.py | 3 +- tests/{cli2 => cli}/test_app.py | 16 +- tests/{cli2 => cli}/test_chat_cmd.py | 46 ++- tests/{cli2 => cli}/test_config_store.py | 172 ++++++++- tests/{cli2 => cli}/test_confirm.py | 12 +- tests/{cli2 => cli}/test_context.py | 10 +- tests/cli/test_dispatch.py | 324 +++++++++++++++++ tests/{cli2 => cli}/test_errors.py | 6 +- tests/{cli2 => cli}/test_ft_cmd.py | 204 ++++++++++- tests/{cli2 => cli}/test_input.py | 4 +- tests/{cli2 => cli}/test_output.py | 58 ++- tests/{cli2 => cli}/test_package_layout.py | 47 +-- tests/{cli2 => cli}/test_progress.py | 45 ++- tests/cli2/test_dispatch.py | 272 -------------- tests/legacy_1x/legacy/conftest.py | 5 +- tests/legacy_1x/unit/conftest.py | 5 +- tests/legacy_1x/unit/mock_server.py | 5 +- tests/test_legacy_import_aliases.py | 4 +- 55 files changed, 2295 insertions(+), 897 deletions(-) rename dashscope/{cli2 => cli}/__init__.py (66%) create mode 100644 dashscope/cli/__main__.py rename dashscope/{cli2 => cli}/app.py (64%) rename dashscope/{cli2 => cli}/commands/__init__.py (100%) rename dashscope/{cli2 => cli}/commands/chat.py (67%) rename dashscope/{cli2 => cli}/commands/config.py (96%) rename dashscope/{cli2 => cli}/commands/ft.py (77%) rename dashscope/{cli2 => cli}/config_store.py (70%) rename dashscope/{cli2 => cli}/confirm.py (85%) rename dashscope/{cli2 => cli}/context.py (90%) rename dashscope/{cli2 => cli}/errors.py (90%) rename dashscope/{cli2 => cli}/input.py (92%) create mode 100644 dashscope/cli/main.py rename dashscope/{cli2 => cli}/output.py (71%) rename dashscope/{cli2 => cli}/progress.py (54%) create mode 100644 dashscope/cli/sdk.py delete mode 100644 dashscope/cli2/__main__.py delete mode 100644 dashscope/cli2/main.py delete mode 100644 dashscope/cli2/sdk.py rename tests/{cli2 => cli}/__init__.py (100%) rename tests/{cli2 => cli}/conftest.py (97%) rename tests/{cli2 => cli}/test_app.py (95%) rename tests/{cli2 => cli}/test_chat_cmd.py (93%) rename tests/{cli2 => cli}/test_config_store.py (63%) rename tests/{cli2 => cli}/test_confirm.py (87%) rename tests/{cli2 => cli}/test_context.py (96%) create mode 100644 tests/cli/test_dispatch.py rename tests/{cli2 => cli}/test_errors.py (99%) rename tests/{cli2 => cli}/test_ft_cmd.py (69%) rename tests/{cli2 => cli}/test_input.py (99%) rename tests/{cli2 => cli}/test_output.py (83%) rename tests/{cli2 => cli}/test_package_layout.py (65%) rename tests/{cli2 => cli}/test_progress.py (78%) delete mode 100644 tests/cli2/test_dispatch.py diff --git a/README.md b/README.md index 3ed6ff90..b4297151 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** commands on your `PATH`: + +| Command | Runs | Typical subcommands | +|---------|------|---------------------| +| `dashscope` | the **2.0** CLI | `chat create`, `ft create/get/wait/list/stream/logs/cancel/delete`, `files upload/list/get/delete`, `models list/get`, `images generate`, `images jobs create/get/wait/list/cancel`, `videos generate`, `videos jobs create/get/wait/list/cancel`, `embeddings create`, `embeddings jobs create/get/wait/list/cancel`, `multimodal chat create`, `multimodal embeddings create`, `rerank create`, `tokenization count`, `apps completions create`, `deployments create/get/list/scale/delete/stream`, `rl jobs create/get/wait/list/logs/cancel/delete`, `rl functions register/test/upload-data/logs/delete`, `audio tts synthesize/http`, `audio asr transcribe/get/wait/recognize`, `audio realtime run`, `audio vocabularies/phrases/voices/voice-design` management, `tingwu call`, `decision create`, `agentstudio agents create/get/list/versions/update/archive`, `agentstudio sessions create/get/list/update/archive/delete`, `agentstudio sessions events send/list/stream`, `config set/get/list`, `version` | +| `dashscope1` | the **1.x** CLI (legacy) | `generation`, `application`, `image-synthesis`, `multimodal-conversation`, `auth`, `oss`, … | + +The module entry points are equivalent: + +```bash +python -m dashscope.cli # 2.0 +python -m dashscope.legacy_1x.cli # 1.x +``` + +Running `dashscope` with no arguments starts the [AI assistant](#ai-assistant-dashscope-sdk-expert) instead of the CLI. + +For the full 2.0 CLI contract — output formats, exit codes, configuration precedence, CI recipes — see [`examples/05_cli/README.md`](examples/05_cli/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 ``` +The 1.x CLI (`dashscope1`) has its own completion — run `dashscope1 --install-completion ` separately if you still use it. + ## Logging Set the level via settings or the environment: @@ -496,6 +518,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..7f06bbb6 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` 会在 `PATH` 上安装**两个**命令: + +| 命令 | 跑的是 | 典型子命令 | +|------|--------|-----------| +| `dashscope` | **2.0** CLI | `chat create`、`ft create/get/wait/list/stream/logs/cancel/delete`、`files upload/list/get/delete`、`models list/get`、`images generate`、`images jobs create/get/wait/list/cancel`、`videos generate`、`videos jobs create/get/wait/list/cancel`、`embeddings create`、`embeddings jobs create/get/wait/list/cancel`、`multimodal chat create`、`multimodal embeddings create`、`rerank create`、`tokenization count`、`apps completions create`、`deployments create/get/list/scale/delete/stream`、`rl jobs create/get/wait/list/logs/cancel/delete`、`rl functions register/test/upload-data/logs/delete`、`audio tts synthesize/http`、`audio asr transcribe/get/wait/recognize`、`audio realtime run`、`audio vocabularies/phrases/voices/voice-design` 管理面、`tingwu call`、`decision create`、`agentstudio agents create/get/list/versions/update/archive`、`agentstudio sessions create/get/list/update/archive/delete`、`agentstudio sessions events send/list/stream`、`config set/get/list`、`version` | +| `dashscope1` | **1.x** CLI(历史) | `generation`、`application`、`image-synthesis`、`multimodal-conversation`、`auth`、`oss`…… | + +模块入口等价: + +```bash +python -m dashscope.cli # 2.0 +python -m dashscope.legacy_1x.cli # 1.x +``` + +不带参数运行 `dashscope` 会启动 [AI 助手](#ai-助手dashscope-sdk-expert),而不是 CLI。 + +完整的 2.0 CLI 契约 —— 输出格式、退出码、配置优先级、CI 配方 —— 见 [`examples/05_cli/README_zh.md`](examples/05_cli/README_zh.md)。 + ## Shell 命令补全 针对你的 Shell 运行一次对应命令,然后重启 Shell(或重新 source 配置文件): @@ -453,6 +473,8 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 dashscope --show-completion bash ``` +1.x CLI(`dashscope1`)有自己独立的补全 —— 如果仍在使用,请单独运行 `dashscope1 --install-completion `。 + ## 日志 通过设置或环境变量配置日志级别: @@ -496,6 +518,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/cli2/__init__.py b/dashscope/cli/__init__.py similarity index 66% rename from dashscope/cli2/__init__.py rename to dashscope/cli/__init__.py index c9c5ccbb..83fd9de9 100644 --- a/dashscope/cli2/__init__.py +++ b/dashscope/cli/__init__.py @@ -2,12 +2,12 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """The dashscope CLI (v2). -Deliberately empty. ``import dashscope.cli2`` runs for shell completion and +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.cli2.main` +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.cli2 import main`` resolve to a function or a module +``from dashscope.cli import main`` resolve to a function or a module depending on import order. The console script names the module directly -(``dashscope.cli2.main:main``). +(``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/cli2/app.py b/dashscope/cli/app.py similarity index 64% rename from dashscope/cli2/app.py rename to dashscope/cli/app.py index 54c3012e..e5ac194a 100644 --- a/dashscope/cli2/app.py +++ b/dashscope/cli/app.py @@ -8,9 +8,8 @@ dashscope -k sk-xxx --output json chat create -m qwen-plus --prompt hi The 1.x tree tolerated a trailing ``-k`` by surgically rewriting argv. -That tolerance is deliberately *not* reimplemented here: it only ever -applied to the 1.x parser, and ``main.py`` still hands those invocations -to it untouched. +That tolerance is deliberately *not* reimplemented here: a dangling option +is a command-line mistake, and click already renders one. """ from __future__ import annotations @@ -19,14 +18,10 @@ import typer -from dashscope.cli2.context import CliContext, GlobalOptions -from dashscope.cli2.errors import ( - NO_DEPRECATION_ENV, - ErrorHandlingGroup, - usage_error, -) -from dashscope.cli2.main import LEGACY_DEPRECATED, LEGACY_UNMIGRATED -from dashscope.cli2.output import OUTPUT_FORMATS +from dashscope.cli.context import CliContext, GlobalOptions +from dashscope.cli.errors import ErrorHandlingGroup, usage_error +from dashscope.cli.main import REPLACED_COMMANDS, UNSUPPORTED_COMMANDS +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 @@ -42,25 +37,25 @@ def _epilog() -> str: - """Static list of the 1.x commands that still work through this entry. + """What happened to the 1.x command names. - They are delegated to rather than mounted, so Typer cannot enumerate - them -- and mounting them would mean importing all eighteen 1.x - command modules just to render ``--help``. Generated from the same - constants the router uses, so the list cannot drift from the routing. + ``dashscope`` serves the v2 tree only, so an old name is rejected + rather than delegated. Naming the removals here means ``--help`` + answers the question the rejection raises, before the user has to hit + it. Generated from the same constants the router uses, so the list + cannot drift from the routing. Written as prose because Rich re-flows the epilog to the terminal width; hand-placed line breaks would not survive. """ - deprecated = ", ".join(sorted(LEGACY_DEPRECATED)) - unmigrated = ", ".join(sorted(LEGACY_UNMIGRATED)) + replaced = ", ".join(sorted(REPLACED_COMMANDS)) + unsupported = ", ".join(sorted(UNSUPPORTED_COMMANDS)) return ( - "Legacy 1.x commands still run through this entry point. " - f"Deprecated: {deprecated}. " - f"Awaiting a v2 replacement: {unmigrated}. " - "`expert` forwards to the bundled agent, and a bare `dashscope` " - "opens it interactively. " - f"Set {NO_DEPRECATION_ENV}=1 to silence the deprecation notice." + "1.x command names are not served here; the entry point runs the " + f"v2 CLI only. Replaced by a v2 command: {replaced}. No v2 " + f"equivalent, still available as `dashscope1`: {unsupported}. " + "A bare `dashscope` opens the bundled `expert` agent " + "interactively." ) @@ -183,9 +178,24 @@ def version(ctx: typer.Context) -> None: # 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.cli2.commands import chat as chat_command - from dashscope.cli2.commands import config as config_command - from dashscope.cli2.commands import ft as ft_command + from dashscope.cli.commands import agentstudio as agentstudio_command + from dashscope.cli.commands import apps as apps_command + from dashscope.cli.commands import audio as audio_command + from dashscope.cli.commands import chat as chat_command + from dashscope.cli.commands import config as config_command + from dashscope.cli.commands import decision as decision_command + from dashscope.cli.commands import deployments as deployments_command + from dashscope.cli.commands import embeddings as embeddings_command + from dashscope.cli.commands import files as files_command + from dashscope.cli.commands import ft as ft_command + from dashscope.cli.commands import images as images_command + from dashscope.cli.commands import models as models_command + from dashscope.cli.commands import multimodal as multimodal_command + from dashscope.cli.commands import rerank as rerank_command + from dashscope.cli.commands import rl as rl_command + from dashscope.cli.commands import tingwu as tingwu_command + from dashscope.cli.commands import tokenization as tokenization_command + from dashscope.cli.commands import videos as videos_command app.add_typer(chat_command.build_app(), name="chat") app.add_typer(ft_command.build_app(), name="ft") @@ -193,6 +203,21 @@ def version(ctx: typer.Context) -> None: # 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(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") + 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/cli2/commands/__init__.py b/dashscope/cli/commands/__init__.py similarity index 100% rename from dashscope/cli2/commands/__init__.py rename to dashscope/cli/commands/__init__.py diff --git a/dashscope/cli2/commands/chat.py b/dashscope/cli/commands/chat.py similarity index 67% rename from dashscope/cli2/commands/chat.py rename to dashscope/cli/commands/chat.py index 554a6e43..89237ad3 100644 --- a/dashscope/cli2/commands/chat.py +++ b/dashscope/cli/commands/chat.py @@ -8,12 +8,12 @@ import typer -from dashscope.cli2.errors import usage_error -from dashscope.cli2.input import ( - parse_json_arg, +from dashscope.cli.input import ( 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. @@ -27,61 +27,6 @@ """ -def _resolve_model(ctx: typer.Context, model: Optional[str]) -> str: - resolved = model or ctx.obj.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 - - -def _parse_tools(raw: Optional[str]) -> Optional[List[Dict[str, Any]]]: - 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 _render_completion(renderer, result) -> None: - """Non-streaming output: 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_stream(renderer, stream) -> None: - """Streaming output. - - 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: - for text in stream.text_stream: - renderer.write(text) - renderer.echo() - return - for chunk in stream: - renderer.print_stream_chunk(chunk) - - def build_app() -> typer.Typer: app = typer.Typer(help=_HELP, no_args_is_help=True) @@ -155,9 +100,9 @@ def create( system=system, ) kwargs: Dict[str, Any] = { - "model": _resolve_model(ctx, model), + "model": obj.resolve_model(model), "messages": conversation, - "tools": _parse_tools(tools), + "tools": parse_tools_arg(tools), "temperature": temperature, "top_p": top_p, "max_tokens": max_tokens, @@ -175,12 +120,12 @@ def create( completions = obj.client.chat.completions if not stream: - _render_completion(renderer, completions.create(**kwargs)) + render_completion(renderer, completions.create(**kwargs)) return result = completions.create(stream=True, **kwargs) try: - _render_stream(renderer, result) + render_text_stream(renderer, result) finally: # Runs on Ctrl-C too, so the httpx stream is not left open. result.close() diff --git a/dashscope/cli2/commands/config.py b/dashscope/cli/commands/config.py similarity index 96% rename from dashscope/cli2/commands/config.py rename to dashscope/cli/commands/config.py index fb563c86..da98c330 100644 --- a/dashscope/cli2/commands/config.py +++ b/dashscope/cli/commands/config.py @@ -6,8 +6,8 @@ import typer -from dashscope.cli2 import config_store -from dashscope.cli2.config_store import API_KEY +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. diff --git a/dashscope/cli2/commands/ft.py b/dashscope/cli/commands/ft.py similarity index 77% rename from dashscope/cli2/commands/ft.py rename to dashscope/cli/commands/ft.py index 9b95b277..97120379 100644 --- a/dashscope/cli2/commands/ft.py +++ b/dashscope/cli/commands/ft.py @@ -8,15 +8,15 @@ from __future__ import annotations -import json from typing import Any, Dict, List, Optional import typer -from dashscope.cli2.confirm import confirm_or_die -from dashscope.cli2.errors import usage_error -from dashscope.cli2.input import parse_json_object_arg -from dashscope.cli2.progress import wait_job +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. @@ -65,26 +65,12 @@ def _merge_hyper_parameters( return merged or None -def _render_event(renderer, event) -> None: - """One SSE frame. - - No ``except`` here or in the caller: ``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("") +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.echo( - json.dumps(output, ensure_ascii=False, separators=(",", ":")), - ) + renderer.print_object(job) def build_app() -> typer.Typer: @@ -153,6 +139,8 @@ def create( # pylint: disable=too-many-arguments,too-many-locals """Start a fine-tune job.""" obj = ctx.obj renderer = obj.renderer + if wait: + validate_wait_timeout(timeout) shortcuts = { key: value for key, value in { @@ -186,10 +174,7 @@ def create( # pylint: disable=too-many-arguments,too-many-locals timeout=timeout, renderer=renderer, ) - if renderer.table_mode: - renderer.print_object(job, columns=_JOB_COLUMNS) - else: - renderer.print_object(job) + _render_job(renderer, job) @app.command("get") def get( @@ -200,10 +185,29 @@ def get( obj = ctx.obj renderer = obj.renderer job = obj.client.fine_tunes.get(job_id) - if renderer.table_mode: - renderer.print_object(job, columns=_JOB_COLUMNS) - else: - renderer.print_object(job) + _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( @@ -235,7 +239,37 @@ def stream( obj = ctx.obj renderer = obj.renderer for event in obj.client.fine_tunes.stream_events(job_id): - _render_event(renderer, event) + 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( diff --git a/dashscope/cli2/config_store.py b/dashscope/cli/config_store.py similarity index 70% rename from dashscope/cli2/config_store.py rename to dashscope/cli/config_store.py index 30eaa644..12990623 100644 --- a/dashscope/cli2/config_store.py +++ b/dashscope/cli/config_store.py @@ -14,7 +14,9 @@ * **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. + 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 @@ -28,10 +30,11 @@ import os import stat import sys +import tempfile from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple -from dashscope.cli2.errors import usage_error +from dashscope.cli.errors import usage_error if sys.version_info >= (3, 11): import tomllib @@ -172,7 +175,7 @@ def _enum_choices() -> Dict[str, Tuple[str, Tuple[str, ...]]]: VALID_PROTOCOLS, VALID_REGIONS, ) - from dashscope.cli2.output import ( # pylint: disable=import-outside-toplevel + from dashscope.cli.output import ( # pylint: disable=import-outside-toplevel OUTPUT_FORMATS, ) @@ -188,9 +191,12 @@ def _enum_choices() -> Dict[str, Tuple[str, Tuple[str, ...]]]: # Count-valued keys: a non-negative integer. _NON_NEGATIVE_INTS = ( "max_retries", - "http_max_connections", "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( @@ -228,6 +234,8 @@ def _validate(key: str, value: Any) -> Any: 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(): @@ -235,6 +243,70 @@ def _validate(key: str, value: Any) -> Any: 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, @@ -242,16 +314,14 @@ def _find_key_line( ) -> Optional[int]: """Index of ``key =`` inside *section*, or None.""" current = "" - for index, line in enumerate(lines): - stripped = line.strip() - if stripped.startswith("[") and stripped.endswith("]"): - current = stripped[1:-1].strip() + for index, code in _code_lines(lines): + header = _header_name(code) + if header is not None: + current = header continue - if current != section: + if current != section or "=" not in code: continue - if stripped.startswith("#") or "=" not in stripped: - continue - name = stripped.split("=", 1)[0].strip().strip("\"'") + name = code.split("=", 1)[0].strip().strip("\"'") if name == key: return index return None @@ -263,15 +333,15 @@ def _section_bounds( ) -> Optional[Tuple[int, int]]: """``(header_index, insert_index)`` for *section*, or None if absent.""" header: Optional[int] = None - for index, line in enumerate(lines): - stripped = line.strip() - if stripped.startswith("[") and stripped.endswith("]"): - name = stripped[1:-1].strip() - if header is None: - if name == section: - header = index - elif name != section: - return header, index + 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) @@ -291,8 +361,8 @@ def _append_toplevel_key(lines: List[str], rendered: str) -> None: first_table = next( ( index - for index, line in enumerate(lines) - if line.strip().startswith("[") and line.strip().endswith("]") + for index, code in _code_lines(lines) + if _header_name(code) is not None ), None, ) @@ -356,16 +426,37 @@ def set_value( else: _insert_key(lines, section, rendered) - _write(target, lines, original) + content = _rendered(lines) + data = _parsed_or_refuse(target, content) + _check_landed(target, data, section, bare, value) + _write(target, content) return bare, value -def _write(target: Path, lines: List[str], original: Optional[str]) -> None: +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: - tomllib.loads(content) + 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. @@ -374,12 +465,57 @@ def _write(target: Path, lines: List[str], original: Optional[str]) -> None: 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) - temporary = target.with_name(target.name + ".tmp") + 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: - temporary.write_text(content, encoding="utf-8") - if original is not None and target.is_file(): - os.chmod(temporary, stat.S_IMODE(target.stat().st_mode)) + 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) diff --git a/dashscope/cli2/confirm.py b/dashscope/cli/confirm.py similarity index 85% rename from dashscope/cli2/confirm.py rename to dashscope/cli/confirm.py index c44e1465..9f12789d 100644 --- a/dashscope/cli2/confirm.py +++ b/dashscope/cli/confirm.py @@ -16,7 +16,7 @@ import typer -from dashscope.cli2.errors import usage_error +from dashscope.cli.errors import usage_error def stdin_is_tty() -> bool: @@ -41,5 +41,7 @@ def confirm_or_die(prompt: str, *, yes: bool) -> None: f"refusing to run a destructive command without a terminal; " f"pass --yes to confirm: {prompt}", ) - if not typer.confirm(prompt, default=False): + # 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/cli2/context.py b/dashscope/cli/context.py similarity index 90% rename from dashscope/cli2/context.py rename to dashscope/cli/context.py index fbb324ef..b02fd51b 100644 --- a/dashscope/cli2/context.py +++ b/dashscope/cli/context.py @@ -20,9 +20,9 @@ from dataclasses import dataclass, field from typing import Any, Dict, Optional, Tuple -from dashscope.cli2 import config_store, sdk -from dashscope.cli2.errors import usage_error -from dashscope.cli2.output import TABLE, Renderer, resolve_no_color +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 = { @@ -169,6 +169,20 @@ def sdk_overrides(self) -> Dict[str, Any]: 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 @@ -190,7 +204,7 @@ def renderer(self) -> Renderer: def client(self) -> Any: """The SDK client, built on first use. - Reached through the :mod:`dashscope.cli2.sdk` module attribute + 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: diff --git a/dashscope/cli2/errors.py b/dashscope/cli/errors.py similarity index 90% rename from dashscope/cli2/errors.py rename to dashscope/cli/errors.py index 3b4dce30..e8210e34 100644 --- a/dashscope/cli2/errors.py +++ b/dashscope/cli/errors.py @@ -35,7 +35,7 @@ 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/cli2/test_errors.py`` fails until someone makes it. +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 @@ -50,7 +50,7 @@ import os import sys import traceback -from typing import Any, Dict, NamedTuple, Optional, Tuple +from typing import Any, Dict, NamedTuple, NoReturn, Optional, Tuple import typer from rich.console import Console @@ -110,16 +110,13 @@ "sdk.IoError": USAGE, } -# Environment kill switch for the deprecation notice the entry point -# prints before delegating to the 1.x command tree. -NO_DEPRECATION_ENV = "DASHSCOPE_CLI_NO_DEPRECATION" - 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. ``exit_code`` overrides the category's + 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. """ @@ -128,6 +125,7 @@ def __init__( category: str, message: str, *, + solution: Optional[str] = None, exit_code: Optional[int] = None, ) -> None: super().__init__(message) @@ -135,12 +133,17 @@ def __init__( 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) -> CliError: +def usage_error( + message: str, + *, + solution: Optional[str] = None, +) -> CliError: """Shorthand for the most common CLI-side failure.""" - return CliError(USAGE, message) + return CliError(USAGE, message, solution=solution) def _sdk_error_module() -> Any: @@ -281,7 +284,7 @@ def classify(exc: BaseException) -> Diagnosis: The message is the SDK/server text verbatim; nothing is re-worded. """ if isinstance(exc, CliError): - return Diagnosis(exc.category, exc.message) + return Diagnosis(exc.category, exc.message, solution=exc.solution) sdk_errors = _sdk_error_module() @@ -405,6 +408,28 @@ def _error_console() -> Console: ) +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 @@ -435,27 +460,25 @@ def invoke(self, ctx: typer.Context): # click already rendered a usage message and chose exit 2. raise except BaseException as exc: # pylint: disable=broad-except - diagnosis = classify(exc) - print_error( - diagnosis, - console=_error_console(), - verbose=_verbose_from(ctx), - exc=exc, - ) - obj = getattr(ctx, "obj", None) - if obj is not None: - close = getattr(obj, "close", None) - if callable(close): - try: - close() - except Exception: # pylint: disable=broad-except - pass - override = getattr(exc, "exit_code", None) - sys.exit( - override - if override is not None - else exit_code_for(diagnosis.category), - ) + _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], ...]: diff --git a/dashscope/cli2/input.py b/dashscope/cli/input.py similarity index 92% rename from dashscope/cli2/input.py rename to dashscope/cli/input.py index ab57a1a6..7d7ed312 100644 --- a/dashscope/cli2/input.py +++ b/dashscope/cli/input.py @@ -26,7 +26,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional -from dashscope.cli2.errors import CliError, usage_error +from dashscope.cli.errors import CliError, usage_error STDIN_TOKEN = "-" @@ -116,6 +116,22 @@ def parse_json_object_arg( 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(("{", "[")) diff --git a/dashscope/cli/main.py b/dashscope/cli/main.py new file mode 100644 index 00000000..ad499390 --- /dev/null +++ b/dashscope/cli/main.py @@ -0,0 +1,332 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Console-script entry point for ``dashscope``: the v2 tree, or the agent. + +Everything this entry point runs is SDK 2.0. The 1.x command tree still +ships, and still answers to its own console script ``dashscope1``, but +``dashscope`` no longer delegates to it: a 1.x-only name is *rejected*, with +the v2 spelling that replaces it. Delegating would leave two SDKs reachable +from one command, and the point of the v2 tree is that there is one. + +The single thing still imported from ``legacy_1x`` is ``_route_to_expert``, +the bundled agent behind ``expert`` and a bare ``dashscope``. That is not +part of the 1.x command tree -- it merely lives in the same module -- and +the import is deferred so ``--help`` and every v2 command skip paying for +it. +""" + +from __future__ import annotations + +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" +REMOVED = "removed" + +# Names this entry point serves itself. ``fine-tunes`` is the hidden alias +# the 1.x tree already used for ``ft``, so muscle memory keeps working. +# Pinned to the groups ``app.py`` actually registers by a test. +NEW_COMMANDS = frozenset( + { + "chat", + "ft", + "fine-tunes", + "files", + "models", + "images", + "videos", + "embeddings", + "multimodal", + "rerank", + "tokenization", + "apps", + "deployments", + "rl", + "audio", + "tingwu", + "decision", + "agentstudio", + "config", + "version", + }, +) + +# 1.x command groups whose v2 replacement has landed. The rejection names +# the replacement, so an old script is told what to change into rather than +# only that it stopped working. +# +# argv is deliberately *not* rewritten to the v2 spelling: the two trees do +# not share an option surface (``generation call --prompt`` has no +# ``chat create --prompt``), so a translated command line would fail on the +# first flag and blame the user for a mistake the CLI made. Naming the +# replacement and stopping is honest; guessing at it is not. +REPLACED_COMMANDS = { + "application": "apps completions create", + "agentic-rl": "rl", + "generation": "chat create", + "image-generation": "images generate", + "image-synthesis": "images generate", + "multimodal-conversation": "multimodal chat create", + "multimodal-embedding": "multimodal embeddings create", + "speech-synthesis": "audio tts synthesize", + "transcription": "audio asr transcribe", + "video-synthesis": "videos generate", +} + +# 1.x command groups with no v2 equivalent. ``dashscope1`` still serves +# them, so the rejection can say where to go instead of only saying no. +UNSUPPORTED_COMMANDS = frozenset({"auth", "oss"}) + +REMOVED_COMMANDS = frozenset(REPLACED_COMMANDS) | UNSUPPORTED_COMMANDS + +# 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 _first_command_token(argv: Sequence[str]) -> Optional[str]: + """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 arg + 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`` -- the module global the 1.x + entry point populated before handing over. Nothing else in the v2 tree + consults it; 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 _legacy_group(token: str) -> str: + """The command group *token* names, dotted spelling resolved. + + ``fine_tunes.list`` is the pre-Typer form the 1.x tree translated + through a table of its own. Its group half regularises to the v2 name + by swapping underscores for hyphens, which is what lets the router + judge a dotted spelling without carrying that table again. + """ + return token.partition(".")[0].replace("_", "-") + + +def _replacement_for(token: str) -> Optional[str]: + """The v2 command that takes over *token*, or ``None`` if none does. + + Covers the dotted spellings too: the translation is regular enough to + derive -- ``call`` was the old spelling of ``create`` -- so deriving it + keeps one less table in step with the v2 groups. + """ + if token in REPLACED_COMMANDS: + return REPLACED_COMMANDS[token] + if "." not in token: + return None + group = _legacy_group(token) + sub = token.partition(".")[2] + if group in NEW_COMMANDS: + return f"{group} {'create' if sub == 'call' else sub}" + # ``generation.call``: the group is gone, so fall back to whatever + # replaced it and let that carry the subcommand. + return REPLACED_COMMANDS.get(group) + + +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 classify(argv: Sequence[str]) -> str: + """Decide what serves *argv*: the v2 tree, the agent, or a rejection.""" + token = _first_command_token(argv) + if token is None: + return _classify_without_command(argv) + if token == _AGENT_COMMAND: + return AGENT + if token in NEW_COMMANDS: + return NEW + # A removed 1.x name -- or a dotted spelling of one, whose group half + # decides it. Everything else, unknown or misspelled, goes to the v2 + # parser, so a typo gets a real usage error and the exit code matches + # the new tiers. + if ( + _legacy_group(token) in REMOVED_COMMANDS + or _replacement_for(token) is not None + ): + return REMOVED + return NEW + + +def _removed_error(token: str): + """The rejection for a 1.x name this entry point no longer serves.""" + replacement = _replacement_for(token) + if replacement is None: + return usage_error( + f"`dashscope {token}` is a 1.x command with no v2 equivalent.", + solution=( + f"`dashscope1 {token}` still runs it against the 1.x " + "client. `dashscope --help` lists the v2 commands." + ), + ) + return usage_error( + f"`dashscope {token}` is a 1.x command and is no longer served here.", + solution=( + f"Use `dashscope {replacement}` instead. " + "`dashscope --help` lists the v2 commands." + ), + ) + + +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 + + # Importing this module builds the 1.x Typer app and its eighteen + # command groups as a side effect, so the import stays in here: only an + # agent invocation should pay for it. + # pylint: disable-next=import-outside-toplevel + from dashscope.legacy_1x.cli 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) + target = classify(argv) + + if target is AGENT: + _run_agent(argv) + return + if target is REMOVED: + fail(_removed_error(_first_command_token(argv) or "")) + + from dashscope.cli.app import build_app + + build_app()(argv) diff --git a/dashscope/cli2/output.py b/dashscope/cli/output.py similarity index 71% rename from dashscope/cli2/output.py rename to dashscope/cli/output.py index 853df36f..3bfbc29a 100644 --- a/dashscope/cli2/output.py +++ b/dashscope/cli/output.py @@ -53,13 +53,20 @@ def _strip_transport(value: Any) -> Any: def to_serializable(obj: Any, *, verbose: bool = False) -> Any: """Flatten an SDK response into JSON-ready primitives. - Accepts pydantic models (every v2 response), plain containers, and - scalars. ``model_dump(mode="json")`` already converts datetimes and - enums, so nothing downstream has to know about SDK types. + 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): @@ -240,6 +247,80 @@ def _print_table(self, rows: Sequence[Any], *, columns: Sequence[str]): 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 "-" diff --git a/dashscope/cli2/progress.py b/dashscope/cli/progress.py similarity index 54% rename from dashscope/cli2/progress.py rename to dashscope/cli/progress.py index 248e57b5..5fb11ab5 100644 --- a/dashscope/cli2/progress.py +++ b/dashscope/cli/progress.py @@ -2,8 +2,10 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Waiting on long-running jobs without reimplementing the polling. -``client.fine_tunes.wait`` already does exponential backoff, enforces the -timeout, and raises on FAILED/CANCELED. The CLI's only addition is a +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. @@ -15,17 +17,26 @@ * 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.cli2.output import Renderer +from dashscope.cli.errors import usage_error +from dashscope.cli.output import Renderer def _show_progress(renderer: Renderer) -> bool: @@ -37,6 +48,28 @@ def _show_progress(renderer: Renderer) -> bool: 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, @@ -44,13 +77,22 @@ def wait_job( 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. """ - waiter = client.fine_tunes + validate_wait_timeout(timeout) + if waiter is None: + waiter = client.fine_tunes kwargs: dict = {} if timeout is not None: kwargs["timeout"] = timeout 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/cli2/__main__.py b/dashscope/cli2/__main__.py deleted file mode 100644 index 63292f15..00000000 --- a/dashscope/cli2/__main__.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""``python -m dashscope.cli2``. - -The 1.x tree owns ``python -m dashscope.cli`` and must keep it, so the v2 -entry point is spelled with the same ``2`` the package uses. -""" - -from dashscope.cli2.main import main - -if __name__ == "__main__": - main() diff --git a/dashscope/cli2/main.py b/dashscope/cli2/main.py deleted file mode 100644 index 751ca5bd..00000000 --- a/dashscope/cli2/main.py +++ /dev/null @@ -1,233 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Console-script entry point: route between the v2 and 1.x command trees. - -Both trees answer to ``dashscope``. Rather than mounting the 1.x Typer -sub-apps inside the new root -- which would import all eighteen of their -modules just to render ``--help``, or reach into ``typer.core`` internals -that the ``typer>=0.9`` pin does not guarantee -- this module *delegates*: -the 1.x import is paid only when a 1.x command is actually run, and its -argv quirks (``fine_tunes.call`` spellings, a trailing ``-k``, -``--page-size`` → ``--size``) keep working because the 1.x ``main()`` -still does its own parsing. - -The cost is that Typer cannot enumerate the legacy groups in ``--help``; -``app.py`` prints a static list generated from the same constants below. -""" - -from __future__ import annotations - -import os -import sys -from typing import List, Optional, Sequence, Tuple - -import click - -from dashscope.cli2.errors import NO_DEPRECATION_ENV - -NEW = "new" -LEGACY = "legacy" - -# Names this entry point serves itself. ``fine-tunes`` is the hidden alias -# the 1.x tree already used for ``ft``, so muscle memory keeps working. -NEW_COMMANDS = frozenset({"chat", "ft", "fine-tunes", "config", "version"}) - -# 1.x-only command groups. They have no v2 equivalent yet and are on their -# way out, so invoking one says so. -LEGACY_DEPRECATED = frozenset( - { - "application", - "agentic-rl", - "generation", - "image-generation", - "image-synthesis", - "multimodal-conversation", - "multimodal-embedding", - "speech-synthesis", - "transcription", - "video-synthesis", - }, -) - -# Names the v2 tree will take over later (design doc §4.4/§4.5). Until the -# corresponding command group lands they route to 1.x silently: warning -# about a command that has no replacement yet would only be noise. -LEGACY_UNMIGRATED = frozenset( - { - "auth", - "deployments", - "embeddings", - "files", - "models", - "oss", - "rerank", - "rl", - "tokenization", - }, -) - -# Mirrors ``legacy_1x/cli/__init__.py``'s ``_TOP_LEVEL_COMMANDS``. Not -# imported from there, because importing it is exactly what routing away -# from the 1.x tree is meant to avoid. -LEGACY_ALL = LEGACY_DEPRECATED | LEGACY_UNMIGRATED | {"expert"} - -# Flags that opt into the bundled agent's interactive modes. They are -# stripped by the 1.x ``main()``, and only it knows what to do with them. -_EXPERT_MODE_FLAGS = ("--cli", "--tui") - -# Root options that consume the token after them. Without this the -# scanner would read the *value* as the command -- `dashscope -o json -# generation create` would look like an invocation of `json`. -# Mirrors app.py's callback; tests/cli2/test_app.py pins the two together. -_VALUE_TAKING_OPTIONS = frozenset( - { - "-k", - "--api-key", - "--protocol", - "--base-url", - "--workspace", - "--region", - "--timeout", - "-o", - "--output", - "-c", - }, -) - -# Option spellings that consume the following token as their value in the -# 1.x tree, which tolerates a *trailing* ``-k``. Kept separate from the -# above because they drive the bare-``dashscope`` decision below. -_VALUE_OPTIONS = ("-k", "--api-key") - - -def _first_command_token(argv: Sequence[str]) -> Optional[str]: - """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 arg - return None - - -def _is_only_api_key_options(argv: Sequence[str]) -> bool: - """True when every token is a ``-k``/``--api-key`` pair. - - That is the case the 1.x entry point treats as a bare ``dashscope`` - (it strips the pair and then finds nothing left), so it must keep - opening the interactive agent rather than landing in the v2 tree. - """ - index = 0 - while index < len(argv): - arg = argv[index] - if arg in _VALUE_OPTIONS: - if index + 1 >= len(argv): - return False - index += 2 - continue - if arg.startswith("--api-key="): - index += 1 - continue - return False - return True - - -def _classify_without_command(argv: Sequence[str]) -> Tuple[str, bool]: - """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, False - if not argv or _is_only_api_key_options(argv): - return LEGACY, False - # 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, False - - -def _known_token_target(token: str) -> Optional[Tuple[str, bool]]: - """Route a command token the router recognizes, else ``None``.""" - if token in NEW_COMMANDS: - return NEW, False - if token in LEGACY_DEPRECATED: - return LEGACY, True - if token in LEGACY_UNMIGRATED: - return LEGACY, False - return None - - -def classify(argv: Sequence[str]) -> Tuple[str, bool]: - """Decide which tree serves *argv*. Returns ``(target, is_deprecated)``.""" - if any(arg in _EXPERT_MODE_FLAGS for arg in argv): - return LEGACY, False - - token = _first_command_token(argv) - if token is None: - return _classify_without_command(argv) - if token == "expert" or "." in token: - # `expert` forwards to the bundled agent; the dotted spellings are - # the pre-Typer ``fine_tunes.call`` style the 1.x tree translates. - return LEGACY, False - - known = _known_token_target(token) - if known is not None: - return known - # Unknown or misspelled: the v2 tree produces a real usage error - # instead of the 1.x tree's, so the exit code matches the new tiers. - return NEW, False - - -def _deprecation_notice(command: str) -> str: - return ( - f"Note: `dashscope {command}` is served by the 1.x command tree, " - f"which is deprecated and will be removed.\n" - f" Run `dashscope --help` for the v2 commands. Set " - f"{NO_DEPRECATION_ENV}=1 to silence this notice." - ) - - -def _run_legacy(args: Optional[Sequence[str]]) -> None: - """Hand the invocation to the 1.x entry point. - - The 1.x ``main()`` parses ``sys.argv`` itself and depends on seeing it - pristine -- it rewrites argv for dotted command spellings and extracts - a trailing ``-k``. So the console-script path passes nothing and lets - it read the real argv. Only an explicit ``args`` (a programmatic or - test call) needs sys.argv aligned to it. - """ - if args is not None: - sys.argv = [sys.argv[0] if sys.argv else "dashscope", *args] - from dashscope.legacy_1x.cli import main as legacy_main - - legacy_main() - - -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) - target, deprecated = classify(argv) - - if target == LEGACY: - if deprecated and not os.environ.get(NO_DEPRECATION_ENV): - click.echo( - _deprecation_notice(_first_command_token(argv)), - err=True, - ) - _run_legacy(argv if args is not None else None) - return - - from dashscope.cli2.app import build_app - - build_app()(argv) diff --git a/dashscope/cli2/sdk.py b/dashscope/cli2/sdk.py deleted file mode 100644 index 4f94e193..00000000 --- a/dashscope/cli2/sdk.py +++ /dev/null @@ -1,33 +0,0 @@ -# -*- 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, Mapping - -# 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" - - -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. - """ - # pylint: disable=import-outside-toplevel - from dashscope import Client - - return Client(user_agent_suffix=USER_AGENT_SUFFIX, **dict(overrides)) 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/resources/chat/completions.py b/dashscope/resources/chat/completions.py index ac195e79..ef906d7f 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) 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/README.md b/examples/05_cli/README.md index 9900f531..8f04b70b 100644 --- a/examples/05_cli/README.md +++ b/examples/05_cli/README.md @@ -25,10 +25,12 @@ dashscope version From a source checkout where the script is not installed, the module entry point is identical: ```bash -python3 -m dashscope.cli2 --help -python3 -m dashscope.cli2 version +python -m dashscope.cli --help +python -m dashscope.cli version ``` +> The 1.x CLI lives at a separate entry point (`dashscope1` / `python -m dashscope.legacy_1x.cli`). See [`../legacy_1x/`](../legacy_1x/) for details. + Set the key once, either way: ```bash @@ -55,15 +57,75 @@ That last one is not a quirk — it is the single most common way to get a confu | `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 same entry point still runs the 1.x commands, so old shell scripts keep working. `main.py` sorts argv into three buckets: the v2 tree above, a **deprecated** set (`generation`, `application`, `image-synthesis`, `multimodal-conversation`, `speech-synthesis`, `transcription`, `video-synthesis`, `image-generation`, `multimodal-embedding`, `agentic-rl`) that prints a notice before delegating, and an **awaiting-v2** set (`auth`, `deployments`, `embeddings`, `files`, `models`, `oss`, `rerank`, `rl`, `tokenization`) that delegates silently. Set `DASHSCOPE_CLI_NO_DEPRECATION=1` to silence the notice in a build log. A bare `dashscope` with no subcommand opens the bundled `expert` agent interactively. +The entry point runs the v2 CLI only: it does **not** delegate to the 1.x tree, so an old name is rejected with exit code 2 instead of quietly running against the old client. `main.py` sorts argv into three buckets — the v2 tree above, a **replaced** set (`generation`, `application`, `image-synthesis`, `multimodal-conversation`, `speech-synthesis`, `transcription`, `video-synthesis`, `image-generation`, `multimodal-embedding`, `agentic-rl`) whose error names the v2 command that took over, and an **unsupported** set (`auth`, `oss`) with no v2 equivalent yet. The rejection names the replacement for you — `REPLACED_COMMANDS` in `main.py` is the table it reads, so `dashscope video-synthesis create ...` tells you to use `dashscope videos generate` instead of leaving you to hunt for it. The pre-Typer dotted spellings are rejected the same way and resolve through the same table: `dashscope fine_tunes.list` points at `dashscope fine-tunes list`. Anything you still need from the 1.x client is reachable as `dashscope1`. 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. --- @@ -120,7 +182,7 @@ Error [ratelimit]: Rate limit exceeded. `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 (`cli2/errors.py`, `_STATUS_CATEGORY`): +How an HTTP status becomes a category (`cli/errors.py`, `_STATUS_CATEGORY`): | Status | Category | Exit | | --- | --- | --- | @@ -288,6 +350,104 @@ dashscope ft create -m qwen-plus -t "$FILE_ID" --n-epochs 2 --wait --timeout 180 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 @@ -305,10 +465,10 @@ dashscope config set api_key "$DASHSCOPE_API_KEY" dashscope chat create --prompt "hi" # -m omitted: comes from default_model ``` -**Clean logs** — no colour, no deprecation notice: +**Clean logs** — no colour: ```bash -export NO_COLOR=1 DASHSCOPE_CLI_NO_DEPRECATION=1 +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. @@ -320,18 +480,18 @@ export NO_COLOR=1 DASHSCOPE_CLI_NO_DEPRECATION=1 Both run offline and deterministically; neither needs a key. From the repository root, with the checkout importable: ```bash -PYTHONPATH=. python3 examples/05_cli/cli_output_and_errors.py -PYTHONPATH=. python3 examples/05_cli/cli_ci_harness.py +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, `python3 examples/05_cli/cli_output_and_errors.py` is enough. The harness sets it for its own child processes, so it works either way.) +(`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 python3 examples/05_cli/cli_ci_harness.py +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. @@ -342,9 +502,17 @@ Live mode skips every fault-injection scenario and **never** runs `ft cancel` or 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 `ft cancel` and `ft delete`, 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` does. The source comment says why: a chat default is not a fine-tune base. +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`. See also [`../README.md`](../README.md) for how this tier fits the rest, and [`../02_advanced_engineering/local_mock_debugging.py`](../02_advanced_engineering/local_mock_debugging.py) for the SDK-side equivalent of the mock gateway the harness builds here. diff --git a/examples/05_cli/README_zh.md b/examples/05_cli/README_zh.md index e2b444c8..e750e588 100644 --- a/examples/05_cli/README_zh.md +++ b/examples/05_cli/README_zh.md @@ -25,10 +25,12 @@ dashscope version 在没装 console script 的源码检出里,模块入口完全等价: ```bash -python3 -m dashscope.cli2 --help -python3 -m dashscope.cli2 version +python -m dashscope.cli --help +python -m dashscope.cli version ``` +> 1.x CLI 有独立的入口(`dashscope1` / `python -m dashscope.legacy_1x.cli`),详见 [`../legacy_1x/`](../legacy_1x/)。 + 密钥配一次,两种方式都行: ```bash @@ -55,15 +57,75 @@ dashscope chat create -m qwen-plus --prompt "hi" --output json # exi | `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 命令,所以老的 shell 脚本不会断。`main.py` 把 argv 分成三类:上面这棵 v2 命令树、一组**已废弃**命令(`generation`、`application`、`image-synthesis`、`multimodal-conversation`、`speech-synthesis`、`transcription`、`video-synthesis`、`image-generation`、`multimodal-embedding`、`agentic-rl`,委派前先打印提示),以及一组**等待 v2 替代**的命令(`auth`、`deployments`、`embeddings`、`files`、`models`、`oss`、`rerank`、`rl`、`tokenization`,静默委派)。设 `DASHSCOPE_CLI_NO_DEPRECATION=1` 可以在构建日志里静音这条提示。不带子命令的裸 `dashscope` 会交互式打开内置的 `expert` agent。 +这个入口只跑 v2 CLI:它**不再**委派给 1.x 命令树,所以旧命令名会以退出码 2 被拒绝,而不是悄悄跑在旧客户端上。`main.py` 把 argv 分成三类——上面这棵 v2 命令树、一组**已被替代**的命令(`generation`、`application`、`image-synthesis`、`multimodal-conversation`、`speech-synthesis`、`transcription`、`video-synthesis`、`image-generation`、`multimodal-embedding`、`agentic-rl`,报错时会给出接管它的 v2 命令),以及一组**尚无 v2 等价物**的命令(`auth`、`oss`)。报错会直接告诉你 v2 的对应写法——它读的是 `main.py` 里的 `REPLACED_COMMANDS` 表,所以 `dashscope video-synthesis create ...` 会提示你改用 `dashscope videos generate`,而不是让你自己去翻。Typer 之前的点号写法同样被拒绝,并走同一张表解析:`dashscope fine_tunes.list` 会指向 `dashscope fine-tunes list`。仍然需要 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 暴露的其余东西都有命令了。 --- @@ -120,7 +182,7 @@ Error [ratelimit]: Rate limit exceeded. `Error [category]: message` 是稳定的;两行 dim 只在有内容可说时才出现。`request_id` 是提工单需要的东西,`solution` 是注册表自带的指引——单独一行,这样你永远能分清哪些字来自网关。 -HTTP 状态码如何变成 category(`cli2/errors.py` 的 `_STATUS_CATEGORY`): +HTTP 状态码如何变成 category(`cli/errors.py` 的 `_STATUS_CATEGORY`): | 状态码 | category | 退出码 | | --- | --- | --- | @@ -288,6 +350,104 @@ dashscope ft create -m qwen-plus -t "$FILE_ID" --n-epochs 2 --wait --timeout 180 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 @@ -305,10 +465,10 @@ dashscope config set api_key "$DASHSCOPE_API_KEY" dashscope chat create --prompt "hi" # 不传 -m:来自 default_model ``` -**干净的日志**——不要颜色、不要废弃提示: +**干净的日志**——不要颜色: ```bash -export NO_COLOR=1 DASHSCOPE_CLI_NO_DEPRECATION=1 +export NO_COLOR=1 ``` `NO_COLOR=1` 和 `--no-color` 是一回事。Rich 本来就能识别管道,所以这一条主要是给那种「先分配伪终端再把它倒进日志」的 runner 用的。 @@ -320,18 +480,18 @@ export NO_COLOR=1 DASHSCOPE_CLI_NO_DEPRECATION=1 两个都离线、确定性,都不需要密钥。在仓库根目录、且检出可导入的情况下: ```bash -PYTHONPATH=. python3 examples/05_cli/cli_output_and_errors.py -PYTHONPATH=. python3 examples/05_cli/cli_ci_harness.py +PYTHONPATH=. python examples/05_cli/cli_output_and_errors.py +PYTHONPATH=. python examples/05_cli/cli_ci_harness.py ``` -(只有包没被 pip 安装时才需要 `PYTHONPATH=.`。装了的话 `python3 examples/05_cli/cli_output_and_errors.py` 就够了。harness 会为自己的子进程设好这个变量,所以两种情况下都能跑。) +(只有包没被 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 python3 examples/05_cli/cli_ci_harness.py +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,正是「示例删掉了重要东西」的标准死法。 @@ -342,9 +502,17 @@ Live 模式跳过所有故障注入场景,并且**绝不**运行 `ft cancel` 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。你的流水线需要在 `ft cancel` 和 `ft delete` 上加 `--yes`,而且必须是显式加——因为重点就在于这个命令不肯替你做决定。 -4. **`ft create -m` 永不回落到 `default_model`。**只有 `chat create` 会。源码注释写了原因:chat 的默认模型不是微调基座。 +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`。 另见 [`../README_zh.md`](../README_zh.md) 了解这一层在整体中的位置,以及 [`../02_advanced_engineering/local_mock_debugging.py`](../02_advanced_engineering/local_mock_debugging.py)——那是这里的 mock 网关在 SDK 侧的对应物。 diff --git a/examples/05_cli/cli_ci_harness.py b/examples/05_cli/cli_ci_harness.py index 395cc0de..989d5777 100644 --- a/examples/05_cli/cli_ci_harness.py +++ b/examples/05_cli/cli_ci_harness.py @@ -31,12 +31,12 @@ Run it offline (the default):: - python3 examples/05_cli/cli_ci_harness.py + 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 \ - python3 examples/05_cli/cli_ci_harness.py + 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. @@ -77,7 +77,7 @@ REQUEST_ID = "req-mock-0001" USAGE = {"input_tokens": 18, "output_tokens": 22, "total_tokens": 40} -# The four tiers, mirroring dashscope/cli2/errors.py. +# 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 @@ -338,11 +338,11 @@ def _start_gateway() -> Tuple[ThreadingHTTPServer, str]: def _cli(*args: str) -> List[str]: """The command prefix that runs the real CLI in a subprocess. - ``python -m dashscope.cli2`` rather than the ``dashscope`` console + ``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.cli2", *args] + return [sys.executable, "-m", "dashscope.cli", *args] def _child_env(config_path: str) -> Dict[str, str]: @@ -713,10 +713,8 @@ def _scenario_ci_hygiene(base_url, env) -> None: "allocate a pseudo-terminal and then dump it into a log.", ) print( - " Also worth setting: DASHSCOPE_CLI_NO_DEPRECATION=1 to silence", - "the notice printed when a 1.x command is delegated to the legacy", - "tree, and DASHSCOPE_CONFIG to a job-local path so parallel jobs", - "never share one file.", + " Also worth setting: DASHSCOPE_CONFIG to a job-local path so", + "parallel jobs never share one file.", ) diff --git a/examples/05_cli/cli_output_and_errors.py b/examples/05_cli/cli_output_and_errors.py index e110c914..beca3585 100644 --- a/examples/05_cli/cli_output_and_errors.py +++ b/examples/05_cli/cli_output_and_errors.py @@ -8,7 +8,7 @@ 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.cli2.sdk.build_client``) with a fake client that returns +(``dashscope.cli.sdk.build_client``) with a fake client that returns canned -- but genuinely typed -- SDK responses. Five demonstrations: @@ -24,7 +24,7 @@ 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/cli2/errors.py`` is what + 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 @@ -32,7 +32,7 @@ Run it:: - python3 examples/05_cli/cli_output_and_errors.py + 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 @@ -51,8 +51,8 @@ from typer.testing import CliRunner from dashscope import errors as sdk_errors -from dashscope.cli2 import sdk as cli_sdk -from dashscope.cli2.app import build_app +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 @@ -74,7 +74,7 @@ USAGE = {"input_tokens": 18, "output_tokens": 22, "total_tokens": 40} JOB_ID = "ft-20260101-demo" -# The four tiers, mirroring dashscope/cli2/errors.py. +# 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 @@ -207,7 +207,7 @@ def delete(self, job_id, **_): def _fake_build_client(overrides): - """Replacement for ``dashscope.cli2.sdk.build_client``. + """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 diff --git a/examples/README.md b/examples/README.md index ffcc4444..acb2bfc2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -58,7 +58,7 @@ 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`, `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. @@ -149,7 +149,7 @@ The same capabilities behind a **shell** contract rather than a Python one: thre | `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 | -The command surface itself (`chat create`, `ft create|get|list|stream|cancel|delete`, `config set|get|list`, `version`), the full exit-code and precedence tables, and copy-pasteable shell recipes live in the tier's own README. +The command surface itself (`chat`, `ft`, `files`, `models`, `images`, `videos`, `embeddings`, `multimodal`, `rerank`, `tokenization`, `apps`, `deployments`, `rl`, `audio`, `tingwu`, `decision`, `agentstudio`, `config`, `version`), the full exit-code and precedence tables, and copy-pasteable shell recipes live in the tier's own README. See [`05_cli/README.md`](./05_cli/README.md). @@ -157,6 +157,8 @@ See [`05_cli/README.md`](./05_cli/README.md). 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:** the legacy command tree is available as `dashscope1` (console script) or `python -m dashscope.legacy_1x.cli` (module entry). It is separate from the 2.0 `dashscope` command documented in [`05_cli/`](./05_cli/). + | 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 c8c087c7..c5821840 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -58,7 +58,7 @@ 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`、`05_cli/cli_output_and_errors.py` 和 `05_cli/cli_ci_harness.py` 使用 mock transport / 本地 mock 网关 / 脚本化接缝 / 子进程——完全离线且输出确定,适合先动手感受调用形态。 @@ -149,7 +149,7 @@ examples/ | `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` | 否 | -命令面本身(`chat create`、`ft create|get|list|stream|cancel|delete`、`config set|get|list`、`version`)、完整的退出码与优先级表格,以及可直接复制的 shell 配方,都在这一层自己的 README 里。 +命令面本身(`chat`、`ft`、`files`、`models`、`images`、`videos`、`embeddings`、`multimodal`、`rerank`、`tokenization`、`apps`、`deployments`、`rl`、`audio`、`tingwu`、`decision`、`agentstudio`、`config`、`version`)、完整的退出码与优先级表格,以及可直接复制的 shell 配方,都在这一层自己的 README 里。 详见 [`05_cli/README.md`](./05_cli/README.md)。 @@ -157,6 +157,8 @@ examples/ 以下使用旧的 1.x 风格(`dashscope.Generation`、`dashscope.MultiModalConversation` 等)。内部子目录布局原样保留、内容不做改动。新项目请使用上面的 2.0 分层示例。 +**1.x CLI:** 历史命令树通过 `dashscope1`(console script)或 `python -m dashscope.legacy_1x.cli`(模块入口)访问,与 [`05_cli/`](./05_cli/) 中的 2.0 `dashscope` 命令相互独立。 + | 路径 | 内容 | | --- | --- | | `generation/` | Generation 文本生成:基础、深度思考、工具调用、深度研究、自定义会话 | diff --git a/setup.py b/setup.py index 74e85c20..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.cli2.main:main"], + "console_scripts": [ + "dashscope = dashscope.cli.main:main", + "dashscope1 = dashscope.legacy_1x.cli:main", + ], }, ) diff --git a/tests/cli2/__init__.py b/tests/cli/__init__.py similarity index 100% rename from tests/cli2/__init__.py rename to tests/cli/__init__.py diff --git a/tests/cli2/conftest.py b/tests/cli/conftest.py similarity index 97% rename from tests/cli2/conftest.py rename to tests/cli/conftest.py index f69fcc91..3d9794ab 100644 --- a/tests/cli2/conftest.py +++ b/tests/cli/conftest.py @@ -33,7 +33,6 @@ "DASHSCOPE_LOG", "DASHSCOPE_CONFIG", "DASHSCOPE_API_KEY_FILE_PATH", - "DASHSCOPE_CLI_NO_DEPRECATION", "NO_COLOR", "FORCE_COLOR", ) @@ -117,7 +116,7 @@ def build(overrides: Dict[str, Any]) -> SimpleNamespace: calls.append(dict(overrides)) return client - monkeypatch.setattr("dashscope.cli2.sdk.build_client", build) + monkeypatch.setattr("dashscope.cli.sdk.build_client", build) return client install.calls = calls diff --git a/tests/cli2/test_app.py b/tests/cli/test_app.py similarity index 95% rename from tests/cli2/test_app.py rename to tests/cli/test_app.py index a04aed26..0b73159d 100644 --- a/tests/cli2/test_app.py +++ b/tests/cli/test_app.py @@ -8,9 +8,9 @@ import typer from typer.testing import CliRunner -from dashscope.cli2.app import build_app -from dashscope.cli2.errors import ErrorHandlingGroup -from dashscope.cli2.main import _VALUE_TAKING_OPTIONS +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__ @@ -44,14 +44,18 @@ def test_completion_is_enabled(self, app): assert "--install-completion" in opts assert "--show-completion" in opts - def test_help_lists_both_the_v2_and_the_1x_commands(self, app): + 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 - # Delegated commands cannot be enumerated by typer, so the epilog - # carries them; assert one from each set. + # 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): diff --git a/tests/cli2/test_chat_cmd.py b/tests/cli/test_chat_cmd.py similarity index 93% rename from tests/cli2/test_chat_cmd.py rename to tests/cli/test_chat_cmd.py index 953f0b1e..10bc7d9d 100644 --- a/tests/cli2/test_chat_cmd.py +++ b/tests/cli/test_chat_cmd.py @@ -11,7 +11,7 @@ import pytest from dashscope import errors as sdk_errors -from dashscope.cli2.app import build_app +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 ( @@ -134,7 +134,7 @@ def test_falls_back_to_the_configured_default( completions, clean_env, ): - from dashscope.cli2 import config_store + from dashscope.cli import config_store config_store.set_value("default_model", "qwen-plus") recorder = completions() @@ -147,7 +147,7 @@ def test_flag_beats_the_configured_default( completions, clean_env, ): - from dashscope.cli2 import config_store + from dashscope.cli import config_store config_store.set_value("default_model", "qwen-plus") recorder = completions() @@ -367,6 +367,46 @@ def test_table_writes_incremental_text(self, invoke, completions): 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"])) diff --git a/tests/cli2/test_config_store.py b/tests/cli/test_config_store.py similarity index 63% rename from tests/cli2/test_config_store.py rename to tests/cli/test_config_store.py index 7fc79fb3..de9c77fe 100644 --- a/tests/cli2/test_config_store.py +++ b/tests/cli/test_config_store.py @@ -7,7 +7,8 @@ import pytest -from dashscope.cli2.config_store import ( +from dashscope.cli import config_store +from dashscope.cli.config_store import ( CLI_SECTION, effective_values, get_value, @@ -20,7 +21,7 @@ restrict_permissions, set_value, ) -from dashscope.cli2.errors import CliError +from dashscope.cli.errors import CliError @pytest.fixture @@ -28,6 +29,16 @@ 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", @@ -99,6 +110,8 @@ class TestValidation: ("timeout", "abc"), ("max_retries", "-1"), ("http_keepalive_expiry", "0"), + ("http_max_connections", "0"), + ("http_max_connections", "-1"), ("api_key", ""), ], ) @@ -107,6 +120,19 @@ def test_rejected(self, key, value, config): 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 @@ -196,6 +222,98 @@ def test_missing_directory_is_created(self, tmp_path): 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, @@ -210,7 +328,7 @@ def test_refuses_to_write_invalid_toml_and_leaves_the_original( assert info.value.category == "usage" assert "invalid TOML" in info.value.message assert config.read_text(encoding="utf-8") == original - assert not (config.parent / (config.name + ".tmp")).exists() + assert not list(config.parent.glob("*.tmp")) def test_existing_invalid_file_is_reported_not_overwritten_silently( self, @@ -233,6 +351,54 @@ def test_file_mode_is_preserved_across_rewrites(self, config): 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") diff --git a/tests/cli2/test_confirm.py b/tests/cli/test_confirm.py similarity index 87% rename from tests/cli2/test_confirm.py rename to tests/cli/test_confirm.py index d92bb24b..b2489405 100644 --- a/tests/cli2/test_confirm.py +++ b/tests/cli/test_confirm.py @@ -6,8 +6,8 @@ import pytest -from dashscope.cli2.confirm import confirm_or_die, stdin_is_tty -from dashscope.cli2.errors import CliError +from dashscope.cli.confirm import confirm_or_die, stdin_is_tty +from dashscope.cli.errors import CliError class _Tty(io.StringIO): @@ -39,7 +39,7 @@ def fake_confirm(prompt, **kwargs): return answer monkeypatch.setattr( - "dashscope.cli2.confirm.typer.confirm", + "dashscope.cli.confirm.typer.confirm", fake_confirm, ) return asked @@ -103,6 +103,12 @@ def test_default_is_no(self, tty, answers): 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: diff --git a/tests/cli2/test_context.py b/tests/cli/test_context.py similarity index 96% rename from tests/cli2/test_context.py rename to tests/cli/test_context.py index 037584f9..daca9d68 100644 --- a/tests/cli2/test_context.py +++ b/tests/cli/test_context.py @@ -4,10 +4,10 @@ import pytest -from dashscope.cli2.config_store import set_value -from dashscope.cli2.context import CliContext, CliDefaults, GlobalOptions -from dashscope.cli2.errors import CliError -from dashscope.cli2.output import JSON, TABLE, YAML +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: @@ -126,7 +126,7 @@ def test_config_file_is_not_read_until_needed( ): calls = [] monkeypatch.setattr( - "dashscope.cli2.config_store.load_cli_section", + "dashscope.cli.config_store.load_cli_section", lambda *a, **k: calls.append(1) or {}, ) ctx = _ctx() diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py new file mode 100644 index 00000000..402a42a3 --- /dev/null +++ b/tests/cli/test_dispatch.py @@ -0,0 +1,324 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Routing at the ``dashscope`` console-script entry point. + +The entry point serves the v2 tree or the bundled agent, and rejects 1.x +command names. What it must never do is hand a command line to the 1.x +tree, so several tests below assert on the *absence* of that call. +""" + +import sys +from types import ModuleType + +import pytest +import typer + +from dashscope.cli.errors import EXIT_USAGE +from dashscope.cli.main import ( + AGENT, + NEW, + NEW_COMMANDS, + REMOVED, + REMOVED_COMMANDS, + REPLACED_COMMANDS, + UNSUPPORTED_COMMANDS, + classify, + main, +) + + +class TestClassify: + @pytest.mark.parametrize( + "argv", + [ + ["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"], + # A misspelled command must reach the v2 parser so the exit + # code matches the new tiers (2), not the 1.x tree's. + ["chatz", "create"], + ["auth_x"], + # A stray option with no command is a command-line mistake. + ["-k"], + ["--verbose"], + # --help with no command describes the tree being written + # against from here on. + ["--help"], + ["-h"], + ["-k", "sk-1", "--help"], + ], + ) + def test_new_tree(self, argv): + assert classify(argv) == NEW + + @pytest.mark.parametrize( + "argv", + [ + [], # bare `dashscope` → TUI + ["-k", "sk-1"], # nothing but the key → still the agent + ["--api-key=sk-1"], + ["expert", "what", "is", "lora"], + ["expert", "--help"], + ["--cli"], + ["--tui"], + ], + ) + def test_agent(self, argv): + assert classify(argv) == AGENT + + @pytest.mark.parametrize("name", sorted(REMOVED_COMMANDS)) + def test_removed_1x_names(self, name): + assert classify([name, "list"]) == REMOVED + + @pytest.mark.parametrize( + "token", + [ + "fine_tunes.list", + "files.upload", + "deployments.call", + "generation.call", + "oss.upload", + "auth.whoami", + ], + ) + def test_dotted_spellings_do_not_reach_the_1x_tree(self, token): + """The pre-Typer form is judged by its group, like any other name.""" + assert classify([token, "--page-size", "5"]) == REMOVED + + def test_a_dotted_spelling_of_an_unknown_group_is_a_plain_typo(self): + assert classify(["chatz.create"]) == NEW + + def test_ft_and_its_alias_stay_together(self): + """`ft` and hidden `fine-tunes` must land in the same tree.""" + assert ( + classify(["ft", "list"]) == classify(["fine-tunes", "list"]) == NEW + ) + + def test_global_options_do_not_hide_the_command(self): + assert ( + classify(["-k", "sk-1", "--output", "json", "ft", "list"]) == NEW + ) + assert classify(["-k", "sk-1", "generation", "create"]) == REMOVED + + def test_value_of_a_global_option_is_not_read_as_the_command(self): + """``-o json`` must not make ``json`` look like the command.""" + assert classify(["-o", "json", "generation", "create"]) == REMOVED + assert classify(["--output", "json", "ft", "list"]) == NEW + assert ( + classify(["-c", "output=json", "generation", "create"]) == REMOVED + ) + assert classify(["--timeout", "30", "oss", "list"]) == REMOVED + + def test_self_contained_option_forms(self): + assert classify(["--api-key=sk-1", "generation", "create"]) == REMOVED + assert classify(["--output=json", "ft", "list"]) == NEW + + def test_a_mode_flag_does_not_rescue_a_removed_command(self): + """`--cli generation create` names a command, so it is not a chat.""" + assert classify(["--cli", "generation", "create"]) == REMOVED + + +class TestCommandNameSets: + def test_removed_and_served_names_do_not_overlap(self): + assert NEW_COMMANDS.isdisjoint(REMOVED_COMMANDS) + assert set(REPLACED_COMMANDS).isdisjoint(UNSUPPORTED_COMMANDS) + + def test_removed_names_partition_into_replaced_and_unsupported(self): + assert ( + REMOVED_COMMANDS == set(REPLACED_COMMANDS) | UNSUPPORTED_COMMANDS + ) + + def test_every_replacement_names_a_command_the_v2_tree_serves(self): + """The rejection can only help if the mapping stays real.""" + for replacement in REPLACED_COMMANDS.values(): + assert replacement.split()[0] in NEW_COMMANDS + + def test_new_command_names_match_the_registered_tree(self): + """The router's view of v2 must not drift from the app's.""" + 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): + """Replace the bundled agent with a recorder. + + Injected into ``sys.modules`` rather than patched on the real module so + the test never pays for importing the 1.x Typer tree. Its ``main`` is a + tripwire: reaching the agent must not drag the 1.x command tree in + behind it, which is what importing the real module would do. + """ + calls = [] + + def route_to_expert(command, tui=None): + calls.append((command, tui)) + + def legacy_main(): # pragma: no cover - must never be called + raise AssertionError("the 1.x command tree was invoked") + + module = ModuleType("dashscope.legacy_1x.cli") + module._route_to_expert = route_to_expert + module.main = legacy_main + monkeypatch.setitem(sys.modules, "dashscope.legacy_1x.cli", 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_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): + """The agent reads the ``dashscope.api_key`` global, so set it.""" + import dashscope + + monkeypatch.setattr(dashscope, "api_key", None, raising=False) + main(["-k", "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 + + +class TestRemovedCommands: + def test_rejection_exits_with_the_usage_tier(self, new_app, expert): + with pytest.raises(SystemExit) as exited: + main(["generation", "create"]) + assert exited.value.code == EXIT_USAGE + assert new_app == [] + assert expert == [] + + def test_rejection_names_the_v2_replacement(self, capsys, new_app, expert): + with pytest.raises(SystemExit): + main(["video-synthesis", "create"]) + err = capsys.readouterr().err + assert "Error [usage]" in err + assert "`dashscope video-synthesis`" in err + assert "`dashscope videos generate`" in err + + def test_rejection_names_the_command_even_after_global_options( + self, + capsys, + new_app, + expert, + ): + with pytest.raises(SystemExit): + main(["-k", "sk-1", "generation", "create"]) + assert "`dashscope generation`" in capsys.readouterr().err + + def test_rejection_goes_to_stderr_not_stdout( + self, + capsys, + new_app, + expert, + ): + with pytest.raises(SystemExit): + main(["generation", "create"]) + assert capsys.readouterr().out == "" + + @pytest.mark.parametrize("name", sorted(UNSUPPORTED_COMMANDS)) + def test_unsupported_names_point_at_the_1x_entry_point( + self, + capsys, + new_app, + expert, + name, + ): + with pytest.raises(SystemExit): + main([name, "list"]) + err = capsys.readouterr().err + assert "no v2 equivalent" in err + assert f"dashscope1 {name}" in err + + def test_dotted_spelling_points_at_its_v2_command( + self, + capsys, + new_app, + expert, + ): + with pytest.raises(SystemExit): + main(["fine_tunes.list", "--page-size", "5"]) + assert "`dashscope fine-tunes list`" in capsys.readouterr().err + + def test_no_environment_switch_mutes_a_rejection( + self, + capsys, + new_app, + expert, + monkeypatch, + ): + """It is an error now, not a notice, so the old kill switch is dead.""" + monkeypatch.setenv("DASHSCOPE_CLI_NO_DEPRECATION", "1") + with pytest.raises(SystemExit): + main(["generation", "create"]) + assert "`dashscope generation`" in capsys.readouterr().err diff --git a/tests/cli2/test_errors.py b/tests/cli/test_errors.py similarity index 99% rename from tests/cli2/test_errors.py rename to tests/cli/test_errors.py index cf3fc6fe..ded44ffe 100644 --- a/tests/cli2/test_errors.py +++ b/tests/cli/test_errors.py @@ -11,8 +11,8 @@ from dashscope import _error_registry as registry from dashscope import errors as sdk_errors -from dashscope.cli2 import errors as cli_errors -from dashscope.cli2.errors import ( +from dashscope.cli import errors as cli_errors +from dashscope.cli.errors import ( AUTH, EXIT_AUTH, EXIT_NOT_FOUND, @@ -30,7 +30,7 @@ exit_code_for, usage_error, ) -from tests.cli2.conftest import strip_ansi_codes +from tests.cli.conftest import strip_ansi_codes def _runner() -> CliRunner: diff --git a/tests/cli2/test_ft_cmd.py b/tests/cli/test_ft_cmd.py similarity index 69% rename from tests/cli2/test_ft_cmd.py rename to tests/cli/test_ft_cmd.py index 2cbc1a80..695acb4d 100644 --- a/tests/cli2/test_ft_cmd.py +++ b/tests/cli/test_ft_cmd.py @@ -10,11 +10,12 @@ import pytest from dashscope import errors as sdk_errors -from dashscope.cli2.app import build_app +from dashscope.cli.app import build_app from dashscope.resources.fine_tunes import ( FineTune, FineTuneEvent, FineTuneList, + JobLogs, ) from dashscope.types.common import JobStatus, Usage @@ -65,6 +66,9 @@ 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) @@ -96,9 +100,9 @@ def install(**results: Any) -> _FineTunes: def _as_terminal_tty(monkeypatch, answer: bool): - monkeypatch.setattr("dashscope.cli2.confirm.stdin_is_tty", lambda: True) + monkeypatch.setattr("dashscope.cli.confirm.stdin_is_tty", lambda: True) monkeypatch.setattr( - "dashscope.cli2.confirm.typer.confirm", + "dashscope.cli.confirm.typer.confirm", lambda *a, **k: answer, ) @@ -116,7 +120,7 @@ def test_the_model_does_not_fall_back_to_default_model( clean_env, ): """A chat default is not a fine-tune base model.""" - from dashscope.cli2 import config_store + from dashscope.cli import config_store config_store.set_value("default_model", "qwen-plus") result = invoke(["ft", "create", "-t", "file-1"]) @@ -318,6 +322,105 @@ def test_a_failed_job_exits_nonzero(self, invoke, fine_tunes): 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 @@ -443,6 +546,64 @@ def test_a_missing_job_exits_four(self, invoke, fine_tunes): 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): @@ -505,6 +666,35 @@ def test_the_result_is_rendered(self, invoke, fine_tunes): ) 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"]) @@ -529,7 +719,7 @@ 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.cli2.main import classify + from dashscope.cli.main import NEW, classify - assert classify(["ft", "list"]) == ("new", False) - assert classify(["fine-tunes", "list"]) == ("new", False) + assert classify(["ft", "list"]) == NEW + assert classify(["fine-tunes", "list"]) == NEW diff --git a/tests/cli2/test_input.py b/tests/cli/test_input.py similarity index 99% rename from tests/cli2/test_input.py rename to tests/cli/test_input.py index 6e369a3f..a21b5b8f 100644 --- a/tests/cli2/test_input.py +++ b/tests/cli/test_input.py @@ -7,8 +7,8 @@ import pytest -from dashscope.cli2.errors import CliError -from dashscope.cli2.input import ( +from dashscope.cli.errors import CliError +from dashscope.cli.input import ( messages_from_prompt, parse_json_arg, parse_json_object_arg, diff --git a/tests/cli2/test_output.py b/tests/cli/test_output.py similarity index 83% rename from tests/cli2/test_output.py rename to tests/cli/test_output.py index 9a54aa71..5cd280f7 100644 --- a/tests/cli2/test_output.py +++ b/tests/cli/test_output.py @@ -6,13 +6,14 @@ import pytest -from dashscope.cli2.output import ( +from dashscope.cli.output import ( JSON, OUTPUT_FORMATS, TABLE, YAML, Renderer, is_machine_format, + render_text_stream, resolve_no_color, to_serializable, ) @@ -231,6 +232,61 @@ def test_success_shows_in_table_format(self, capsys): 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 diff --git a/tests/cli2/test_package_layout.py b/tests/cli/test_package_layout.py similarity index 65% rename from tests/cli2/test_package_layout.py rename to tests/cli/test_package_layout.py index 7657ad45..b0518ceb 100644 --- a/tests/cli2/test_package_layout.py +++ b/tests/cli/test_package_layout.py @@ -2,12 +2,10 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Structural invariants of the CLI package layout. -``dashscope.cli`` is not free: it is a frozen public alias for the 1.x -tree, installed by ``dashscope/legacy_1x/_shim.py``'s meta-path finder and -pinned by ``tests/test_legacy_import_aliases.py`` (which also runs -``python -m dashscope.cli --help`` and expects the 1.x CLI). The v2 CLI -therefore lives in ``dashscope.cli2``. These tests keep that split from -being "tidied" back into a collision. +``dashscope.cli`` is the v2 CLI. The 1.x command tree lives at +``dashscope.legacy_1x.cli`` and is reachable through its own console +script (``dashscope1``); ``dashscope`` itself serves the v2 tree only and +rejects 1.x command names, pointing at whatever replaced them. """ import ast @@ -15,7 +13,7 @@ import sys from pathlib import Path -PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "dashscope" / "cli2" +PACKAGE_ROOT = Path(__file__).resolve().parents[2] / "dashscope" / "cli" COMMANDS_ROOT = PACKAGE_ROOT / "commands" @@ -28,37 +26,26 @@ def _run_python(code: str) -> subprocess.CompletedProcess: ) -def test_cli2_never_loads_the_1x_command_tree(): - """What the split can and cannot buy. +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 the split does avoid is ``dashscope.legacy_1x.cli`` -- 18 - Typer submodules plus ``set_sdk_client`` -- which is the part that + 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.cli2, dashscope.cli2.context\n" - "import dashscope.cli2.commands\n" + "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_legacy_dashscope_cli_still_aliases_the_1x_tree(): - result = _run_python( - "import importlib\n" - "old = importlib.import_module('dashscope.cli')\n" - "canonical = importlib.import_module('dashscope.legacy_1x.cli')\n" - "assert old is canonical\n" - "assert old.__file__.endswith('legacy_1x/cli/__init__.py'), old.__file__\n", - ) - assert result.returncode == 0, result.stdout + result.stderr - - def _module_level_imports(path: Path): tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in tree.body: @@ -77,7 +64,7 @@ def test_command_modules_never_import_the_sdk_at_module_scope(): single stray top-level ``from dashscope import Client`` in any future command module would make every ``--help`` pay for pydantic and httpx. - ``dashscope.cli2`` itself is allowed: importing the package runs an + ``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`). """ @@ -86,23 +73,23 @@ def test_command_modules_never_import_the_sdk_at_module_scope(): for name in _module_level_imports(path): if name == "dashscope" or ( name.startswith("dashscope.") - and name != "dashscope.cli2" - and not name.startswith("dashscope.cli2.") + and name != "dashscope.cli" + and not name.startswith("dashscope.cli.") ): offenders.append(f"{path.name}: {name}") assert offenders == [] def test_console_script_target_resolves(): - """``setup.py`` points at ``dashscope.cli2.main:main``. + """``setup.py`` points at ``dashscope.cli.main:main``. ``main`` there is a *module*, so the package must not bind the same name to a function: whichever import ran first would win, and - ``from dashscope.cli2 import main`` would mean two different things. + ``from dashscope.cli import main`` would mean two different things. """ from importlib import import_module - module = import_module("dashscope.cli2.main") + module = import_module("dashscope.cli.main") assert callable(module.main) diff --git a/tests/cli2/test_progress.py b/tests/cli/test_progress.py similarity index 78% rename from tests/cli2/test_progress.py rename to tests/cli/test_progress.py index a26de2c1..064b454c 100644 --- a/tests/cli2/test_progress.py +++ b/tests/cli/test_progress.py @@ -7,8 +7,9 @@ import pytest -from dashscope.cli2.output import JSON, Renderer -from dashscope.cli2.progress import wait_job +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: @@ -136,7 +137,7 @@ class TestSleepHook: def test_sleeps_for_the_requested_interval(self, tty_stderr, monkeypatch): tty_stderr() slept = [] - monkeypatch.setattr("dashscope.cli2.progress.time.sleep", slept.append) + monkeypatch.setattr("dashscope.cli.progress.time.sleep", slept.append) waiter = _RecordingWait(result=None) wait_job( _client(waiter), @@ -150,7 +151,7 @@ def test_sleeps_for_the_requested_interval(self, tty_stderr, monkeypatch): def test_mentions_the_job_id(self, tty_stderr, monkeypatch): stream = tty_stderr() monkeypatch.setattr( - "dashscope.cli2.progress.time.sleep", + "dashscope.cli.progress.time.sleep", lambda s: None, ) waiter = _RecordingWait(result=None) @@ -170,7 +171,7 @@ def test_repeated_polls_do_not_stack_the_suffix( ): stream = tty_stderr() monkeypatch.setattr( - "dashscope.cli2.progress.time.sleep", + "dashscope.cli.progress.time.sleep", lambda s: None, ) waiter = _RecordingWait(result=None) @@ -189,7 +190,7 @@ def test_repeated_polls_do_not_stack_the_suffix( def test_custom_label(self, tty_stderr, monkeypatch): stream = tty_stderr() monkeypatch.setattr( - "dashscope.cli2.progress.time.sleep", + "dashscope.cli.progress.time.sleep", lambda s: None, ) waiter = _RecordingWait(result=None) @@ -202,3 +203,35 @@ def test_custom_label(self, tty_stderr, monkeypatch): ) 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/cli2/test_dispatch.py b/tests/cli2/test_dispatch.py deleted file mode 100644 index b1278c84..00000000 --- a/tests/cli2/test_dispatch.py +++ /dev/null @@ -1,272 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Routing between the v2 and 1.x command trees.""" - -import sys -from types import ModuleType - -import pytest - -from dashscope.cli2.errors import NO_DEPRECATION_ENV -from dashscope.cli2.main import ( - LEGACY, - LEGACY_DEPRECATED, - LEGACY_UNMIGRATED, - NEW, - NEW_COMMANDS, - classify, - main, -) - - -class TestClassify: - @pytest.mark.parametrize( - "argv", - [ - ["chat", "create", "-m", "qwen-plus"], - ["ft", "list"], - ["fine-tunes", "get", "job-1"], - ["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"], - # A misspelled command must reach the v2 parser so the exit - # code matches the new tiers (2), not the 1.x tree's. - ["chatz", "create"], - ["auth_x"], - # A stray option with no command is a command-line mistake. - ["-k"], - ["--verbose"], - # --help with no command describes the tree being written - # against from here on. - ["--help"], - ["-h"], - ["-k", "sk-1", "--help"], - ], - ) - def test_new_tree(self, argv): - assert classify(argv) == (NEW, False) - - @pytest.mark.parametrize( - "argv", - [ - [], # bare `dashscope` → TUI - ["-k", "sk-1"], # 1.x strips it, then TUI - ["--api-key=sk-1"], - ["expert", "what", "is", "lora"], - ["expert", "--help"], - ["fine_tunes.list", "--page-size", "5"], - ["generation.call"], - ["oss.upload"], - ["files", "list"], - ["models", "list"], - ["auth", "whoami"], - ["deployments", "list"], - ["embeddings", "create"], - ["rerank", "create"], - ["tokenization", "create"], - ["rl", "list"], - ["--cli"], - ["--tui"], - ["--cli", "generation", "create"], - ], - ) - def test_legacy_tree(self, argv): - assert classify(argv)[0] == LEGACY - - @pytest.mark.parametrize("name", sorted(LEGACY_DEPRECATED)) - def test_deprecated_names_warn(self, name): - assert classify([name, "list"]) == (LEGACY, True) - - @pytest.mark.parametrize("name", sorted(LEGACY_UNMIGRATED)) - def test_unmigrated_names_are_silent(self, name): - assert classify([name, "list"]) == (LEGACY, False) - - def test_expert_is_never_deprecation_warned(self): - assert classify(["expert", "hi"]) == (LEGACY, False) - - def test_global_options_do_not_hide_the_command(self): - assert ( - classify(["-k", "sk-1", "--output", "json", "ft", "list"])[0] - == NEW - ) - assert classify(["-k", "sk-1", "generation", "create"]) == ( - LEGACY, - True, - ) - - def test_value_of_a_global_option_is_not_read_as_the_command(self): - """``-o json`` must not make ``json`` look like the command.""" - assert classify(["-o", "json", "generation", "create"]) == ( - LEGACY, - True, - ) - assert classify(["--output", "json", "ft", "list"]) == (NEW, False) - assert classify(["-c", "output=json", "generation", "create"]) == ( - LEGACY, - True, - ) - assert classify(["--timeout", "30", "files", "list"])[0] == LEGACY - - def test_self_contained_option_forms(self): - assert classify(["--api-key=sk-1", "generation", "create"]) == ( - LEGACY, - True, - ) - assert classify(["--output=json", "ft", "list"]) == (NEW, False) - - def test_name_sets_do_not_overlap(self): - assert NEW_COMMANDS.isdisjoint(LEGACY_DEPRECATED) - assert NEW_COMMANDS.isdisjoint(LEGACY_UNMIGRATED) - assert LEGACY_DEPRECATED.isdisjoint(LEGACY_UNMIGRATED) - - def test_ft_and_its_alias_moved_together(self): - """`ft` and hidden `fine-tunes` must land in the same tree.""" - assert ( - classify(["ft", "list"])[0] - == classify(["fine-tunes", "list"])[0] - == NEW - ) - - -@pytest.fixture -def legacy_main(monkeypatch): - """Replace the 1.x entry point with a recorder. - - Inserted into ``sys.modules`` rather than patched on the real module - so the test never pays for importing eighteen Typer apps. - """ - calls = [] - - def fake_main(): - calls.append(list(sys.argv)) - - module = ModuleType("dashscope.legacy_1x.cli") - module.main = fake_main - monkeypatch.setitem(sys.modules, "dashscope.legacy_1x.cli", 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.cli2.app.build_app", fake_build_app) - return calls - - -class TestMainDispatch: - def test_new_route_builds_and_calls_the_app(self, new_app, legacy_main): - main(["version"]) - assert new_app == [["version"]] - assert legacy_main == [] - - def test_legacy_route_delegates(self, new_app, legacy_main): - main(["files", "list"]) - assert legacy_main == [[sys.argv[0], "files", "list"]] - assert new_app == [] - - def test_legacy_route_receives_the_argv_it_was_given( - self, - new_app, - legacy_main, - monkeypatch, - ): - """The 1.x parser reads sys.argv, so an explicit args list must be - published there or it would dispatch on the real command line.""" - monkeypatch.setattr(sys, "argv", ["dashscope", "stale", "args"]) - main(["files", "list"]) - assert legacy_main == [["dashscope", "files", "list"]] - - def test_bare_invocation_goes_to_the_agent(self, new_app, legacy_main): - main([]) - assert legacy_main != [] - assert new_app == [] - - def test_deprecation_notice_for_deprecated_commands( - self, - capsys, - new_app, - legacy_main, - monkeypatch, - ): - monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) - main(["generation", "create"]) - err = capsys.readouterr().err - assert "deprecated" in err - assert "dashscope generation" in err - assert NO_DEPRECATION_ENV in err - - def test_notice_names_the_command_even_after_global_options( - self, - capsys, - new_app, - legacy_main, - monkeypatch, - ): - monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) - main(["-k", "sk-1", "generation", "create"]) - assert "dashscope generation" in capsys.readouterr().err - - def test_no_notice_for_unmigrated_commands( - self, - capsys, - new_app, - legacy_main, - monkeypatch, - ): - monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) - main(["files", "list"]) - assert capsys.readouterr().err == "" - - def test_notice_can_be_silenced( - self, - capsys, - new_app, - legacy_main, - monkeypatch, - ): - monkeypatch.setenv(NO_DEPRECATION_ENV, "1") - main(["generation", "create"]) - assert capsys.readouterr().err == "" - assert legacy_main != [] - - def test_notice_goes_to_stderr_not_stdout( - self, - capsys, - new_app, - legacy_main, - monkeypatch, - ): - monkeypatch.delenv(NO_DEPRECATION_ENV, raising=False) - main(["generation", "create"]) - assert capsys.readouterr().out == "" - - def test_reads_sys_argv_when_called_with_no_arguments( - self, - new_app, - legacy_main, - monkeypatch, - ): - monkeypatch.setattr(sys, "argv", ["dashscope", "version"]) - main() - assert new_app == [["version"]] - - def test_console_script_reads_pristine_argv( - self, - new_app, - legacy_main, - monkeypatch, - ): - """The 1.x tree rewrites argv itself; it must see the original.""" - monkeypatch.setattr(sys, "argv", ["dashscope", "files", "list"]) - main() - assert legacy_main == [["dashscope", "files", "list"]] diff --git a/tests/legacy_1x/legacy/conftest.py b/tests/legacy_1x/legacy/conftest.py index 6351d532..c3852696 100644 --- a/tests/legacy_1x/legacy/conftest.py +++ b/tests/legacy_1x/legacy/conftest.py @@ -45,13 +45,16 @@ def http_server(request): print("starting legacy server!!!!!!!!!") proc = multiprocessing.Process(target=run_server) proc.start() - wait_for_port(HTTP_SERVER_PORT) def stop_server(): 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 ca0d020f..3f698b19 100644 --- a/tests/legacy_1x/unit/conftest.py +++ b/tests/legacy_1x/unit/conftest.py @@ -51,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() - wait_for_port(HTTP_SERVER_PORT) def stop_server(): 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 2756abf2..ede59aeb 100644 --- a/tests/legacy_1x/unit/mock_server.py +++ b/tests/legacy_1x/unit/mock_server.py @@ -1021,13 +1021,16 @@ def create_mock_server(request): ), ) proc.start() - wait_for_port(MOCK_SERVER_PORT) def stop_server(): 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/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") From ee7233c1b63e58253560ce760e3633ffa1c73586 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Thu, 5 Nov 2026 10:04:31 +0800 Subject: [PATCH 05/13] =?UTF-8?q?feat(cli):=20expand=20v2=20CLI=20with=20f?= =?UTF-8?q?ull=20command=20coverage=20and=20rename=20cli2=20=E2=86=92=20cl?= =?UTF-8?q?i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the v2 package directory from cli2 to cli, making it the primary command tree. Add 16 new resource commands (files, models, images, videos, embeddings, multimodal, rerank, tokenization, apps, deployments, rl, audio, audio-customization, tingwu, decision, agentstudio) alongside the existing chat/ft/config commands. Key changes: - Rename dashscope/cli2 → dashscope/cli and tests/cli2 → tests/cli - Bump version to 2.0.4 - Refactor main.py dispatch logic with consolidated command routing - Expand config_store.py with TOML line-editor writer (no new deps) - Overhaul error handling, output formatting, and progress display - Enhance ft command with extended fine-tuning workflows - Update README and CLI examples (en/zh) with new command docs - Add comprehensive test coverage for all new commands - Clean up legacy import aliases --- dashscope/cli/commands/agentstudio.py | 646 ++++++++++++ dashscope/cli/commands/apps.py | 226 +++++ dashscope/cli/commands/audio.py | 643 ++++++++++++ dashscope/cli/commands/audio_customization.py | 567 +++++++++++ dashscope/cli/commands/decision.py | 141 +++ dashscope/cli/commands/deployments.py | 210 ++++ dashscope/cli/commands/embeddings.py | 303 ++++++ dashscope/cli/commands/files.py | 123 +++ dashscope/cli/commands/images.py | 375 +++++++ dashscope/cli/commands/models.py | 78 ++ dashscope/cli/commands/multimodal.py | 321 ++++++ dashscope/cli/commands/rerank.py | 139 +++ dashscope/cli/commands/rl.py | 462 +++++++++ dashscope/cli/commands/tingwu.py | 109 +++ dashscope/cli/commands/tokenization.py | 91 ++ dashscope/cli/commands/videos.py | 401 ++++++++ tests/cli/test_agentstudio_cmd.py | 897 +++++++++++++++++ tests/cli/test_apps_cmd.py | 448 +++++++++ tests/cli/test_audio_cmd.py | 926 ++++++++++++++++++ tests/cli/test_audio_customization_cmd.py | 602 ++++++++++++ tests/cli/test_decision_cmd.py | 273 ++++++ tests/cli/test_deployments_cmd.py | 321 ++++++ tests/cli/test_embeddings_cmd.py | 317 ++++++ tests/cli/test_files_cmd.py | 201 ++++ tests/cli/test_images_cmd.py | 390 ++++++++ tests/cli/test_models_cmd.py | 147 +++ tests/cli/test_multimodal_cmd.py | 565 +++++++++++ tests/cli/test_rerank_cmd.py | 153 +++ tests/cli/test_rl_cmd.py | 516 ++++++++++ tests/cli/test_sdk.py | 109 +++ tests/cli/test_tingwu_cmd.py | 177 ++++ tests/cli/test_tokenization_cmd.py | 118 +++ tests/cli/test_videos_cmd.py | 430 ++++++++ tests/legacy_1x/unit/test_mock_server.py | 110 +++ 34 files changed, 11535 insertions(+) create mode 100644 dashscope/cli/commands/agentstudio.py create mode 100644 dashscope/cli/commands/apps.py create mode 100644 dashscope/cli/commands/audio.py create mode 100644 dashscope/cli/commands/audio_customization.py create mode 100644 dashscope/cli/commands/decision.py create mode 100644 dashscope/cli/commands/deployments.py create mode 100644 dashscope/cli/commands/embeddings.py create mode 100644 dashscope/cli/commands/files.py create mode 100644 dashscope/cli/commands/images.py create mode 100644 dashscope/cli/commands/models.py create mode 100644 dashscope/cli/commands/multimodal.py create mode 100644 dashscope/cli/commands/rerank.py create mode 100644 dashscope/cli/commands/rl.py create mode 100644 dashscope/cli/commands/tingwu.py create mode 100644 dashscope/cli/commands/tokenization.py create mode 100644 dashscope/cli/commands/videos.py create mode 100644 tests/cli/test_agentstudio_cmd.py create mode 100644 tests/cli/test_apps_cmd.py create mode 100644 tests/cli/test_audio_cmd.py create mode 100644 tests/cli/test_audio_customization_cmd.py create mode 100644 tests/cli/test_decision_cmd.py create mode 100644 tests/cli/test_deployments_cmd.py create mode 100644 tests/cli/test_embeddings_cmd.py create mode 100644 tests/cli/test_files_cmd.py create mode 100644 tests/cli/test_images_cmd.py create mode 100644 tests/cli/test_models_cmd.py create mode 100644 tests/cli/test_multimodal_cmd.py create mode 100644 tests/cli/test_rerank_cmd.py create mode 100644 tests/cli/test_rl_cmd.py create mode 100644 tests/cli/test_sdk.py create mode 100644 tests/cli/test_tingwu_cmd.py create mode 100644 tests/cli/test_tokenization_cmd.py create mode 100644 tests/cli/test_videos_cmd.py create mode 100644 tests/legacy_1x/unit/test_mock_server.py 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..0953f58f --- /dev/null +++ b/dashscope/cli/commands/audio.py @@ -0,0 +1,643 @@ +# -*- 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 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 +recognize`` 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, Iterator, List, Optional, Tuple + +import typer + +from dashscope.cli.commands.audio_customization import build_admin_apps +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 +http` answers with a download URL. `audio asr transcribe` submits file URLs +to the batch service and waits; `audio asr recognize` 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` runs over websocket and returns the audio itself, so it needs +somewhere to put it: --out, or stdout when stdout is redirected. +`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. `recognize` 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: bytes, out: Optional[str], renderer: Renderer) -> None: + """Bytes to a file, or to stdout when no ``--out`` was given.""" + if out: + path = Path(out).expanduser() + try: + path.write_bytes(data) + except OSError as exc: + raise usage_error(f"cannot write {path}: {exc}") from exc + renderer.note(f"wrote {len(data)} 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", + ) + stream.write(data) + 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 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("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 + data = obj.client.audio.tts.synthesize( + _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("recognize") + def asr_recognize( # 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..5582fe74 --- /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 recognize --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/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..5e84753e --- /dev/null +++ b/dashscope/cli/commands/deployments.py @@ -0,0 +1,210 @@ +# -*- 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 + +from typing import Optional + +import typer + +from dashscope.cli.confirm import confirm_or_die +from dashscope.cli.input import parse_json_object_arg +from dashscope.cli.output import render_stream_event + +_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") + + +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( + ..., + "--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.", + ), + ) -> 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 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("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/images.py b/dashscope/cli/commands/images.py new file mode 100644 index 00000000..954051f9 --- /dev/null +++ b/dashscope/cli/commands/images.py @@ -0,0 +1,375 @@ +# -*- 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.", + ), + 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, + 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/rerank.py b/dashscope/cli/commands/rerank.py new file mode 100644 index 00000000..14c0c1b8 --- /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..98a02bff --- /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 recognize`` 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..835192ec --- /dev/null +++ b/dashscope/cli/commands/videos.py @@ -0,0 +1,401 @@ +# -*- 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.", + ), + 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, + 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/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_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..286715e9 --- /dev/null +++ b/tests/cli/test_audio_cmd.py @@ -0,0 +1,926 @@ +# -*- 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({"text": text, "kwargs": kwargs}) + return _AUDIO + + +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 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 TestAsrRecognize: + 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", + "recognize", + 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", + "recognize", + 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", + "recognize", + 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", + "recognize", + 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_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..9f00d4a2 --- /dev/null +++ b/tests/cli/test_deployments_cmd.py @@ -0,0 +1,321 @@ +# -*- 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_requires_capacity(self, invoke, deployments): + deployments(create=_deployment()) + result = invoke(["deployments", "create", "-m", "qwen-plus-ft"]) + assert result.exit_code == 2 + + +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_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_files_cmd.py b/tests/cli/test_files_cmd.py new file mode 100644 index 00000000..0f6926e0 --- /dev/null +++ b/tests/cli/test_files_cmd.py @@ -0,0 +1,201 @@ +# -*- 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_images_cmd.py b/tests/cli/test_images_cmd.py new file mode 100644 index 00000000..1187d73f --- /dev/null +++ b/tests/cli/test_images_cmd.py @@ -0,0 +1,390 @@ +# -*- 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", + ], + ) + assert resource.jobs.calls[0]["kwargs"] == { + "page_no": 2, + "page_size": 5, + "status": "SUCCEEDED", + "model_name": "wan2.2-t2i-flash", + "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_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_rerank_cmd.py b/tests/cli/test_rerank_cmd.py new file mode 100644 index 00000000..a50302b5 --- /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..e91b1f41 --- /dev/null +++ b/tests/cli/test_videos_cmd.py @@ -0,0 +1,430 @@ +# -*- 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", + ], + ) + assert resource.jobs.calls[0]["kwargs"] == { + "page_no": 2, + "page_size": 5, + "status": "SUCCEEDED", + "model_name": "wan2.2-t2v-plus", + "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/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() From cfdd6031f5c6a98b5fa74bf8fee100296b842b49 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Thu, 5 Nov 2026 11:12:18 +0800 Subject: [PATCH 06/13] style(code): add trailing commas for consistent formatting - Add trailing comma in delete operation of deployments command - Add trailing comma in raise statements of rerank command - Add trailing commas to function parameter lists in test files - Add trailing commas to list and dict arguments in test files - Unify code style to comply with auto-formatting tools --- dashscope/cli/commands/deployments.py | 2 +- dashscope/cli/commands/rerank.py | 2 +- tests/cli/test_app.py | 3 ++- tests/cli/test_deployments_cmd.py | 4 +++- tests/cli/test_files_cmd.py | 8 ++++++-- tests/cli/test_rerank_cmd.py | 2 +- 6 files changed, 14 insertions(+), 7 deletions(-) diff --git a/dashscope/cli/commands/deployments.py b/dashscope/cli/commands/deployments.py index 5e84753e..d1eedf1a 100644 --- a/dashscope/cli/commands/deployments.py +++ b/dashscope/cli/commands/deployments.py @@ -193,7 +193,7 @@ def delete( confirm_or_die(f"delete deployment {deployed_model}?", yes=yes) obj = ctx.obj obj.renderer.print_object( - obj.client.deployments.delete(deployed_model) + obj.client.deployments.delete(deployed_model), ) @app.command("stream") diff --git a/dashscope/cli/commands/rerank.py b/dashscope/cli/commands/rerank.py index 14c0c1b8..f64d632f 100644 --- a/dashscope/cli/commands/rerank.py +++ b/dashscope/cli/commands/rerank.py @@ -45,7 +45,7 @@ def _parse_documents( value = parse_json_arg(raw, option_name="--documents") if not isinstance(value, list): raise usage_error( - "--documents: expected a JSON array of strings" + "--documents: expected a JSON array of strings", ) docs.extend(str(item) for item in value) if repeatable: diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index 0b73159d..23c072c2 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -45,7 +45,8 @@ def test_completion_is_enabled(self, app): assert "--show-completion" in opts def test_help_lists_the_v2_commands_and_names_the_removed_1x_ones( - self, app + self, + app, ): result = _invoke(app, ["--help"]) assert result.exit_code == 0 diff --git a/tests/cli/test_deployments_cmd.py b/tests/cli/test_deployments_cmd.py index 9f00d4a2..f351d0e8 100644 --- a/tests/cli/test_deployments_cmd.py +++ b/tests/cli/test_deployments_cmd.py @@ -142,7 +142,9 @@ def test_create_accepts_a_deploy_spec(self, invoke, deployments): assert kwargs["billing_method"] == "monthly" def test_create_rejects_a_non_object_deploy_spec( - self, invoke, deployments + self, + invoke, + deployments, ): deployments(create=_deployment()) result = invoke( diff --git a/tests/cli/test_files_cmd.py b/tests/cli/test_files_cmd.py index 0f6926e0..86667824 100644 --- a/tests/cli/test_files_cmd.py +++ b/tests/cli/test_files_cmd.py @@ -84,7 +84,10 @@ def _as_terminal_tty(monkeypatch, answer: bool): class TestUpload: def test_upload_calls_sdk_with_path_and_purpose( - self, invoke, files, tmp_path + self, + invoke, + files, + tmp_path, ): payload = tmp_path / "train.jsonl" payload.write_text('{"messages": []}\n', encoding="utf-8") @@ -165,7 +168,8 @@ def test_get_by_id(self, invoke, files): def test_get_json_output(self, invoke, files): files(get=_file()) result = invoke( - ["files", "get", "file-abc123"], global_args=["-o", "json"] + ["files", "get", "file-abc123"], + global_args=["-o", "json"], ) assert result.exit_code == 0 import json diff --git a/tests/cli/test_rerank_cmd.py b/tests/cli/test_rerank_cmd.py index a50302b5..c27d99b8 100644 --- a/tests/cli/test_rerank_cmd.py +++ b/tests/cli/test_rerank_cmd.py @@ -147,7 +147,7 @@ def test_create_requires_query(self, invoke, rerank): def test_create_requires_documents(self, invoke, rerank): rerank(create=_rerank_response()) result = invoke( - ["rerank", "create", "-m", "gte-rerank", "-q", "query"] + ["rerank", "create", "-m", "gte-rerank", "-q", "query"], ) assert result.exit_code == 2 assert "no documents" in result.stderr From 4945b19ac67f396f5a214d6c014b103565f3d3e7 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 11:52:48 +0800 Subject: [PATCH 07/13] ci: pin Python 3.9 leg to ubuntu-24.04 ubuntu-latest now resolves to Ubuntu 26.04, which ships no CPython 3.9 build, so setup-python failed on the 3.9 leg and matrix fail-fast cancelled the passing 3.10-3.13 legs. Keep the still-supported 3.9 leg on ubuntu-24.04 and leave 3.10-3.13 on ubuntu-latest, for both the current and legacy jobs. --- .github/workflows/unit_test.yml | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 9beeb4e9..d6c14d06 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -15,8 +15,19 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + # ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the + # still-supported 3.9 leg to 24.04 and keep the rest on latest. + include: + - os: ubuntu-24.04 + python-version: '3.9' + - os: ubuntu-latest + python-version: '3.10' + - os: ubuntu-latest + python-version: '3.11' + - os: ubuntu-latest + python-version: '3.12' + - os: ubuntu-latest + python-version: '3.13' steps: - name: Checkout repository @@ -68,8 +79,19 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-latest ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + # ubuntu-latest (26.04) ships no CPython 3.9 build, so pin the + # still-supported 3.9 leg to 24.04 and keep the rest on latest. + include: + - os: ubuntu-24.04 + python-version: '3.9' + - os: ubuntu-latest + python-version: '3.10' + - os: ubuntu-latest + python-version: '3.11' + - os: ubuntu-latest + python-version: '3.12' + - os: ubuntu-latest + python-version: '3.13' steps: - name: Checkout repository From ff7fefd02a11c534cb638423fe9317d66e84872f Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 16:59:51 +0800 Subject: [PATCH 08/13] feat(cli): unify 1.x and 2.0 under one command --- README.md | 23 +- README_zh.md | 23 +- dashscope/acli/expert.py | 143 +++ dashscope/cli/app.py | 74 +- dashscope/cli/commands/audio.py | 119 ++- dashscope/cli/commands/audio_customization.py | 2 +- dashscope/cli/commands/auth.py | 127 +++ dashscope/cli/commands/chat.py | 31 +- dashscope/cli/commands/deployments.py | 77 +- dashscope/cli/commands/images.py | 4 + dashscope/cli/commands/oss.py | 55 ++ dashscope/cli/commands/tingwu.py | 2 +- dashscope/cli/commands/videos.py | 4 + dashscope/cli/compat_1x.py | 871 ++++++++++++++++++ dashscope/cli/input.py | 12 +- dashscope/cli/main.py | 205 ++--- dashscope/legacy_1x/cli/__init__.py | 144 +-- dashscope/resources/_oss_upload.py | 8 +- dashscope/resources/chat/completions.py | 11 +- examples/05_cli/README.md | 518 ----------- examples/05_cli/README_zh.md | 518 ----------- examples/05_cli/cli_ci_harness.py | 2 +- examples/05_cli/cli_output_and_errors.py | 2 +- examples/README.md | 492 +++++++++- examples/README_zh.md | 492 +++++++++- setup.py | 1 - tests/cli/test_app.py | 3 +- tests/cli/test_audio_cmd.py | 64 +- tests/cli/test_auth_cmd.py | 163 ++++ tests/cli/test_chat_cmd.py | 44 + tests/cli/test_compat_1x.py | 185 ++++ tests/cli/test_deployments_cmd.py | 7 +- tests/cli/test_dispatch.py | 263 +++--- tests/cli/test_images_cmd.py | 6 + tests/cli/test_input.py | 12 + tests/cli/test_oss_cmd.py | 91 ++ tests/cli/test_package_layout.py | 64 +- tests/cli/test_videos_cmd.py | 6 + tests/legacy_1x/unit/test_cli_agent_route.py | 67 +- tests/test_chat.py | 24 + tests/test_oss_upload.py | 30 +- 41 files changed, 3416 insertions(+), 1573 deletions(-) create mode 100644 dashscope/acli/expert.py create mode 100644 dashscope/cli/commands/auth.py create mode 100644 dashscope/cli/commands/oss.py create mode 100644 dashscope/cli/compat_1x.py delete mode 100644 examples/05_cli/README.md delete mode 100644 examples/05_cli/README_zh.md create mode 100644 tests/cli/test_auth_cmd.py create mode 100644 tests/cli/test_compat_1x.py create mode 100644 tests/cli/test_oss_cmd.py diff --git a/README.md b/README.md index b4297151..c638a359 100644 --- a/README.md +++ b/README.md @@ -439,23 +439,23 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built ## Command line -`pip install -U dashscope` puts **two** commands on your `PATH`: +`pip install -U dashscope` puts one `dashscope` command on your `PATH`. It serves both the SDK-shaped **2.0** command tree and the existing **1.x** command forms, so current 1.x invocations need no migration: -| Command | Runs | Typical subcommands | -|---------|------|---------------------| -| `dashscope` | the **2.0** CLI | `chat create`, `ft create/get/wait/list/stream/logs/cancel/delete`, `files upload/list/get/delete`, `models list/get`, `images generate`, `images jobs create/get/wait/list/cancel`, `videos generate`, `videos jobs create/get/wait/list/cancel`, `embeddings create`, `embeddings jobs create/get/wait/list/cancel`, `multimodal chat create`, `multimodal embeddings create`, `rerank create`, `tokenization count`, `apps completions create`, `deployments create/get/list/scale/delete/stream`, `rl jobs create/get/wait/list/logs/cancel/delete`, `rl functions register/test/upload-data/logs/delete`, `audio tts synthesize/http`, `audio asr transcribe/get/wait/recognize`, `audio realtime run`, `audio vocabularies/phrases/voices/voice-design` management, `tingwu call`, `decision create`, `agentstudio agents create/get/list/versions/update/archive`, `agentstudio sessions create/get/list/update/archive/delete`, `agentstudio sessions events send/list/stream`, `config set/get/list`, `version` | -| `dashscope1` | the **1.x** CLI (legacy) | `generation`, `application`, `image-synthesis`, `multimodal-conversation`, `auth`, `oss`, … | +| Form | Examples | +|------|----------| +| **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` | +| **1.x**, kept compatible | `generation call`, `application call`, `image-synthesis create`, `multimodal-conversation call`, `fine-tunes call`, `agentic-rl run`, `auth login`, `oss upload` | -The module entry points are equivalent: +Legacy-only names and dotted commands run against the frozen 1.x implementation. Shared names are served by 2.0 with compatible 1.x option spellings and defaults. The module entry point uses the same router: ```bash -python -m dashscope.cli # 2.0 -python -m dashscope.legacy_1x.cli # 1.x +python -m dashscope.cli generation call ... +python -m dashscope.cli apps completions create ... ``` Running `dashscope` with no arguments starts the [AI assistant](#ai-assistant-dashscope-sdk-expert) instead of the CLI. -For the full 2.0 CLI contract — output formats, exit codes, configuration precedence, CI recipes — see [`examples/05_cli/README.md`](examples/05_cli/README.md). +For the complete CLI command map, 1.x compatibility rules, output formats, exit codes, configuration precedence and CI recipes, see the CLI section in [`examples/README.md`](examples/README.md). ## Shell completion @@ -473,7 +473,7 @@ Preview the script without installing: dashscope --show-completion bash ``` -The 1.x CLI (`dashscope1`) has its own completion — run `dashscope1 --install-completion ` separately if you still use it. +Completion is installed through the single `dashscope` entry point. ## Logging @@ -497,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 diff --git a/README_zh.md b/README_zh.md index 7f06bbb6..5f20d4b2 100644 --- a/README_zh.md +++ b/README_zh.md @@ -439,23 +439,23 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 ## 命令行 -`pip install -U dashscope` 会在 `PATH` 上安装**两个**命令: +`pip install -U dashscope` 只安装一个 `dashscope` 命令。它同时提供按 SDK 资源树设计的 **2.0** 命令和现有 **1.x** 命令形式,现有 1.x 调用无需迁移: -| 命令 | 跑的是 | 典型子命令 | -|------|--------|-----------| -| `dashscope` | **2.0** CLI | `chat create`、`ft create/get/wait/list/stream/logs/cancel/delete`、`files upload/list/get/delete`、`models list/get`、`images generate`、`images jobs create/get/wait/list/cancel`、`videos generate`、`videos jobs create/get/wait/list/cancel`、`embeddings create`、`embeddings jobs create/get/wait/list/cancel`、`multimodal chat create`、`multimodal embeddings create`、`rerank create`、`tokenization count`、`apps completions create`、`deployments create/get/list/scale/delete/stream`、`rl jobs create/get/wait/list/logs/cancel/delete`、`rl functions register/test/upload-data/logs/delete`、`audio tts synthesize/http`、`audio asr transcribe/get/wait/recognize`、`audio realtime run`、`audio vocabularies/phrases/voices/voice-design` 管理面、`tingwu call`、`decision create`、`agentstudio agents create/get/list/versions/update/archive`、`agentstudio sessions create/get/list/update/archive/delete`、`agentstudio sessions events send/list/stream`、`config set/get/list`、`version` | -| `dashscope1` | **1.x** CLI(历史) | `generation`、`application`、`image-synthesis`、`multimodal-conversation`、`auth`、`oss`…… | +| 形式 | 示例 | +|------|------| +| **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` | +| **1.x**,保持兼容 | `generation call`、`application call`、`image-synthesis create`、`multimodal-conversation call`、`fine-tunes call`、`agentic-rl run`、`auth login`、`oss upload` | -模块入口等价: +1.x 专有名称和点式命令仍由冻结的 1.x 实现执行;共享名称由 2.0 执行,并兼容 1.x 参数拼写和默认值。模块入口使用同一个路由器: ```bash -python -m dashscope.cli # 2.0 -python -m dashscope.legacy_1x.cli # 1.x +python -m dashscope.cli generation call ... +python -m dashscope.cli apps completions create ... ``` 不带参数运行 `dashscope` 会启动 [AI 助手](#ai-助手dashscope-sdk-expert),而不是 CLI。 -完整的 2.0 CLI 契约 —— 输出格式、退出码、配置优先级、CI 配方 —— 见 [`examples/05_cli/README_zh.md`](examples/05_cli/README_zh.md)。 +完整的 CLI 命令地图、1.x 兼容规则、输出格式、退出码、配置优先级和 CI 配方,见 [`examples/README_zh.md`](examples/README_zh.md) 的命令行章节。 ## Shell 命令补全 @@ -473,7 +473,7 @@ python -m dashscope.legacy_1x.cli # 1.x dashscope --show-completion bash ``` -1.x CLI(`dashscope1`)有自己独立的补全 —— 如果仍在使用,请单独运行 `dashscope1 --install-completion `。 +命令补全通过唯一的 `dashscope` 入口安装。 ## 日志 @@ -497,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 迁移 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/app.py b/dashscope/cli/app.py index e5ac194a..d214614c 100644 --- a/dashscope/cli/app.py +++ b/dashscope/cli/app.py @@ -2,14 +2,12 @@ # 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 must precede the -subcommand:: +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 tree tolerated a trailing ``-k`` by surgically rewriting argv. -That tolerance is deliberately *not* reimplemented here: a dangling option -is a command-line mistake, and click already renders one. +The console entry point also dispatches frozen 1.x-only commands and +normalises compatible options on shared commands before invoking this app. """ from __future__ import annotations @@ -20,7 +18,6 @@ from dashscope.cli.context import CliContext, GlobalOptions from dashscope.cli.errors import ErrorHandlingGroup, usage_error -from dashscope.cli.main import REPLACED_COMMANDS, UNSUPPORTED_COMMANDS from dashscope.cli.output import OUTPUT_FORMATS # `\[` is Rich markup escaping: unescaped, Rich reads the bracketed run as a @@ -37,25 +34,10 @@ def _epilog() -> str: - """What happened to the 1.x command names. - - ``dashscope`` serves the v2 tree only, so an old name is rejected - rather than delegated. Naming the removals here means ``--help`` - answers the question the rejection raises, before the user has to hit - it. Generated from the same constants the router uses, so the list - cannot drift from the routing. - - Written as prose because Rich re-flows the epilog to the terminal - width; hand-placed line breaks would not survive. - """ - replaced = ", ".join(sorted(REPLACED_COMMANDS)) - unsupported = ", ".join(sorted(UNSUPPORTED_COMMANDS)) return ( - "1.x command names are not served here; the entry point runs the " - f"v2 CLI only. Replaced by a v2 command: {replaced}. No v2 " - f"equivalent, still available as `dashscope1`: {unsupported}. " - "A bare `dashscope` opens the bundled `expert` agent " - "interactively." + "Frozen 1.x commands such as `generation create`, `application create` " + "and dotted spellings remain available under this same entry point. " + "A bare `dashscope` opens the bundled `expert` agent interactively." ) @@ -178,25 +160,30 @@ def version(ctx: typer.Context) -> None: # 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 - from dashscope.cli.commands import apps as apps_command - from dashscope.cli.commands import audio as audio_command - from dashscope.cli.commands import chat as chat_command - from dashscope.cli.commands import config as config_command - from dashscope.cli.commands import decision as decision_command - from dashscope.cli.commands import deployments as deployments_command - from dashscope.cli.commands import embeddings as embeddings_command - from dashscope.cli.commands import files as files_command - from dashscope.cli.commands import ft as ft_command - from dashscope.cli.commands import images as images_command - from dashscope.cli.commands import models as models_command - from dashscope.cli.commands import multimodal as multimodal_command - from dashscope.cli.commands import rerank as rerank_command - from dashscope.cli.commands import rl as rl_command - from dashscope.cli.commands import tingwu as tingwu_command - from dashscope.cli.commands import tokenization as tokenization_command - from dashscope.cli.commands import videos as videos_command + 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 @@ -205,6 +192,7 @@ def version(ctx: typer.Context) -> None: 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") @@ -214,6 +202,8 @@ def version(ctx: typer.Context) -> None: 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") diff --git a/dashscope/cli/commands/audio.py b/dashscope/cli/commands/audio.py index 0953f58f..df46a929 100644 --- a/dashscope/cli/commands/audio.py +++ b/dashscope/cli/commands/audio.py @@ -9,11 +9,12 @@ ``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 http`` is the one-POST variant that answers with a -URL instead of a payload, which is what a pipeline that stores audio wants. +"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 -recognize`` streams one local file over websocket and prints the sentences. +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 @@ -29,11 +30,12 @@ import base64 import sys from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Tuple +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, @@ -46,26 +48,27 @@ _HELP = r"""Speech synthesis and recognition. `audio tts synthesize` writes audio bytes to --out or stdout; `audio tts -http` answers with a download URL. `audio asr transcribe` submits file URLs -to the batch service and waits; `audio asr recognize` 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. +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` runs over websocket and returns the audio itself, so it needs -somewhere to put it: --out, or stdout when stdout is redirected. +`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. `recognize` streams one local audio file over websocket and -prints the recognized sentences as they complete. `get` checks a batch task -submitted with --no-wait. +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. @@ -170,23 +173,37 @@ def _read_text(text: Optional[str], *, option_name: str) -> str: return resolved -def _write_audio(data: bytes, out: Optional[str], renderer: Renderer) -> None: +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: - path.write_bytes(data) + output = path.open("wb") except OSError as exc: raise usage_error(f"cannot write {path}: {exc}") from exc - renderer.note(f"wrote {len(data)} bytes to {path}") + 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", ) - stream.write(data) - stream.flush() + for chunk in chunks: + stream.write(chunk) + stream.flush() def _render_synthesis(renderer: Renderer, result: Any) -> None: @@ -235,12 +252,22 @@ def _render_job(renderer: Renderer, job: Any) -> None: 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, @@ -290,7 +317,9 @@ def tts_synthesize( # pylint: disable=too-many-arguments,too-many-locals ) -> None: """Synthesize speech and write the audio out.""" obj = ctx.obj - data = obj.client.audio.tts.synthesize( + 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, @@ -475,8 +504,54 @@ def asr_wait( ) _render_job(renderer, job) - @asr_app.command("recognize") - def asr_recognize( # pylint: disable=too-many-arguments + @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( diff --git a/dashscope/cli/commands/audio_customization.py b/dashscope/cli/commands/audio_customization.py index 5582fe74..f0d63278 100644 --- a/dashscope/cli/commands/audio_customization.py +++ b/dashscope/cli/commands/audio_customization.py @@ -5,7 +5,7 @@ Four registries that the speech commands consume but do not own: * ``vocabularies`` -- ASR hot-word tables, referenced by - ``audio asr recognize --vocabulary-id``; + ``audio asr stream --vocabulary-id``; * ``phrases`` -- compiled phrase sets, referenced by ``audio asr transcribe --phrase-id``; * ``voices`` -- voices cloned from a sample recording; diff --git a/dashscope/cli/commands/auth.py b/dashscope/cli/commands/auth.py new file mode 100644 index 00000000..650505dd --- /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. +""" + + +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 index 89237ad3..c8e36eab 100644 --- a/dashscope/cli/commands/chat.py +++ b/dashscope/cli/commands/chat.py @@ -8,7 +8,9 @@ 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, @@ -18,9 +20,10 @@ _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 piped -stdin -- in that order of preference. Local image and video paths inside the -messages are uploaded by the SDK itself; pass them through untouched. +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. @@ -33,6 +36,11 @@ def build_app() -> typer.Typer: @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", @@ -67,6 +75,7 @@ def create( ), 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, @@ -74,6 +83,15 @@ def create( 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", @@ -94,10 +112,13 @@ def create( """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), @@ -105,9 +126,13 @@ def create( "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( diff --git a/dashscope/cli/commands/deployments.py b/dashscope/cli/commands/deployments.py index d1eedf1a..035ca67d 100644 --- a/dashscope/cli/commands/deployments.py +++ b/dashscope/cli/commands/deployments.py @@ -15,13 +15,16 @@ from __future__ import annotations -from typing import Optional +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. @@ -44,6 +47,33 @@ _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) @@ -58,7 +88,8 @@ def create( # pylint: disable=too-many-arguments,too-many-locals help="Model to deploy, e.g. a fine-tune output model.", ), capacity: int = typer.Option( - ..., + 1, + "-c", "--capacity", help="Provisioned capacity to request.", ), @@ -89,6 +120,16 @@ def create( # pylint: disable=too-many-arguments,too-many-locals "--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 @@ -106,6 +147,14 @@ def create( # pylint: disable=too-many-arguments,too-many-locals ), 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: @@ -125,6 +174,30 @@ def get( 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, diff --git a/dashscope/cli/commands/images.py b/dashscope/cli/commands/images.py index 954051f9..f016d5fd 100644 --- a/dashscope/cli/commands/images.py +++ b/dashscope/cli/commands/images.py @@ -322,6 +322,8 @@ def jobs_list( "--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", @@ -341,6 +343,8 @@ def jobs_list( 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, ) 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/tingwu.py b/dashscope/cli/commands/tingwu.py index 98a02bff..fc17f599 100644 --- a/dashscope/cli/commands/tingwu.py +++ b/dashscope/cli/commands/tingwu.py @@ -8,7 +8,7 @@ ``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 recognize`` already +is not a shape a shell pipeline can own; ``audio asr stream`` already covers the file-driven recognizer case. """ diff --git a/dashscope/cli/commands/videos.py b/dashscope/cli/commands/videos.py index 835192ec..3cea7155 100644 --- a/dashscope/cli/commands/videos.py +++ b/dashscope/cli/commands/videos.py @@ -356,6 +356,8 @@ def jobs_list( "--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: @@ -367,6 +369,8 @@ def jobs_list( 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, ) diff --git a/dashscope/cli/compat_1x.py b/dashscope/cli/compat_1x.py new file mode 100644 index 00000000..f2819d3e --- /dev/null +++ b/dashscope/cli/compat_1x.py @@ -0,0 +1,871 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Normalize compatible 1.x spellings on commands shared with v2. + +The entry point routes legacy-only groups and dotted commands to the frozen +1.x implementation before calling this module. Shared commands are rewritten +here and executed by the v2 commands and SDK 2.0. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +from dashscope.cli.errors import usage_error + + +_DOTTED_COMMANDS = { + "fine_tunes.call": ("ft", "create"), + "fine_tunes.get": ("ft", "get"), + "fine_tunes.list": ("ft", "list"), + "fine_tunes.stream": ("ft", "stream"), + "fine_tunes.cancel": ("ft", "cancel"), + "fine_tunes.delete": ("ft", "delete"), + "generation.call": ("generation", "create"), + "files.upload": ("files", "upload"), + "files.get": ("files", "get"), + "files.list": ("files", "list"), + "files.delete": ("files", "delete"), + "deployments.call": ("deployments", "create"), + "deployments.get": ("deployments", "get"), + "deployments.list": ("deployments", "list"), + "deployments.scale": ("deployments", "scale"), + "deployments.delete": ("deployments", "delete"), + "oss.upload": ("oss", "upload"), +} + +_GROUPS = { + "auth", + "generation", + "ft", + "fine-tunes", + "files", + "deployments", + "oss", + "rerank", + "embeddings", + "tokenization", + "models", + "application", + "image-synthesis", + "video-synthesis", + "image-generation", + "multimodal-conversation", + "multimodal-embedding", + "transcription", + "speech-synthesis", + "rl", + "agentic-rl", +} + +_GROUP_ALIASES = { + "fine_tunes": "ft", + "fine-tunes": "ft", + "agentic-rl": "rl", +} + +_GROUP_HELP_TARGETS = { + "application": "apps", + "generation": "chat", + "image-generation": "images", + "image-synthesis": "images", + "multimodal-conversation": "multimodal", + "multimodal-embedding": "multimodal", + "speech-synthesis": "audio", + "transcription": "audio", + "video-synthesis": "videos", +} + +_ROOT_VALUE_OPTIONS = { + "-k", + "--api-key", + "--api_key", + "--protocol", + "-u", + "--base-url", + "--base_url", + "-w", + "--workspace", + "--region", + "--timeout", + "-o", + "--output", + "-c", +} + +_ROOT_ALIASES = { + "--api_key": "--api-key", + "--base_url": "--base-url", +} + +_OPTION_ALIASES = { + **_ROOT_ALIASES, + "--training_file_ids": "--training-file-ids", + "--validation_file_ids": "--validation-file-ids", + "--n_epochs": "--n-epochs", + "--batch_size": "--batch-size", + "--learning_rate": "--learning-rate", + "--prompt_loss": "--prompt-loss", + "--hyper_parameters": "--hyper-parameters", + "--file_id": "--file-id", + "--deployed_model": "--deployed-model", + "--start_page": "--start-page", + "--page_size": "--page-size", +} + +_VALUE_OPTIONS = frozenset( + { + *_ROOT_VALUE_OPTIONS, + "-a", + "-b", + "-d", + "-e", + "-f", + "-i", + "-l", + "-m", + "-n", + "-p", + "-q", + "-s", + "-t", + "-v", + "--app-id", + "--model", + "--prompt", + "--messages", + "--system", + "--tools", + "--temperature", + "--top-p", + "--top-k", + "--max-tokens", + "--stop", + "--seed", + "--repetition-penalty", + "--presence-penalty", + "--enable-search", + "--n", + "--result-format", + "--history", + "--parameters", + "--session-id", + "--memory-id", + "--biz-params", + "--image", + "--file", + "--rag-options", + "--doc-tag-code", + "--doc-reference-type", + "--negative-prompt", + "--size", + "--page", + "--page-size", + "--start-page", + "--image-url", + "--function", + "--style", + "--extra-input", + "--submit-timeout", + "--model-name", + "--api-key-id", + "--status", + "--start-time", + "--end-time", + "--img-url", + "--first-frame-url", + "--last-frame-url", + "--duration", + "--resolution", + "--ratio", + "--media", + "--file-url", + "--phrase-id", + "--channel-id", + "--speaker-count", + "--special-word-filter", + "--audio-format", + "--format", + "--sample-rate", + "--voice", + "--url", + "--volume", + "--rate", + "--pitch", + "--language-type", + "--training-file-ids", + "--validation-file-ids", + "--mode", + "--hyper-parameters", + "--n-epochs", + "--batch-size", + "--learning-rate", + "--prompt-loss", + "--finetuned-model", + "--purpose", + "--description", + "--file-id", + "--deployed-model", + "--capacity", + "--model-version", + "--suffix", + "--plan", + "--template-id", + "--deploy-spec", + "--billing-method", + "--input", + "--text-type", + "--dimension", + "--output-type", + "--output-format", + "--provider", + "--instruct", + "--text", + "--audio", + "--video", + "--fps", + "--res-level", + "--max-video-frames", + "--query", + "--documents", + "--document", + "--top-n", + "--rollout-classpaths", + "--reward-classpaths", + "--group-reward-classpaths", + "--rollout", + "--reward", + "--group-reward", + "--workspace-dir", + "--training-files", + "--validation-files", + "--training-file", + "--validation-file", + "--teacher-model", + "--job-name", + "--set", + "--instance-id", + "--type", + "--offset", + "--lines", + "--config", + "--key", + }, +) + +_COMMON_FLAGS = frozenset( + { + "-h", + "--help", + "--stream", + "--yes", + "-y", + "--wait", + "--no-wait", + "--verbose", + "--has-thoughts", + "--no-has-thoughts", + "--enable-thinking", + "--no-enable-thinking", + "--no-enable-search", + "--enable-fusion", + "--disable-fusion", + "--no-enable-fusion", + "--return-documents", + "--no-return-documents", + "--prompt-extend", + "--no-prompt-extend", + "--watermark", + "--no-watermark", + "--lazy-load", + "--no-lazy-load", + "--pull-logs", + "--all", + "--enable-ssml", + "--no-enable-ssml", + "--disfluency-removal-enabled", + "--diarization-enabled", + "--timestamp-alignment-enabled", + "--audio-event-detection-enabled", + }, +) + +_HOISTED_OPTIONS = { + "-k": "--api-key", + "--api-key": "--api-key", + "-w": "--workspace", + "--workspace": "--workspace", + "-u": "--base-url", + "--base-url": "--base-url", +} + + +@dataclass +class _Parsed: + options: List[Tuple[str, Optional[str]]] = field(default_factory=list) + positionals: List[str] = field(default_factory=list) + + def has(self, *names: str) -> bool: + return any(name in names for name, _ in self.options) + + def take(self, *names: str) -> List[Tuple[str, Optional[str]]]: + taken = [item for item in self.options if item[0] in names] + self.options = [item for item in self.options if item[0] not in names] + return taken + + def values(self, *names: str) -> List[Optional[str]]: + return [value for _, value in self.take(*names)] + + def add(self, name: str, value: Optional[str] = None) -> None: + self.options.append((name, value)) + + def add_flag(self, name: str) -> None: + if not self.has(name): + self.add(name) + + def rename(self, mapping: Dict[str, str]) -> None: + self.options = [ + (mapping.get(name, name), value) for name, value in self.options + ] + + def emit(self) -> List[str]: + result: List[str] = [] + for name, value in self.options: + result.append(name) + if value is not None: + result.append(value) + result.extend(self.positionals) + return result + + +def _split_option(token: str) -> Tuple[str, Optional[str]]: + name, separator, value = token.partition("=") + alias = _OPTION_ALIASES.get(name) + if alias is None and name.startswith("--"): + alias = name.replace("_", "-") + alias = alias or name + return alias, value if separator else None + + +def _normalise_token(token: str) -> str: + name, value = _split_option(token) + return f"{name}={value}" if value is not None else name + + +def _command_index(argv: Sequence[str]) -> Optional[int]: + index = 0 + while index < len(argv): + token = argv[index] + name, value = _split_option(token) + if name in _ROOT_VALUE_OPTIONS: + index += 1 if value is not None else 2 + continue + if token.startswith("-"): + index += 1 + continue + return index + return None + + +def _parse(rest: Sequence[str], flags: Iterable[str]) -> _Parsed: + parsed = _Parsed() + flags = _COMMON_FLAGS | frozenset(flags) + index = 0 + while index < len(rest): + token = rest[index] + if token == "--": + parsed.positionals.extend(rest[index + 1 :]) + break + if not token.startswith("-"): + parsed.positionals.append(token) + index += 1 + continue + name, value = _split_option(token) + if ( + value is None + and name not in flags + and name in _VALUE_OPTIONS + and index + 1 < len(rest) + ): + value = rest[index + 1] + index += 1 + parsed.add(name, value) + index += 1 + return parsed + + +def _json_scalar(raw: Optional[str]) -> object: + if raw is None: + return True + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + + +def _boolean(raw: Optional[str], option: str) -> bool: + if raw is None: + return True + value = raw.strip().lower() + if value in {"1", "true", "yes", "on"}: + return True + if value in {"0", "false", "no", "off"}: + return False + raise usage_error(f"{option} expects true or false, got {raw!r}") + + +def _merge_json_option( + parsed: _Parsed, + option: str, + values: Dict[str, object], +) -> None: + merged: Dict[str, object] = {} + existing = parsed.values(option) + for raw in existing: + try: + value = json.loads(raw or "") + except json.JSONDecodeError as exc: + raise usage_error( + f"{option} must be an inline JSON object when combined with " + "a 1.x option", + ) from exc + if not isinstance(value, dict): + raise usage_error(f"{option} must contain a JSON object") + merged.update(value) + merged.update(values) + if merged or existing: + parsed.add(option, json.dumps(merged, ensure_ascii=False)) + + +def _one(values: List[Optional[str]]) -> Optional[str]: + return values[-1] if values else None + + +def _bool_value_flag(parsed: _Parsed, option: str, negative: str) -> None: + for raw in parsed.values(option): + parsed.add(option if _boolean(raw, option) else negative) + + +def _hoist_globals( + prefix: List[str], + parsed: _Parsed, + *, + auth_login: bool, + rl: bool, + speech: bool, +) -> List[str]: + hoisted: List[str] = [] + mapping = dict(_HOISTED_OPTIONS) + if speech: + mapping["--url"] = "--base-url" + if rl: + mapping.update( + { + "-o": "--output", + "--output-format": "--output", + }, + ) + for name, value in parsed.take(*mapping): + if auth_login and name == "-k": + parsed.add(name, value) + continue + target = mapping[name] + hoisted.append(target) + if value is not None: + hoisted.append(value) + if rl: + for name, _ in parsed.take("--verbose"): + hoisted.append(name) + if parsed.has("-v"): + parsed.take("-v") + hoisted.append("--verbose") + return prefix + hoisted + + +def _normalize_generation(parsed: _Parsed) -> None: + parsed.rename({"-p": "--prompt", "-s": "--stream"}) + stops = parsed.values("--stop") + for raw in stops: + for stop in (raw or "").split(","): + if stop.strip(): + parsed.add("--stop", stop.strip()) + _bool_value_flag(parsed, "--enable-search", "--no-enable-search") + result_format = _one(parsed.values("--result-format")) + if result_format is not None: + _merge_json_option( + parsed, + "--parameters", + {"result_format": result_format}, + ) + parsed.take("--history") + + +def _normalize_multimodal_conversation(parsed: _Parsed) -> None: + text = _one(parsed.values("-t", "--text")) + images = parsed.values("--image") + content = [{"image": image} for image in images if image is not None] + if text is not None: + content.append({"text": text}) + parsed.add( + "--messages", + json.dumps( + [{"role": "user", "content": content}], + ensure_ascii=False, + ), + ) + result_format = _one(parsed.values("--result-format")) + if result_format is not None: + _merge_json_option( + parsed, + "--parameters", + {"result_format": result_format}, + ) + + +def _normalize_image_generation(parsed: _Parsed, subcommand: str) -> None: + if subcommand != "create": + return + parsed.rename({"-t": "--prompt", "--text": "--prompt", "--image": "--image-url"}) + max_images = _one(parsed.values("--max-images")) + if max_images is not None: + _merge_json_option( + parsed, + "--parameters", + {"max_images": _json_scalar(max_images)}, + ) + + +def _normalize_video(parsed: _Parsed, subcommand: str) -> None: + if subcommand != "create": + return + parsed.rename({"--img-url": "--image-url"}) + extra: Dict[str, object] = {"extend_prompt": True} + for option, key in ( + ("--negative-prompt", "negative_prompt"), + ("--first-frame-url", "first_frame_url"), + ("--last-frame-url", "last_frame_url"), + ): + value = _one(parsed.values(option)) + if value is not None: + extra[key] = value + _merge_json_option(parsed, "--extra-input", extra) + + +def _normalize_transcription(parsed: _Parsed, subcommand: str) -> None: + if subcommand != "create": + return + urls = [value for value in parsed.values("--file-url") if value is not None] + parsed.positionals = urls + parsed.positionals + parsed.add_flag("--no-wait") + channels = [ + _json_scalar(value) for value in parsed.values("--channel-id") + ] + parameters: Dict[str, object] = {} + if channels: + parameters["channel_id"] = channels + for option in ( + "--disfluency-removal-enabled", + "--diarization-enabled", + "--timestamp-alignment-enabled", + "--audio-event-detection-enabled", + ): + if parsed.has(option): + parsed.take(option) + parameters[option[2:].replace("-", "_")] = True + for option in ("--speaker-count", "--special-word-filter"): + value = _one(parsed.values(option)) + if value is not None: + parameters[option[2:].replace("-", "_")] = _json_scalar(value) + _merge_json_option(parsed, "--parameters", parameters) + + +def _normalize_speech(parsed: _Parsed) -> None: + text = _one(parsed.values("-t", "--text")) + if text is not None: + parsed.positionals.insert(0, text) + parsed.rename({"--audio-format": "--format"}) + if not parsed.has("--format"): + parsed.add("--format", "wav") + if not parsed.has("--sample-rate"): + parsed.add("--sample-rate", "24000") + extra: Dict[str, object] = {} + for option in ("--volume", "--rate", "--pitch"): + value = _one(parsed.values(option)) + if value is not None: + extra[option[2:]] = _json_scalar(value) + _merge_json_option(parsed, "--extra-input", extra) + + +def _normalize_ft(parsed: _Parsed, subcommand: str) -> None: + if subcommand == "create": + parsed.add_flag("--wait") + if subcommand == "list": + parsed.rename( + { + "-p": "--page", + "-s": "--size", + "--page-size": "--size", + "--start-page": "--page", + }, + ) + if subcommand in {"cancel", "delete"}: + parsed.add_flag("--yes") + + +def _normalize_files(parsed: _Parsed, subcommand: str) -> None: + if subcommand == "upload": + parsed.rename({"-p": "--purpose", "-d": "--description"}) + file_path = _one(parsed.values("-f", "--file")) + if file_path is not None: + parsed.positionals.insert(0, file_path) + if subcommand in {"get", "delete"}: + file_id = _one(parsed.values("--file-id")) + if file_id is not None: + parsed.positionals.insert(0, file_id) + if subcommand == "list": + parsed.rename( + { + "-p": "--page", + "-s": "--size", + "--page-size": "--size", + "--start-page": "--page", + }, + ) + if subcommand == "delete": + parsed.add_flag("--yes") + + +def _normalize_deployments(parsed: _Parsed, subcommand: str) -> None: + parsed.rename({"-c": "--capacity"}) + if subcommand in {"call", "create"}: + parsed.rename({"-s": "--suffix"}) + parsed.add_flag("--wait") + if subcommand == "list": + parsed.rename( + { + "-p": "--page", + "-s": "--size", + "--page-size": "--size", + "--start-page": "--page", + }, + ) + if subcommand in {"get", "scale", "delete"}: + deployed_model = _one(parsed.values("--deployed-model")) + if deployed_model is not None: + parsed.positionals.insert(0, deployed_model) + if subcommand in {"scale", "delete"}: + parsed.add_flag("--yes") + + +def _normalize_models(parsed: _Parsed, subcommand: str) -> None: + if subcommand == "list": + parsed.rename( + { + "-p": "--page", + "-s": "--size", + "--page-size": "--size", + "--start-page": "--page", + }, + ) + + +def _normalize_embeddings(parsed: _Parsed, subcommand: str) -> None: + if subcommand != "create": + return + parsed.rename({"-i": "--input", "-t": "--text-type"}) + inputs = [value for value in parsed.values("--input") if value is not None] + if len(inputs) == 1: + parsed.add("--input", inputs[0]) + elif inputs: + parsed.add("--input", json.dumps(inputs, ensure_ascii=False)) + + +def _normalize_rl(parsed: _Parsed, subcommand: str) -> None: + if subcommand == "register-functions": + parsed.rename( + { + "--rollout-classpaths": "--rollout", + "--reward-classpaths": "--reward", + "--group-reward-classpaths": "--group-reward", + }, + ) + elif subcommand == "test-functions": + parsed.rename({"-t": "--type", "-i": "--input"}) + if parsed.positionals: + parsed.add("--instance-id", parsed.positionals.pop(0)) + elif subcommand == "upload-data": + parsed.rename( + { + "--training-files": "--training-file", + "--validation-files": "--validation-file", + }, + ) + elif subcommand == "run": + parsed.rename({"-c": "--config"}) + teacher_model = _one(parsed.values("--teacher-model")) + if teacher_model is not None: + parsed.add("--set", f"teacher_model={teacher_model}") + elif subcommand == "list": + parsed.rename({"-p": "--page", "-s": "--size"}) + if subcommand == "cancel": + parsed.add_flag("--yes") + + +def _flags_for(group: str, subcommand: str) -> frozenset: + flags = set() + if group == "generation": + flags.update({"-s"}) + if group == "tokenization": + flags.add("--enable-search") + if group == "rl" and subcommand == "run": + flags.add("-v") + return frozenset(flags) + + +def _target(group: str, subcommand: str) -> Tuple[str, List[str], str]: + if group == "generation": + return "chat", ["create"], "create" + if group == "application": + return "apps", ["call"], "call" + if group in {"image-generation", "image-synthesis"}: + verbs = {"fetch": "get"} + verb = verbs.get(subcommand, subcommand) + return "images", ["jobs", verb], verb + if group == "video-synthesis": + verbs = {"fetch": "get"} + verb = verbs.get(subcommand, subcommand) + return "videos", ["jobs", verb], verb + if group == "multimodal-conversation": + return "multimodal", ["create"], "create" + if group == "multimodal-embedding": + return "multimodal", ["embeddings", "create"], "create" + if group == "transcription": + verbs = {"create": "transcribe", "fetch": "get"} + verb = verbs.get(subcommand, subcommand) + return "audio", ["asr", verb], verb + if group == "speech-synthesis": + return "audio", ["tts", "http"], "http" + if group == "tokenization": + return "tokenization", ["count"], "count" + verb = "create" if subcommand == "call" else subcommand + if group in {"rl", "agentic-rl"}: + verb = verb.replace("_", "-") + function_verbs = { + "register-functions": "register", + "test-functions": "test", + "upload-data": "upload-data", + } + if verb in function_verbs: + return "rl", ["functions", function_verbs[verb]], verb + if verb == "run": + return "rl", ["jobs", "create"], verb + return "rl", ["jobs", verb], verb + return _GROUP_ALIASES.get(group, group), [verb], verb + + +def normalize_argv(argv: Sequence[str]) -> List[str]: + """Return *argv* in v2 form, accepting every registered 1.x command.""" + args = list(argv) + index = _command_index(args) + if index is None: + return [_normalise_token(arg) for arg in args] + prefix = [_normalise_token(arg) for arg in args[:index]] + token = args[index] + if token == "expert" or (token not in _GROUPS and token not in _DOTTED_COMMANDS): + return prefix + args[index:] + if token in _DOTTED_COMMANDS: + group, subcommand = _DOTTED_COMMANDS[token] + rest = args[index + 1 :] + else: + group = token + rest = args[index + 1 :] + if ( + group in {"rl", "agentic-rl"} + and rest + and rest[0] in {"jobs", "functions"} + ): + return prefix + ["rl"] + rest + subcommand = "" + if rest and not rest[0].startswith("-"): + subcommand = rest.pop(0) + if not subcommand: + target = _GROUP_HELP_TARGETS.get( + group, + _GROUP_ALIASES.get(group, group), + ) + return prefix + [target] + rest + + original_subcommand = subcommand + target_group, target_path, normalized_subcommand = _target( + group, + subcommand, + ) + parsed = _parse(rest, _flags_for(group, original_subcommand)) + root = _hoist_globals( + prefix, + parsed, + auth_login=group == "auth" and original_subcommand == "login", + rl=group in {"rl", "agentic-rl"}, + speech=group == "speech-synthesis", + ) + + if group == "generation": + _normalize_generation(parsed) + elif group == "application": + parsed.rename({"-a": "--app-id", "-p": "--prompt"}) + elif group == "multimodal-conversation": + _normalize_multimodal_conversation(parsed) + elif group == "multimodal-embedding": + parsed.rename({"--disable-fusion": "--no-enable-fusion"}) + elif group in {"image-generation", "image-synthesis"}: + _normalize_image_generation(parsed, normalized_subcommand) + elif group == "video-synthesis": + _normalize_video(parsed, normalized_subcommand) + elif group == "transcription": + _normalize_transcription(parsed, original_subcommand) + elif group == "speech-synthesis": + _normalize_speech(parsed) + elif group in {"ft", "fine-tunes"}: + _normalize_ft(parsed, normalized_subcommand) + elif group == "files": + _normalize_files(parsed, original_subcommand) + elif group == "deployments": + _normalize_deployments(parsed, original_subcommand) + elif group == "models": + _normalize_models(parsed, original_subcommand) + elif group == "embeddings": + _normalize_embeddings(parsed, original_subcommand) + elif group == "rerank": + parsed.rename({"-n": "--top-n", "-i": "--instruct"}) + elif group == "tokenization": + enable_search = parsed.has("--enable-search") + parsed.take("--enable-search") + if enable_search: + _merge_json_option( + parsed, + "--parameters", + {"enable_search": True}, + ) + parsed.rename({"-p": "--prompt"}) + elif group in {"rl", "agentic-rl"}: + _normalize_rl(parsed, normalized_subcommand) + + if group in {"image-generation", "image-synthesis", "video-synthesis"}: + parsed.rename( + { + "--model-name": "--model", + "-p": "--page", + "-s": "--size", + "--page-size": "--size", + }, + ) + if normalized_subcommand == "cancel": + parsed.add_flag("--yes") + if group == "transcription": + parsed.rename({"-p": "--page", "-s": "--size"}) + if normalized_subcommand == "cancel": + parsed.add_flag("--yes") + + return root + [target_group] + target_path + parsed.emit() diff --git a/dashscope/cli/input.py b/dashscope/cli/input.py index 7d7ed312..7dbe07c8 100644 --- a/dashscope/cli/input.py +++ b/dashscope/cli/input.py @@ -224,20 +224,22 @@ def read_prompt_or_messages( 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`` > piped 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. + 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 stdin_is_piped(): + if allow_stdin_prompt and (explicit_stdin or stdin_is_piped()): text = read_stdin().strip() if text: if _looks_like_json(text): diff --git a/dashscope/cli/main.py b/dashscope/cli/main.py index ad499390..ebf0c1ee 100644 --- a/dashscope/cli/main.py +++ b/dashscope/cli/main.py @@ -1,43 +1,76 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""Console-script entry point for ``dashscope``: the v2 tree, or the agent. - -Everything this entry point runs is SDK 2.0. The 1.x command tree still -ships, and still answers to its own console script ``dashscope1``, but -``dashscope`` no longer delegates to it: a 1.x-only name is *rejected*, with -the v2 spelling that replaces it. Delegating would leave two SDKs reachable -from one command, and the point of the v2 tree is that there is one. - -The single thing still imported from ``legacy_1x`` is ``_route_to_expert``, -the bundled agent behind ``expert`` and a bare ``dashscope``. That is not -part of the 1.x command tree -- it merely lives in the same module -- and -the import is deferred so ``--help`` and every v2 command skip paying for -it. +"""Route ``dashscope`` to the SDK-shaped v2 tree, frozen 1.x commands, or agent. + +Legacy-only command names and dotted spellings keep their original 1.x +implementation. Shared names such as ``ft`` and ``files`` are served by v2 +with compatible option spellings. There is only one console entry point. """ 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 +from dashscope.cli.compat_1x import normalize_argv +from dashscope.cli.errors import CliError, fail NEW = "new" AGENT = "agent" -REMOVED = "removed" +LEGACY = "legacy" -# Names this entry point serves itself. ``fine-tunes`` is the hidden alias -# the 1.x tree already used for ``ft``, so muscle memory keeps working. -# Pinned to the groups ``app.py`` actually registers by a test. +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", + }, +) + +# Names this entry point serves itself. ``fine-tunes`` and ``agentic-rl`` +# are the hidden aliases the 1.x tree already used for ``ft`` and ``rl``, +# so muscle memory keeps working. 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", @@ -47,6 +80,7 @@ "apps", "deployments", "rl", + "agentic-rl", "audio", "tingwu", "decision", @@ -56,34 +90,6 @@ }, ) -# 1.x command groups whose v2 replacement has landed. The rejection names -# the replacement, so an old script is told what to change into rather than -# only that it stopped working. -# -# argv is deliberately *not* rewritten to the v2 spelling: the two trees do -# not share an option surface (``generation call --prompt`` has no -# ``chat create --prompt``), so a translated command line would fail on the -# first flag and blame the user for a mistake the CLI made. Naming the -# replacement and stopping is honest; guessing at it is not. -REPLACED_COMMANDS = { - "application": "apps completions create", - "agentic-rl": "rl", - "generation": "chat create", - "image-generation": "images generate", - "image-synthesis": "images generate", - "multimodal-conversation": "multimodal chat create", - "multimodal-embedding": "multimodal embeddings create", - "speech-synthesis": "audio tts synthesize", - "transcription": "audio asr transcribe", - "video-synthesis": "videos generate", -} - -# 1.x command groups with no v2 equivalent. ``dashscope1`` still serves -# them, so the rejection can say where to go instead of only saying no. -UNSUPPORTED_COMMANDS = frozenset({"auth", "oss"}) - -REMOVED_COMMANDS = frozenset(REPLACED_COMMANDS) | UNSUPPORTED_COMMANDS - # The bundled agent's routing keyword, the flags that pick its interactive # mode, and the help request it answers itself. _AGENT_COMMAND = "expert" @@ -115,8 +121,10 @@ { "-k", "--api-key", + "--api_key", "--protocol", "--base-url", + "--base_url", "--workspace", "--region", "--timeout", @@ -129,7 +137,7 @@ # 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") +_VALUE_OPTIONS = ("-k", "--api-key", "--api_key") def _first_command_token(argv: Sequence[str]) -> Optional[str]: @@ -193,37 +201,6 @@ def _api_key_from(argv: Sequence[str]) -> Optional[str]: return None -def _legacy_group(token: str) -> str: - """The command group *token* names, dotted spelling resolved. - - ``fine_tunes.list`` is the pre-Typer form the 1.x tree translated - through a table of its own. Its group half regularises to the v2 name - by swapping underscores for hyphens, which is what lets the router - judge a dotted spelling without carrying that table again. - """ - return token.partition(".")[0].replace("_", "-") - - -def _replacement_for(token: str) -> Optional[str]: - """The v2 command that takes over *token*, or ``None`` if none does. - - Covers the dotted spellings too: the translation is regular enough to - derive -- ``call`` was the old spelling of ``create`` -- so deriving it - keeps one less table in step with the v2 groups. - """ - if token in REPLACED_COMMANDS: - return REPLACED_COMMANDS[token] - if "." not in token: - return None - group = _legacy_group(token) - sub = token.partition(".")[2] - if group in NEW_COMMANDS: - return f"{group} {'create' if sub == 'call' else sub}" - # ``generation.call``: the group is gone, so fall back to whatever - # replaced it and let that carry the subcommand. - return REPLACED_COMMANDS.get(group) - - def _classify_without_command(argv: Sequence[str]) -> str: """Route an invocation that names no command.""" if "-h" in argv or "--help" in argv: @@ -243,44 +220,26 @@ def _classify_without_command(argv: Sequence[str]) -> str: def classify(argv: Sequence[str]) -> str: - """Decide what serves *argv*: the v2 tree, the agent, or a rejection.""" + """Route legacy-only names to 1.x and everything else to v2 or agent.""" token = _first_command_token(argv) if token is None: return _classify_without_command(argv) if token == _AGENT_COMMAND: return AGENT - if token in NEW_COMMANDS: - return NEW - # A removed 1.x name -- or a dotted spelling of one, whose group half - # decides it. Everything else, unknown or misspelled, goes to the v2 - # parser, so a typo gets a real usage error and the exit code matches - # the new tiers. - if ( - _legacy_group(token) in REMOVED_COMMANDS - or _replacement_for(token) is not None - ): - return REMOVED + if token in LEGACY_GROUPS or token in LEGACY_DOTTED_COMMANDS: + return LEGACY return NEW -def _removed_error(token: str): - """The rejection for a 1.x name this entry point no longer serves.""" - replacement = _replacement_for(token) - if replacement is None: - return usage_error( - f"`dashscope {token}` is a 1.x command with no v2 equivalent.", - solution=( - f"`dashscope1 {token}` still runs it against the 1.x " - "client. `dashscope --help` lists the v2 commands." - ), - ) - return usage_error( - f"`dashscope {token}` is a 1.x command and is no longer served here.", - solution=( - f"Use `dashscope {replacement}` instead. " - "`dashscope --help` lists the v2 commands." - ), - ) +def _run_legacy(argv: Sequence[str]) -> None: + previous = sys.argv + sys.argv = ["dashscope", *argv] + try: + from dashscope.legacy_1x.cli import main as legacy_main + + legacy_main(prog_name="dashscope") + finally: + sys.argv = previous def _run_agent(argv: Sequence[str]) -> None: @@ -307,11 +266,9 @@ def _run_agent(argv: Sequence[str]) -> None: dashscope.api_key = api_key - # Importing this module builds the 1.x Typer app and its eighteen - # command groups as a side effect, so the import stays in here: only an - # agent invocation should pay for it. + # Keep the agent and its optional dependencies off every other command. # pylint: disable-next=import-outside-toplevel - from dashscope.legacy_1x.cli import _route_to_expert + from dashscope.acli.expert import _route_to_expert _route_to_expert(" ".join(tokens).strip() or None, tui=tui) @@ -319,13 +276,33 @@ def _run_agent(argv: Sequence[str]) -> None: 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 follows the same CLI routing. + # Click signals a completion run through this env var, and a completion call + # often names no command, which would otherwise classify as a bare + # ``dashscope`` and open the agent instead of listing completions. + if os.environ.get("_DASHSCOPE_COMPLETE"): + if classify(argv) is LEGACY: + _run_legacy(argv) + return + from dashscope.cli.app import build_app + + build_app()(argv) + return + target = classify(argv) + if target is LEGACY: + _run_legacy(argv) + return + + try: + argv = normalize_argv(argv) + except CliError as exc: + fail(exc) if target is AGENT: _run_agent(argv) return - if target is REMOVED: - fail(_removed_error(_first_command_token(argv) or "")) from dashscope.cli.app import build_app diff --git a/dashscope/legacy_1x/cli/__init__.py b/dashscope/legacy_1x/cli/__init__.py index b72c1dde..7d6c9b05 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 @@ -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) # --------------------------------------------------------------------------- @@ -517,7 +389,7 @@ def _first_non_option(argv): return None -def main(): +def main(prog_name: Optional[str] = None): """Entry point for the ``dashscope`` console script.""" # Translate legacy command format first so legacy --api_key can be treated # as the global --api-key option. @@ -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/chat/completions.py b/dashscope/resources/chat/completions.py index ef906d7f..6c15873b 100644 --- a/dashscope/resources/chat/completions.py +++ b/dashscope/resources/chat/completions.py @@ -325,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/examples/05_cli/README.md b/examples/05_cli/README.md deleted file mode 100644 index 8f04b70b..00000000 --- a/examples/05_cli/README.md +++ /dev/null @@ -1,518 +0,0 @@ -# 05 Command Line - -> **English** | [中文](README_zh.md) - -The other tiers drive the SDK from Python. This one drives it from a **shell**: the `dashscope` command is the same capabilities behind a different contract, and the contract is what makes it scriptable. Three output formats, a layered exit code, one fixed stderr error line, four configuration layers with a documented precedence, and a refusal to guess when a destructive command meets a terminal-less CI job. Every guarantee below is a promise a build script can rely on — and every one of them is invisible if you only ever read the Python examples. - -| File | What it shows | Network | -| --- | --- | --- | -| [`cli_output_and_errors.py`](./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, `@file` / stdin / `--messages` input shapes, and all four failure tiers | No | -| [`cli_ci_harness.py`](./cli_ci_harness.py) | The contract from the outside: a stdlib mock gateway on an ephemeral port, driving the **real CLI in a real subprocess** — parse `-o json`, branch and retry on exit codes, consume an NDJSON stream line by line, isolate configuration per job, and survive the destructive-command prompt | No (offline mock by default; `CLI_HARNESS_LIVE=1` hits the real gateway, read-only scenarios only) | - -Read them in that order. The first one shows *what* comes out of each channel; the second shows *how a pipeline should consume it*. - ---- - -## Install and first call - -The package registers a console script, so after `pip install -U dashscope` there is a `dashscope` command on your `PATH`: - -```bash -dashscope --help -dashscope version -``` - -From a source checkout where the script is not installed, the module entry point is identical: - -```bash -python -m dashscope.cli --help -python -m dashscope.cli version -``` - -> The 1.x CLI lives at a separate entry point (`dashscope1` / `python -m dashscope.legacy_1x.cli`). See [`../legacy_1x/`](../legacy_1x/) for details. - -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 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 entry point runs the v2 CLI only: it does **not** delegate to the 1.x tree, so an old name is rejected with exit code 2 instead of quietly running against the old client. `main.py` sorts argv into three buckets — the v2 tree above, a **replaced** set (`generation`, `application`, `image-synthesis`, `multimodal-conversation`, `speech-synthesis`, `transcription`, `video-synthesis`, `image-generation`, `multimodal-embedding`, `agentic-rl`) whose error names the v2 command that took over, and an **unsupported** set (`auth`, `oss`) with no v2 equivalent yet. The rejection names the replacement for you — `REPLACED_COMMANDS` in `main.py` is the table it reads, so `dashscope video-synthesis create ...` tells you to use `dashscope videos generate` instead of leaving you to hunt for it. The pre-Typer dotted spellings are rejected the same way and resolve through the same table: `dashscope fine_tunes.list` points at `dashscope fine-tunes list`. Anything you still need from the 1.x client is reachable as `dashscope1`. 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. - ---- - -## A pit you will step into without reading the source - -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`. - -See also [`../README.md`](../README.md) for how this tier fits the rest, and [`../02_advanced_engineering/local_mock_debugging.py`](../02_advanced_engineering/local_mock_debugging.py) for the SDK-side equivalent of the mock gateway the harness builds here. diff --git a/examples/05_cli/README_zh.md b/examples/05_cli/README_zh.md deleted file mode 100644 index e750e588..00000000 --- a/examples/05_cli/README_zh.md +++ /dev/null @@ -1,518 +0,0 @@ -# 05 命令行 - -> [English](README.md) | **中文** - -其他各层都是从 Python 里驱动 SDK,这一层是从 **shell** 里驱动它:`dashscope` 命令是同一批能力的另一个契约面,而「可脚本化」正是这个契约带来的。三档输出格式、分层退出码、固定的一行 stderr 错误、四层配置及其明确的优先级、以及在无终端的 CI 作业里遇到破坏性命令时拒绝替你猜。下面每一条保证都是构建脚本可以依赖的承诺——而如果你只读过 Python 示例,这些保证一条都看不见。 - -| 文件 | 演示内容 | 联网 | -| --- | --- | --- | -| [`cli_output_and_errors.py`](./cli_output_and_errors.py) | 从内部看契约:一个假 client,用 `typer.testing.CliRunner` 在进程内驱动,把 table / json / yaml 三档、NDJSON 流式、`@file` / stdin / `--messages` 三种输入形态,以及四类失败档位的 stdout / stderr / 退出码并排打印出来 | 否 | -| [`cli_ci_harness.py`](./cli_ci_harness.py) | 从外部看契约:用标准库在临时端口起一个 mock 网关,驱动**真实子进程里的真实 CLI**——解析 `-o json`、按退出码分支与重试、逐行消费 NDJSON 流、按作业隔离配置,并闯过破坏性命令的确认关 | 否(默认离线 mock;`CLI_HARNESS_LIVE=1` 打真实网关,仅只读场景) | - -建议按这个顺序读。第一个演示每个通道**吐出什么**,第二个演示**流水线该怎么消费它**。 - ---- - -## 安装与第一次调用 - -包里注册了 console script,所以 `pip install -U dashscope` 之后 `PATH` 上就有一个 `dashscope` 命令: - -```bash -dashscope --help -dashscope version -``` - -在没装 console script 的源码检出里,模块入口完全等价: - -```bash -python -m dashscope.cli --help -python -m dashscope.cli version -``` - -> 1.x CLI 有独立的入口(`dashscope1` / `python -m dashscope.legacy_1x.cli`),详见 [`../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 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` | 每个键、它的生效值,以及这个值来自哪一层 | - -这个入口只跑 v2 CLI:它**不再**委派给 1.x 命令树,所以旧命令名会以退出码 2 被拒绝,而不是悄悄跑在旧客户端上。`main.py` 把 argv 分成三类——上面这棵 v2 命令树、一组**已被替代**的命令(`generation`、`application`、`image-synthesis`、`multimodal-conversation`、`speech-synthesis`、`transcription`、`video-synthesis`、`image-generation`、`multimodal-embedding`、`agentic-rl`,报错时会给出接管它的 v2 命令),以及一组**尚无 v2 等价物**的命令(`auth`、`oss`)。报错会直接告诉你 v2 的对应写法——它读的是 `main.py` 里的 `REPLACED_COMMANDS` 表,所以 `dashscope video-synthesis create ...` 会提示你改用 `dashscope videos generate`,而不是让你自己去翻。Typer 之前的点号写法同样被拒绝,并走同一张表解析:`dashscope fine_tunes.list` 会指向 `dashscope fine-tunes list`。仍然需要 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`。 - -另见 [`../README_zh.md`](../README_zh.md) 了解这一层在整体中的位置,以及 [`../02_advanced_engineering/local_mock_debugging.py`](../02_advanced_engineering/local_mock_debugging.py)——那是这里的 mock 网关在 SDK 侧的对应物。 diff --git a/examples/05_cli/cli_ci_harness.py b/examples/05_cli/cli_ci_harness.py index 989d5777..15acc15d 100644 --- a/examples/05_cli/cli_ci_harness.py +++ b/examples/05_cli/cli_ci_harness.py @@ -773,5 +773,5 @@ def main() -> int: # 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: 05_cli/README.md +# 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 index beca3585..50c09116 100644 --- a/examples/05_cli/cli_output_and_errors.py +++ b/examples/05_cli/cli_output_and_errors.py @@ -602,6 +602,6 @@ def main() -> int: # 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: 05_cli/README.md +# 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 acb2bfc2..da03ddc4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -149,15 +149,501 @@ The same capabilities behind a **shell** contract rather than a Python one: thre | `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 | -The command surface itself (`chat`, `ft`, `files`, `models`, `images`, `videos`, `embeddings`, `multimodal`, `rerank`, `tokenization`, `apps`, `deployments`, `rl`, `audio`, `tingwu`, `decision`, `agentstudio`, `config`, `version`), the full exit-code and precedence tables, and copy-pasteable shell recipes live in the tier's own README. +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*. -See [`05_cli/README.md`](./05_cli/README.md). +### Install and first call + +The package registers a console script, so after `pip install -U dashscope` there is a `dashscope` command on your `PATH`: + +```bash +dashscope --help +dashscope version +``` + +From a source checkout where the script is not installed, the module entry point is identical: + +```bash +python -m dashscope.cli --help +python -m dashscope.cli version +``` + +> Existing 1.x command forms use this same `dashscope` entry point. Legacy-only names run against the frozen 1.x implementation; shared names accept their old option spellings through the 2.0 tree. 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 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 | + +One `dashscope` entry point serves both generations. Legacy-only groups (`generation`, `application`, `image-synthesis`, `multimodal-conversation`, `speech-synthesis`, `transcription`, `video-synthesis`, `image-generation`, `multimodal-embedding`) and pre-Typer dotted spellings keep their original argv and run against the frozen 1.x implementation. Shared groups such as `ft`/`fine-tunes`, `files`, `deployments`, `models`, `rl`/`agentic-rl`, `auth`, and `oss` run through 2.0; the compatibility layer accepts the old option spellings and preserves old defaults where they differ. New 2.0 commands follow the SDK resource tree, so application invocation is `apps completions create`, not a shortened `apps call`. 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:** the legacy command tree is available as `dashscope1` (console script) or `python -m dashscope.legacy_1x.cli` (module entry). It is separate from the 2.0 `dashscope` command documented in [`05_cli/`](./05_cli/). +**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 | | --- | --- | diff --git a/examples/README_zh.md b/examples/README_zh.md index c5821840..f7ca94b6 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -149,15 +149,501 @@ examples/ | `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` | 否 | -命令面本身(`chat`、`ft`、`files`、`models`、`images`、`videos`、`embeddings`、`multimodal`、`rerank`、`tokenization`、`apps`、`deployments`、`rl`、`audio`、`tingwu`、`decision`、`agentstudio`、`config`、`version`)、完整的退出码与优先级表格,以及可直接复制的 shell 配方,都在这一层自己的 README 里。 +建议按表格顺序读两个示例。第一个演示每个通道**吐出什么**,第二个演示**流水线该怎么消费它**。 -详见 [`05_cli/README.md`](./05_cli/README.md)。 +### 安装与第一次调用 + +包里注册了 console script,所以 `pip install -U dashscope` 之后 `PATH` 上就有一个 `dashscope` 命令: + +```bash +dashscope --help +dashscope version +``` + +在没装 console script 的源码检出里,模块入口完全等价: + +```bash +python -m dashscope.cli --help +python -m dashscope.cli version +``` + +> 现有 1.x 命令形式同样使用 `dashscope` 入口。1.x 专有名称仍由冻结的 1.x 实现执行,共享名称则通过 2.0 命令树兼容旧参数拼写。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 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` | 每个键、它的生效值,以及这个值来自哪一层 | + +一个 `dashscope` 入口同时服务两代命令。1.x 专有命令组(`generation`、`application`、`image-synthesis`、`multimodal-conversation`、`speech-synthesis`、`transcription`、`video-synthesis`、`image-generation`、`multimodal-embedding`)和 Typer 之前的点式拼写保留原始 argv,由冻结的 1.x 实现执行。`ft`/`fine-tunes`、`files`、`deployments`、`models`、`rl`/`agentic-rl`、`auth`、`oss` 等共享命令组由 2.0 执行;兼容层接受旧参数拼写,并在默认值不同时保留旧默认。新的 2.0 命令按 SDK 资源树命名,因此应用调用是 `apps completions create`,不是简化的 `apps call`。不带子命令的裸 `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:** 历史命令树通过 `dashscope1`(console script)或 `python -m dashscope.legacy_1x.cli`(模块入口)访问,与 [`05_cli/`](./05_cli/) 中的 2.0 `dashscope` 命令相互独立。 +**1.x CLI:** 现有 1.x 命令形式与 [`05_cli/`](./05_cli/) 中的 2.0 命令共用 `dashscope` 入口。1.x 专有名称仍由冻结的 1.x 实现执行,共享名称由 2.0 执行并兼容旧参数拼写。 | 路径 | 内容 | | --- | --- | diff --git a/setup.py b/setup.py index 547e27d0..72f6f932 100644 --- a/setup.py +++ b/setup.py @@ -144,7 +144,6 @@ def run(self): entry_points={ "console_scripts": [ "dashscope = dashscope.cli.main:main", - "dashscope1 = dashscope.legacy_1x.cli:main", ], }, ) diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index 23c072c2..9a47e861 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -100,7 +100,8 @@ def test_router_and_callback_agree_on_value_taking_options(self, app): # 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) + legacy_aliases = {"--api_key", "--base_url"} + assert actual | legacy_aliases == set(_VALUE_TAKING_OPTIONS) class TestVersionCommand: diff --git a/tests/cli/test_audio_cmd.py b/tests/cli/test_audio_cmd.py index 286715e9..e28c439d 100644 --- a/tests/cli/test_audio_cmd.py +++ b/tests/cli/test_audio_cmd.py @@ -76,9 +76,17 @@ def __init__(self, http_result: Any = None) -> None: ) def synthesize(self, text, **kwargs): - self.calls.append({"text": text, "kwargs": 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: @@ -341,6 +349,50 @@ def test_a_missing_output_file_is_a_usage_error( 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() @@ -530,7 +582,7 @@ def test_wait_rejects_a_timeout_it_could_not_honour( assert resource.asr.jobs.calls == [] -class TestAsrRecognize: +class TestAsrStream: def test_prints_only_the_final_sentences(self, invoke, audio, tmp_path): source = tmp_path / "a.wav" source.write_bytes(b"RIFF") @@ -546,7 +598,7 @@ def test_prints_only_the_final_sentences(self, invoke, audio, tmp_path): [ "audio", "asr", - "recognize", + "stream", str(source), "-m", "paraformer-realtime-v2", @@ -566,7 +618,7 @@ def test_passes_the_recognition_options(self, invoke, audio, tmp_path): [ "audio", "asr", - "recognize", + "stream", str(source), "-m", "paraformer-realtime-v2", @@ -599,7 +651,7 @@ def test_json_output_is_one_event_per_line(self, invoke, audio, tmp_path): [ "audio", "asr", - "recognize", + "stream", str(source), "-m", "paraformer-realtime-v2", @@ -616,7 +668,7 @@ def test_a_missing_file_is_a_usage_error(self, invoke, audio, tmp_path): [ "audio", "asr", - "recognize", + "stream", str(tmp_path / "nope.wav"), "-m", "paraformer-realtime-v2", 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 index 10bc7d9d..e948b7c7 100644 --- a/tests/cli/test_chat_cmd.py +++ b/tests/cli/test_chat_cmd.py @@ -231,6 +231,50 @@ def test_piped_stdin_becomes_the_prompt(self, invoke, completions): {"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"]) diff --git a/tests/cli/test_compat_1x.py b/tests/cli/test_compat_1x.py new file mode 100644 index 00000000..7a927065 --- /dev/null +++ b/tests/cli/test_compat_1x.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Compatible option spellings on commands shared by 1.x and v2.""" + +import json + +import pytest + +from dashscope.cli.compat_1x import normalize_argv +from dashscope.cli.errors import CliError + + +@pytest.mark.parametrize( + ("argv", "prefix"), + [ + (["auth", "whoami"], ["auth", "whoami"]), + (["auth", "login"], ["auth", "login"]), + (["auth", "logout"], ["auth", "logout"]), + (["ft", "create"], ["ft", "create"]), + (["fine-tunes", "call"], ["ft", "create"]), + (["ft", "get"], ["ft", "get"]), + (["ft", "list"], ["ft", "list"]), + (["ft", "stream"], ["ft", "stream"]), + (["ft", "cancel"], ["ft", "cancel"]), + (["ft", "delete"], ["ft", "delete"]), + (["files", "upload"], ["files", "upload"]), + (["files", "get"], ["files", "get"]), + (["files", "list"], ["files", "list"]), + (["files", "delete"], ["files", "delete"]), + (["deployments", "create"], ["deployments", "create"]), + (["deployments", "call"], ["deployments", "create"]), + (["deployments", "get"], ["deployments", "get"]), + (["deployments", "list"], ["deployments", "list"]), + (["deployments", "scale"], ["deployments", "scale"]), + (["deployments", "delete"], ["deployments", "delete"]), + (["oss", "upload"], ["oss", "upload"]), + (["rerank", "create"], ["rerank", "create"]), + (["embeddings", "create"], ["embeddings", "create"]), + (["tokenization", "create"], ["tokenization", "count"]), + (["models", "list"], ["models", "list"]), + (["models", "get"], ["models", "get"]), + (["rl", "register_functions"], ["rl", "functions", "register"]), + (["agentic-rl", "test_functions"], ["rl", "functions", "test"]), + (["rl", "upload_data"], ["rl", "functions", "upload-data"]), + (["rl", "run"], ["rl", "jobs", "create"]), + (["rl", "get"], ["rl", "jobs", "get"]), + (["rl", "logs"], ["rl", "jobs", "logs"]), + (["rl", "list"], ["rl", "jobs", "list"]), + (["rl", "cancel"], ["rl", "jobs", "cancel"]), + ], +) +def test_shared_commands_map_to_v2(argv, prefix): + assert normalize_argv(argv)[: len(prefix)] == prefix + + +def test_root_equals_aliases_and_trailing_globals(): + assert normalize_argv(["--api_key=sk-1", "ft", "list"])[:2] == [ + "--api-key=sk-1", + "ft", + ] + assert normalize_argv(["ft", "list", "--base_url=https://example.com"])[ + :2 + ] == ["--base-url", "https://example.com"] + + +def test_shared_list_options_keep_legacy_spellings(): + for group in ("ft", "files", "deployments", "models"): + result = normalize_argv( + [group, "list", "--start_page", "2", "--page_size", "5"], + ) + assert result[result.index("--page") + 1] == "2" + assert result[result.index("--size") + 1] == "5" + + +def test_shared_legacy_defaults_are_preserved(): + assert "--wait" in normalize_argv(["deployments", "call", "-m", "model"]) + assert "--yes" in normalize_argv(["deployments", "delete", "name"]) + assert "--yes" in normalize_argv(["files", "delete", "file-1"]) + assert "--yes" in normalize_argv(["rl", "cancel", "job"]) + assert "--wait" in normalize_argv(["ft", "create"]) + assert "--wait" in normalize_argv(["fine-tunes", "create"]) + + +def test_ft_training_file_options_are_normalised(): + result = normalize_argv( + [ + "ft", + "call", + "--training_file_ids", + "train-1", + "--validation_file_ids", + "valid-1", + "--n_epochs", + "2", + "--learning_rate", + "0.0001", + ], + ) + for option in ( + "--training-file-ids", + "--validation-file-ids", + "--n-epochs", + "--learning-rate", + "--wait", + ): + assert option in result + + +def test_tokenization_legacy_create_and_enable_search(): + result = normalize_argv( + [ + "tokenization", + "create", + "-m", + "qwen-plus", + "-p", + "hello", + "--enable-search", + ], + ) + assert result[:3] == ["tokenization", "count", "-m"] + parameters = json.loads(result[result.index("--parameters") + 1]) + assert parameters == {"enable_search": True} + + +def test_rl_legacy_names_and_config_overrides(): + result = normalize_argv( + [ + "agentic-rl", + "run", + "--config", + "rl.toml", + "--teacher-model", + "qwen-plus", + "-o", + "json", + "-v", + ], + ) + assert result[:7] == [ + "--output", + "json", + "--verbose", + "rl", + "jobs", + "create", + "--config", + ] + assert result[result.index("--set") + 1] == "teacher_model=qwen-plus" + + +def test_sdk_shaped_v2_commands_are_not_rewritten(): + for argv in ( + ["apps", "completions", "create", "--app-id", "app-1"], + ["multimodal", "chat", "create", "-m", "qwen-vl-plus"], + ["rl", "jobs", "get", "job-1"], + ["rl", "functions", "logs", "instance-1", "--keyword", "error"], + ): + assert normalize_argv(argv) == argv + + +def test_value_options_on_shared_commands_keep_their_values(): + assert normalize_argv(["models", "list", "--provider", "dashscope"]) == [ + "models", + "list", + "--provider", + "dashscope", + ] + assert normalize_argv(["rl", "get", "job-1", "--output-format", "json"])[ + :2 + ] == ["--output", "json"] + + +def test_merging_legacy_fields_requires_an_inline_json_object(): + with pytest.raises(CliError) as caught: + normalize_argv( + [ + "tokenization", + "create", + "--parameters", + "@params.json", + "--enable-search", + ], + ) + assert caught.value.category == "usage" diff --git a/tests/cli/test_deployments_cmd.py b/tests/cli/test_deployments_cmd.py index f351d0e8..e119e011 100644 --- a/tests/cli/test_deployments_cmd.py +++ b/tests/cli/test_deployments_cmd.py @@ -161,10 +161,11 @@ def test_create_rejects_a_non_object_deploy_spec( ) assert result.exit_code == 2 - def test_create_requires_capacity(self, invoke, deployments): - deployments(create=_deployment()) + 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 == 2 + assert result.exit_code == 0 + assert resource.calls[0]["kwargs"]["capacity"] == 1 class TestRead: diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py index 402a42a3..53ba793a 100644 --- a/tests/cli/test_dispatch.py +++ b/tests/cli/test_dispatch.py @@ -2,9 +2,8 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Routing at the ``dashscope`` console-script entry point. -The entry point serves the v2 tree or the bundled agent, and rejects 1.x -command names. What it must never do is hand a command line to the 1.x -tree, so several tests below assert on the *absence* of that call. +The single entry point serves SDK-shaped v2 commands, frozen 1.x commands, +and the bundled agent. Shared names use v2 with compatible option aliases. """ import sys @@ -13,15 +12,13 @@ import pytest import typer -from dashscope.cli.errors import EXIT_USAGE from dashscope.cli.main import ( AGENT, + LEGACY, + LEGACY_DOTTED_COMMANDS, + LEGACY_GROUPS, NEW, NEW_COMMANDS, - REMOVED, - REMOVED_COMMANDS, - REPLACED_COMMANDS, - UNSUPPORTED_COMMANDS, classify, main, ) @@ -31,6 +28,10 @@ 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"], @@ -44,7 +45,7 @@ class TestClassify: ["tokenization", "count"], ["apps", "completions", "create", "--app-id", "app-1"], ["deployments", "list"], - ["rl", "jobs", "list"], + ["rl", "list"], ["audio", "tts", "synthesize", "hi"], ["config", "list"], ["version"], @@ -52,18 +53,14 @@ class TestClassify: ["-k", "sk-1", "chat", "create"], ["--api-key", "sk-1", "-o", "json", "ft", "list"], ["--api-key=sk-1", "version"], - # A misspelled command must reach the v2 parser so the exit - # code matches the new tiers (2), not the 1.x tree's. ["chatz", "create"], ["auth_x"], - # A stray option with no command is a command-line mistake. ["-k"], ["--verbose"], - # --help with no command describes the tree being written - # against from here on. ["--help"], ["-h"], ["-k", "sk-1", "--help"], + ["chatz.create"], ], ) def test_new_tree(self, argv): @@ -72,8 +69,8 @@ def test_new_tree(self, argv): @pytest.mark.parametrize( "argv", [ - [], # bare `dashscope` → TUI - ["-k", "sk-1"], # nothing but the key → still the agent + [], + ["-k", "sk-1"], ["--api-key=sk-1"], ["expert", "what", "is", "lora"], ["expert", "--help"], @@ -84,75 +81,31 @@ def test_new_tree(self, argv): def test_agent(self, argv): assert classify(argv) == AGENT - @pytest.mark.parametrize("name", sorted(REMOVED_COMMANDS)) - def test_removed_1x_names(self, name): - assert classify([name, "list"]) == REMOVED + @pytest.mark.parametrize("group", sorted(LEGACY_GROUPS)) + def test_legacy_groups(self, group): + assert classify([group, "create"]) == LEGACY - @pytest.mark.parametrize( - "token", - [ - "fine_tunes.list", - "files.upload", - "deployments.call", - "generation.call", - "oss.upload", - "auth.whoami", - ], - ) - def test_dotted_spellings_do_not_reach_the_1x_tree(self, token): - """The pre-Typer form is judged by its group, like any other name.""" - assert classify([token, "--page-size", "5"]) == REMOVED - - def test_a_dotted_spelling_of_an_unknown_group_is_a_plain_typo(self): - assert classify(["chatz.create"]) == NEW - - def test_ft_and_its_alias_stay_together(self): - """`ft` and hidden `fine-tunes` must land in the same tree.""" - assert ( - classify(["ft", "list"]) == classify(["fine-tunes", "list"]) == NEW - ) + @pytest.mark.parametrize("command", sorted(LEGACY_DOTTED_COMMANDS)) + def test_legacy_dotted_commands(self, command): + assert classify([command]) == LEGACY def test_global_options_do_not_hide_the_command(self): assert ( classify(["-k", "sk-1", "--output", "json", "ft", "list"]) == NEW ) - assert classify(["-k", "sk-1", "generation", "create"]) == REMOVED def test_value_of_a_global_option_is_not_read_as_the_command(self): - """``-o json`` must not make ``json`` look like the command.""" - assert classify(["-o", "json", "generation", "create"]) == REMOVED + assert classify(["-o", "json", "generation", "create"]) == LEGACY assert classify(["--output", "json", "ft", "list"]) == NEW assert ( - classify(["-c", "output=json", "generation", "create"]) == REMOVED + classify(["-c", "output=json", "generation", "create"]) == LEGACY ) - assert classify(["--timeout", "30", "oss", "list"]) == REMOVED - - def test_self_contained_option_forms(self): - assert classify(["--api-key=sk-1", "generation", "create"]) == REMOVED - assert classify(["--output=json", "ft", "list"]) == NEW - - def test_a_mode_flag_does_not_rescue_a_removed_command(self): - """`--cli generation create` names a command, so it is not a chat.""" - assert classify(["--cli", "generation", "create"]) == REMOVED + assert classify(["--timeout", "30", "application", "create"]) == LEGACY + assert classify(["--api_key", "sk-1", "generation.call"]) == LEGACY class TestCommandNameSets: - def test_removed_and_served_names_do_not_overlap(self): - assert NEW_COMMANDS.isdisjoint(REMOVED_COMMANDS) - assert set(REPLACED_COMMANDS).isdisjoint(UNSUPPORTED_COMMANDS) - - def test_removed_names_partition_into_replaced_and_unsupported(self): - assert ( - REMOVED_COMMANDS == set(REPLACED_COMMANDS) | UNSUPPORTED_COMMANDS - ) - - def test_every_replacement_names_a_command_the_v2_tree_serves(self): - """The rejection can only help if the mapping stays real.""" - for replacement in REPLACED_COMMANDS.values(): - assert replacement.split()[0] in NEW_COMMANDS - def test_new_command_names_match_the_registered_tree(self): - """The router's view of v2 must not drift from the app's.""" from dashscope.cli.app import build_app root = typer.main.get_command(build_app()) @@ -161,25 +114,14 @@ def test_new_command_names_match_the_registered_tree(self): @pytest.fixture def expert(monkeypatch): - """Replace the bundled agent with a recorder. - - Injected into ``sys.modules`` rather than patched on the real module so - the test never pays for importing the 1.x Typer tree. Its ``main`` is a - tripwire: reaching the agent must not drag the 1.x command tree in - behind it, which is what importing the real module would do. - """ calls = [] def route_to_expert(command, tui=None): calls.append((command, tui)) - def legacy_main(): # pragma: no cover - must never be called - raise AssertionError("the 1.x command tree was invoked") - - module = ModuleType("dashscope.legacy_1x.cli") + module = ModuleType("dashscope.acli.expert") module._route_to_expert = route_to_expert - module.main = legacy_main - monkeypatch.setitem(sys.modules, "dashscope.legacy_1x.cli", module) + monkeypatch.setitem(sys.modules, "dashscope.acli.expert", module) return calls @@ -197,6 +139,20 @@ def app(argv): return calls +@pytest.fixture +def legacy_app(monkeypatch): + calls = [] + module = ModuleType("dashscope.legacy_1x.cli") + + def run(prog_name=None): + assert prog_name == "dashscope" + calls.append(list(sys.argv)) + + module.main = run + monkeypatch.setitem(sys.modules, "dashscope.legacy_1x.cli", module) + return calls + + class TestMainDispatch: def test_new_route_builds_and_calls_the_app(self, new_app, expert): main(["version"]) @@ -218,24 +174,32 @@ def test_bare_invocation_opens_the_agent(self, new_app, expert): 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): - """The agent reads the ``dashscope.api_key`` global, so set it.""" import dashscope monkeypatch.setattr(dashscope, "api_key", None, raising=False) - main(["-k", "sk-agent", "expert", "hi"]) + main(["--api_key=sk-agent", "expert", "hi"]) assert expert == [("hi", True)] assert dashscope.api_key == "sk-agent" @@ -249,76 +213,91 @@ def test_agent_help_is_answered_without_starting_the_agent( assert expert == [] assert "DashScope SDK Expert" in capsys.readouterr().out - -class TestRemovedCommands: - def test_rejection_exits_with_the_usage_tier(self, new_app, expert): - with pytest.raises(SystemExit) as exited: - main(["generation", "create"]) - assert exited.value.code == EXIT_USAGE - assert new_app == [] - assert expert == [] - - def test_rejection_names_the_v2_replacement(self, capsys, new_app, expert): - with pytest.raises(SystemExit): - main(["video-synthesis", "create"]) - err = capsys.readouterr().err - assert "Error [usage]" in err - assert "`dashscope video-synthesis`" in err - assert "`dashscope videos generate`" in err - - def test_rejection_names_the_command_even_after_global_options( + def test_legacy_group_keeps_its_original_argv( self, - capsys, new_app, + legacy_app, expert, ): - with pytest.raises(SystemExit): - main(["-k", "sk-1", "generation", "create"]) - assert "`dashscope generation`" in capsys.readouterr().err + argv = ["generation", "call", "-m", "qwen-plus", "-p", "hi", "-s"] + main(argv) + assert legacy_app == [["dashscope", *argv]] + assert new_app == [] + assert expert == [] - def test_rejection_goes_to_stderr_not_stdout( + def test_dotted_command_keeps_its_original_argv( self, - capsys, new_app, + legacy_app, expert, ): - with pytest.raises(SystemExit): - main(["generation", "create"]) - assert capsys.readouterr().out == "" + argv = ["fine_tunes.list", "--page_size", "5", "-k", "sk-1"] + main(argv) + assert legacy_app == [["dashscope", *argv]] + assert new_app == [] - @pytest.mark.parametrize("name", sorted(UNSUPPORTED_COMMANDS)) - def test_unsupported_names_point_at_the_1x_entry_point( + def test_shared_ft_alias_uses_v2_with_legacy_options( self, - capsys, new_app, expert, - name, ): - with pytest.raises(SystemExit): - main([name, "list"]) - err = capsys.readouterr().err - assert "no v2 equivalent" in err - assert f"dashscope1 {name}" in err - - def test_dotted_spelling_points_at_its_v2_command( + main(["fine-tunes", "call", "-m", "base", "--training_file_ids", "f1"]) + assert new_app == [ + [ + "ft", + "create", + "-m", + "base", + "--training-file-ids", + "f1", + "--wait", + ], + ] + + def test_shared_rl_run_uses_the_v2_job_resource(self, new_app, legacy_app): + main(["agentic-rl", "run", "-c", "rl.toml", "-o", "json", "-v"]) + assert new_app == [ + [ + "--output", + "json", + "--verbose", + "rl", + "jobs", + "create", + "--config", + "rl.toml", + ], + ] + assert legacy_app == [] + + def test_shared_deployment_call_preserves_waiting( self, - capsys, new_app, - expert, + legacy_app, ): - with pytest.raises(SystemExit): - main(["fine_tunes.list", "--page-size", "5"]) - assert "`dashscope fine-tunes list`" in capsys.readouterr().err + main(["deployments", "call", "-m", "model", "-c", "2"]) + assert new_app == [ + [ + "deployments", + "create", + "-m", + "model", + "--capacity", + "2", + "--wait", + ], + ] + assert legacy_app == [] - def test_no_environment_switch_mutes_a_rejection( - self, - capsys, - new_app, - expert, - monkeypatch, - ): - """It is an error now, not a notice, so the old kill switch is dead.""" - monkeypatch.setenv("DASHSCOPE_CLI_NO_DEPRECATION", "1") - with pytest.raises(SystemExit): - main(["generation", "create"]) - assert "`dashscope generation`" in capsys.readouterr().err + @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, legacy_app): + main(argv) + assert new_app == [argv] + assert legacy_app == [] diff --git a/tests/cli/test_images_cmd.py b/tests/cli/test_images_cmd.py index 1187d73f..982aa607 100644 --- a/tests/cli/test_images_cmd.py +++ b/tests/cli/test_images_cmd.py @@ -298,6 +298,10 @@ def test_jobs_list_pagination_and_filters(self, invoke, images): "SUCCEEDED", "--model", "wan2.2-t2i-flash", + "--api-key-id", + "key-1", + "--region", + "cn-beijing", ], ) assert resource.jobs.calls[0]["kwargs"] == { @@ -305,6 +309,8 @@ def test_jobs_list_pagination_and_filters(self, invoke, images): "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, } diff --git a/tests/cli/test_input.py b/tests/cli/test_input.py index a21b5b8f..745ec17a 100644 --- a/tests/cli/test_input.py +++ b/tests/cli/test_input.py @@ -238,6 +238,18 @@ def test_no_input_is_a_usage_error(self, tty_stdin): 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): 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_package_layout.py b/tests/cli/test_package_layout.py index b0518ceb..d172b816 100644 --- a/tests/cli/test_package_layout.py +++ b/tests/cli/test_package_layout.py @@ -2,10 +2,9 @@ # Copyright (c) Alibaba, Inc. and its affiliates. """Structural invariants of the CLI package layout. -``dashscope.cli`` is the v2 CLI. The 1.x command tree lives at -``dashscope.legacy_1x.cli`` and is reachable through its own console -script (``dashscope1``); ``dashscope`` itself serves the v2 tree only and -rejects 1.x command names, pointing at whatever replaced them. +``dashscope.cli`` is the v2 command tree and sole console entry point. It +routes frozen 1.x-only commands to ``dashscope.legacy_1x.cli`` lazily; v2 +commands never pay that import cost. There is only one console entry point. """ import ast @@ -46,6 +45,33 @@ def test_cli_never_loads_the_1x_command_tree(): 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_routes_to_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 == 0\n" + "assert 'dashscope.legacy_1x.cli' in sys.modules\n", + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "Usage: dashscope generation call" in result.stdout + + def _module_level_imports(path: Path): tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) for node in tree.body: @@ -57,6 +83,28 @@ def _module_level_imports(path: Path): 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_only_the_entry_point_imports_legacy_1x(): + offenders = [] + for path in sorted(PACKAGE_ROOT.rglob("*.py")): + for name in _all_imports(path): + if name.startswith("dashscope.legacy_1x") and not ( + path.name == "main.py" and name == "dashscope.legacy_1x.cli" + ): + 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``. @@ -93,6 +141,14 @@ def test_console_script_target_resolves(): assert callable(module.main) +def test_legacy_tree_uses_the_shared_dashscope_name(): + from importlib import import_module + + module = import_module("dashscope.legacy_1x.cli") + assert callable(module.main) + assert module.app.info.name == "dashscope" + + def test_package_init_stays_lazy(): """``__init__.py`` imports nothing at all. diff --git a/tests/cli/test_videos_cmd.py b/tests/cli/test_videos_cmd.py index e91b1f41..258d37cf 100644 --- a/tests/cli/test_videos_cmd.py +++ b/tests/cli/test_videos_cmd.py @@ -344,6 +344,10 @@ def test_jobs_list_pagination_and_filters(self, invoke, videos): "2026-01-01", "--end-time", "2026-02-01", + "--api-key-id", + "key-1", + "--region", + "cn-beijing", ], ) assert resource.jobs.calls[0]["kwargs"] == { @@ -351,6 +355,8 @@ def test_jobs_list_pagination_and_filters(self, invoke, videos): "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", } 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/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_oss_upload.py b/tests/test_oss_upload.py index 49eed1ec..d61b5277 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) @@ -295,6 +303,26 @@ def test_oss_post_carries_no_dashscope_credential(tmp_path, monkeypatch): assert user_agent.startswith("dashscope-v2-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-v2-python/") + assert "dashscope-cli/2.0" in user_agent + + def test_oss_post_failure_maps_to_a_dashscope_error(tmp_path, monkeypatch): _capture_oss(monkeypatch, status=403) client, _ = _policy_client() From 28f47ed604fca389c60f5d385ba361ce0f7de9c4 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 17:20:33 +0800 Subject: [PATCH 09/13] fix(cli): support top-level auth shortcuts --- dashscope/cli/commands/auth.py | 2 +- dashscope/cli/compat_1x.py | 27 ++++++++++++++++++++------- examples/README.md | 1 + examples/README_zh.md | 1 + tests/cli/test_compat_1x.py | 3 +++ tests/cli/test_dispatch.py | 25 +++++++++++++++++++++++++ 6 files changed, 51 insertions(+), 8 deletions(-) diff --git a/dashscope/cli/commands/auth.py b/dashscope/cli/commands/auth.py index 650505dd..e7755eac 100644 --- a/dashscope/cli/commands/auth.py +++ b/dashscope/cli/commands/auth.py @@ -18,7 +18,7 @@ `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. +are left untouched. Top-level `login`, `whoami`, and `logout` are shortcuts. """ diff --git a/dashscope/cli/compat_1x.py b/dashscope/cli/compat_1x.py index f2819d3e..42ae1d50 100644 --- a/dashscope/cli/compat_1x.py +++ b/dashscope/cli/compat_1x.py @@ -36,6 +36,8 @@ "oss.upload": ("oss", "upload"), } +_TOP_LEVEL_AUTH_COMMANDS = {"login", "whoami", "logout"} + _GROUPS = { "auth", "generation", @@ -527,7 +529,9 @@ def _normalize_multimodal_conversation(parsed: _Parsed) -> None: def _normalize_image_generation(parsed: _Parsed, subcommand: str) -> None: if subcommand != "create": return - parsed.rename({"-t": "--prompt", "--text": "--prompt", "--image": "--image-url"}) + parsed.rename( + {"-t": "--prompt", "--text": "--prompt", "--image": "--image-url"}, + ) max_images = _one(parsed.values("--max-images")) if max_images is not None: _merge_json_option( @@ -556,12 +560,12 @@ def _normalize_video(parsed: _Parsed, subcommand: str) -> None: def _normalize_transcription(parsed: _Parsed, subcommand: str) -> None: if subcommand != "create": return - urls = [value for value in parsed.values("--file-url") if value is not None] + urls = [ + value for value in parsed.values("--file-url") if value is not None + ] parsed.positionals = urls + parsed.positionals parsed.add_flag("--no-wait") - channels = [ - _json_scalar(value) for value in parsed.values("--channel-id") - ] + channels = [_json_scalar(value) for value in parsed.values("--channel-id")] parameters: Dict[str, object] = {} if channels: parameters["channel_id"] = channels @@ -724,6 +728,7 @@ def _flags_for(group: str, subcommand: str) -> frozenset: return frozenset(flags) +# pylint: disable-next=too-many-return-statements def _target(group: str, subcommand: str) -> Tuple[str, List[str], str]: if group == "generation": return "chat", ["create"], "create" @@ -765,6 +770,7 @@ def _target(group: str, subcommand: str) -> Tuple[str, List[str], str]: return _GROUP_ALIASES.get(group, group), [verb], verb +# pylint: disable-next=too-many-branches,too-many-statements def normalize_argv(argv: Sequence[str]) -> List[str]: """Return *argv* in v2 form, accepting every registered 1.x command.""" args = list(argv) @@ -773,9 +779,16 @@ def normalize_argv(argv: Sequence[str]) -> List[str]: return [_normalise_token(arg) for arg in args] prefix = [_normalise_token(arg) for arg in args[:index]] token = args[index] - if token == "expert" or (token not in _GROUPS and token not in _DOTTED_COMMANDS): + if token == "expert" or ( + token not in _GROUPS + and token not in _DOTTED_COMMANDS + and token not in _TOP_LEVEL_AUTH_COMMANDS + ): return prefix + args[index:] - if token in _DOTTED_COMMANDS: + if token in _TOP_LEVEL_AUTH_COMMANDS: + group, subcommand = "auth", token + rest = args[index + 1 :] + elif token in _DOTTED_COMMANDS: group, subcommand = _DOTTED_COMMANDS[token] rest = args[index + 1 :] else: diff --git a/examples/README.md b/examples/README.md index da03ddc4..8c4d4e1c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -190,6 +190,7 @@ That last one is not a quirk — it is the single most common way to get a confu | 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`. Top-level `dashscope login` / `whoami` / `logout` are shortcuts | | `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 | diff --git a/examples/README_zh.md b/examples/README_zh.md index f7ca94b6..7651c499 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -190,6 +190,7 @@ dashscope chat create -m qwen-plus --prompt "hi" --output json # exi | 命令 | 用途 | | --- | --- | | `dashscope version` | 打印已安装的 SDK 版本。什么都不碰,所以是你手上最便宜的冒烟测试 | +| `dashscope auth login` / `whoami` / `logout` | 保存、验证或删除 `~/.dashscope/api_key` 中的 API Key。顶层 `dashscope login` / `whoami` / `logout` 是等价简写 | | `dashscope chat create` | 一次 chat 补全:`--prompt` / `--messages`、`--stream`、`--tools`、采样参数,其余走 `--parameters` | | `dashscope ft create` | 起一个微调作业;`--wait` 带 spinner 阻塞到终态,`--timeout` 限定等待时长 | | `dashscope ft get` | 查看单个作业 | diff --git a/tests/cli/test_compat_1x.py b/tests/cli/test_compat_1x.py index 7a927065..c4d9aa19 100644 --- a/tests/cli/test_compat_1x.py +++ b/tests/cli/test_compat_1x.py @@ -16,6 +16,9 @@ (["auth", "whoami"], ["auth", "whoami"]), (["auth", "login"], ["auth", "login"]), (["auth", "logout"], ["auth", "logout"]), + (["whoami"], ["auth", "whoami"]), + (["login"], ["auth", "login"]), + (["logout"], ["auth", "logout"]), (["ft", "create"], ["ft", "create"]), (["fine-tunes", "call"], ["ft", "create"]), (["ft", "get"], ["ft", "get"]), diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py index 53ba793a..f70f1d25 100644 --- a/tests/cli/test_dispatch.py +++ b/tests/cli/test_dispatch.py @@ -31,6 +31,9 @@ class TestClassify: ["auth", "login", "--key", "sk-1"], ["auth", "whoami"], ["auth", "logout"], + ["login", "--key", "sk-1"], + ["whoami"], + ["logout"], ["oss", "upload", "-f", "input.png", "-m", "qwen-vl-max"], ["chat", "create", "-m", "qwen-plus"], ["ft", "list"], @@ -254,6 +257,28 @@ def test_shared_ft_alias_uses_v2_with_legacy_options( ], ] + @pytest.mark.parametrize( + ("argv", "expected"), + [ + (["whoami"], ["auth", "whoami"]), + (["logout"], ["auth", "logout"]), + ( + ["login", "-k", "sk-secret"], + ["auth", "login", "-k", "sk-secret"], + ), + ], + ) + def test_top_level_auth_shortcuts_use_v2( + self, + argv, + expected, + new_app, + legacy_app, + ): + main(argv) + assert new_app == [expected] + assert legacy_app == [] + def test_shared_rl_run_uses_the_v2_job_resource(self, new_app, legacy_app): main(["agentic-rl", "run", "-c", "rl.toml", "-o", "json", "-v"]) assert new_app == [ From 1cc9519c6c74e87a15feb9651803f3f35261cae6 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 17:24:15 +0800 Subject: [PATCH 10/13] fix(cli): strip ANSI codes before assertion in test_package_layout --- tests/cli/test_package_layout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_package_layout.py b/tests/cli/test_package_layout.py index d172b816..86c5669e 100644 --- a/tests/cli/test_package_layout.py +++ b/tests/cli/test_package_layout.py @@ -12,6 +12,8 @@ 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" @@ -69,7 +71,7 @@ def test_legacy_argv_routes_to_the_legacy_cli(): "assert 'dashscope.legacy_1x.cli' in sys.modules\n", ) assert result.returncode == 0, result.stdout + result.stderr - assert "Usage: dashscope generation call" in result.stdout + assert "Usage: dashscope generation call" in strip_ansi_codes(result.stdout) def _module_level_imports(path: Path): From 14f065443cae4744eb28b5aaf6ce4b69563c1b0d Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 17:40:14 +0800 Subject: [PATCH 11/13] style(tests): format package layout assertion --- tests/cli/test_package_layout.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_package_layout.py b/tests/cli/test_package_layout.py index 86c5669e..86a54d8a 100644 --- a/tests/cli/test_package_layout.py +++ b/tests/cli/test_package_layout.py @@ -71,7 +71,9 @@ def test_legacy_argv_routes_to_the_legacy_cli(): "assert 'dashscope.legacy_1x.cli' in sys.modules\n", ) assert result.returncode == 0, result.stdout + result.stderr - assert "Usage: dashscope generation call" in strip_ansi_codes(result.stdout) + assert "Usage: dashscope generation call" in strip_ansi_codes( + result.stdout, + ) def _module_level_imports(path: Path): From 4cc9cdf6f135d5f02056c8bea744fa4c17885440 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 5 Nov 2026 18:14:25 +0800 Subject: [PATCH 12/13] chore(sdk): rename default User-Agent product token Use dashscope-sdk-python/{version} so the SDK and language identity are explicit without duplicating the major version. --- dashscope/_shared/_sdk_ident.py | 2 +- dashscope/transport/http.py | 2 +- tests/legacy_1x/unit/test_layer_decoupling.py | 2 +- tests/test_oss_upload.py | 4 ++-- tests/test_user_agent_suffix.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) 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/transport/http.py b/dashscope/transport/http.py index 12a44a58..49f67ad2 100644 --- a/dashscope/transport/http.py +++ b/dashscope/transport/http.py @@ -153,7 +153,7 @@ def default_user_agent(suffix: Optional[str] = None) -> str: from dashscope.version import __version__ except Exception: __version__ = "0.0.0" - agent = f"dashscope-v2-python/{__version__}" + agent = f"dashscope-sdk-python/{__version__}" return f"{agent} {suffix}" if suffix else agent 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/test_oss_upload.py b/tests/test_oss_upload.py index d61b5277..6b42d205 100644 --- a/tests/test_oss_upload.py +++ b/tests/test_oss_upload.py @@ -300,7 +300,7 @@ 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( @@ -319,7 +319,7 @@ def test_oss_post_keeps_the_configured_user_agent_suffix( _oss_upload.upload_local_file(client, "qwen-vl-max", _png(tmp_path)) user_agent = seen["headers"].get("User-Agent", "") - assert user_agent.startswith("dashscope-v2-python/") + assert user_agent.startswith("dashscope-sdk-python/") assert "dashscope-cli/2.0" in user_agent diff --git a/tests/test_user_agent_suffix.py b/tests/test_user_agent_suffix.py index 2d88d993..1d386509 100644 --- a/tests/test_user_agent_suffix.py +++ b/tests/test_user_agent_suffix.py @@ -26,7 +26,7 @@ from dashscope.version import __version__ BASE_URL = "https://api.test.local" -BASE_AGENT = f"dashscope-v2-python/{__version__}" +BASE_AGENT = f"dashscope-sdk-python/{__version__}" def _make_client(handler, **kwargs): From 25b0f2d8535aa084015551e7d7212af8c48d414e Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Fri, 6 Nov 2026 11:23:26 +0800 Subject: [PATCH 13/13] feat(cli): split 1.x and 2.0 entry points Keep dashscope on the v2 SDK and expose the frozen 1.x CLI as dashscope1, rejecting moved commands with a migration hint instead of dispatching or rewriting argv. --- README.md | 18 +- README_zh.md | 18 +- dashscope/cli/app.py | 10 +- dashscope/cli/compat_1x.py | 884 ---------------------------- dashscope/cli/main.py | 123 ++-- dashscope/legacy_1x/cli/__init__.py | 18 +- examples/README.md | 12 +- examples/README_zh.md | 10 +- setup.py | 1 + tests/cli/test_app.py | 3 +- tests/cli/test_compat_1x.py | 188 ------ tests/cli/test_dispatch.py | 163 ++--- tests/cli/test_package_layout.py | 45 +- 13 files changed, 192 insertions(+), 1301 deletions(-) delete mode 100644 dashscope/cli/compat_1x.py delete mode 100644 tests/cli/test_compat_1x.py diff --git a/README.md b/README.md index c638a359..6cecb3a4 100644 --- a/README.md +++ b/README.md @@ -439,23 +439,23 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built ## Command line -`pip install -U dashscope` puts one `dashscope` command on your `PATH`. It serves both the SDK-shaped **2.0** command tree and the existing **1.x** command forms, so current 1.x invocations need no migration: +`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: -| Form | Examples | -|------|----------| -| **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` | -| **1.x**, kept compatible | `generation call`, `application call`, `image-synthesis create`, `multimodal-conversation call`, `fine-tunes call`, `agentic-rl run`, `auth login`, `oss upload` | +| 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` | -Legacy-only names and dotted commands run against the frozen 1.x implementation. Shared names are served by 2.0 with compatible 1.x option spellings and defaults. The module entry point uses the same router: +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 generation call ... 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 CLI command map, 1.x compatibility rules, output formats, exit codes, configuration precedence and CI recipes, see the CLI section in [`examples/README.md`](examples/README.md). +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 @@ -473,7 +473,7 @@ Preview the script without installing: dashscope --show-completion bash ``` -Completion is installed through the single `dashscope` entry point. +Completion is installed through the 2.0 `dashscope` entry point. ## Logging diff --git a/README_zh.md b/README_zh.md index 5f20d4b2..cbfd4ad4 100644 --- a/README_zh.md +++ b/README_zh.md @@ -439,23 +439,23 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 ## 命令行 -`pip install -U dashscope` 只安装一个 `dashscope` 命令。它同时提供按 SDK 资源树设计的 **2.0** 命令和现有 **1.x** 命令形式,现有 1.x 调用无需迁移: +`pip install -U dashscope` 会安装两个相互独立的命令。`dashscope` 提供按 SDK 资源树设计的 **2.0** 命令;`dashscope1` 提供冻结的 **1.x** 实现: -| 形式 | 示例 | -|------|------| -| **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` | -| **1.x**,保持兼容 | `generation call`、`application call`、`image-synthesis create`、`multimodal-conversation call`、`fine-tunes call`、`agentic-rl run`、`auth login`、`oss upload` | +| 入口 | 实现 | 示例 | +|------|------|------| +| `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` | -1.x 专有名称和点式命令仍由冻结的 1.x 实现执行;共享名称由 2.0 执行,并兼容 1.x 参数拼写和默认值。模块入口使用同一个路由器: +两套 CLI 不会相互分发命令或转换参数。通过 `dashscope` 运行 1.x 专有命令会返回用法错误,并提示改用 `dashscope1`。两者的模块入口也相互独立: ```bash -python -m dashscope.cli generation call ... python -m dashscope.cli apps completions create ... +python -m dashscope.legacy_1x.cli generation call ... ``` 不带参数运行 `dashscope` 会启动 [AI 助手](#ai-助手dashscope-sdk-expert),而不是 CLI。 -完整的 CLI 命令地图、1.x 兼容规则、输出格式、退出码、配置优先级和 CI 配方,见 [`examples/README_zh.md`](examples/README_zh.md) 的命令行章节。 +完整的 2.0 CLI 命令地图、输出格式、退出码、配置优先级和 CI 配方,见 [`examples/README_zh.md`](examples/README_zh.md) 的命令行章节。 ## Shell 命令补全 @@ -473,7 +473,7 @@ python -m dashscope.cli apps completions create ... dashscope --show-completion bash ``` -命令补全通过唯一的 `dashscope` 入口安装。 +命令补全通过 2.0 的 `dashscope` 入口安装。 ## 日志 diff --git a/dashscope/cli/app.py b/dashscope/cli/app.py index d214614c..d6253efe 100644 --- a/dashscope/cli/app.py +++ b/dashscope/cli/app.py @@ -6,8 +6,8 @@ dashscope -k sk-xxx --output json chat create -m qwen-plus --prompt hi -The console entry point also dispatches frozen 1.x-only commands and -normalises compatible options on shared commands before invoking this app. +The 1.x command tree is installed separately as ``dashscope1`` and is never +imported or executed by this app. """ from __future__ import annotations @@ -35,9 +35,9 @@ def _epilog() -> str: return ( - "Frozen 1.x commands such as `generation create`, `application create` " - "and dotted spellings remain available under this same entry point. " - "A bare `dashscope` opens the bundled `expert` agent interactively." + "CLI 1.x commands such as `generation create` and `application create` " + "have moved to `dashscope1`. A bare `dashscope` opens the bundled " + "`expert` agent interactively." ) diff --git a/dashscope/cli/compat_1x.py b/dashscope/cli/compat_1x.py deleted file mode 100644 index 42ae1d50..00000000 --- a/dashscope/cli/compat_1x.py +++ /dev/null @@ -1,884 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Normalize compatible 1.x spellings on commands shared with v2. - -The entry point routes legacy-only groups and dotted commands to the frozen -1.x implementation before calling this module. Shared commands are rewritten -here and executed by the v2 commands and SDK 2.0. -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional, Sequence, Tuple - -from dashscope.cli.errors import usage_error - - -_DOTTED_COMMANDS = { - "fine_tunes.call": ("ft", "create"), - "fine_tunes.get": ("ft", "get"), - "fine_tunes.list": ("ft", "list"), - "fine_tunes.stream": ("ft", "stream"), - "fine_tunes.cancel": ("ft", "cancel"), - "fine_tunes.delete": ("ft", "delete"), - "generation.call": ("generation", "create"), - "files.upload": ("files", "upload"), - "files.get": ("files", "get"), - "files.list": ("files", "list"), - "files.delete": ("files", "delete"), - "deployments.call": ("deployments", "create"), - "deployments.get": ("deployments", "get"), - "deployments.list": ("deployments", "list"), - "deployments.scale": ("deployments", "scale"), - "deployments.delete": ("deployments", "delete"), - "oss.upload": ("oss", "upload"), -} - -_TOP_LEVEL_AUTH_COMMANDS = {"login", "whoami", "logout"} - -_GROUPS = { - "auth", - "generation", - "ft", - "fine-tunes", - "files", - "deployments", - "oss", - "rerank", - "embeddings", - "tokenization", - "models", - "application", - "image-synthesis", - "video-synthesis", - "image-generation", - "multimodal-conversation", - "multimodal-embedding", - "transcription", - "speech-synthesis", - "rl", - "agentic-rl", -} - -_GROUP_ALIASES = { - "fine_tunes": "ft", - "fine-tunes": "ft", - "agentic-rl": "rl", -} - -_GROUP_HELP_TARGETS = { - "application": "apps", - "generation": "chat", - "image-generation": "images", - "image-synthesis": "images", - "multimodal-conversation": "multimodal", - "multimodal-embedding": "multimodal", - "speech-synthesis": "audio", - "transcription": "audio", - "video-synthesis": "videos", -} - -_ROOT_VALUE_OPTIONS = { - "-k", - "--api-key", - "--api_key", - "--protocol", - "-u", - "--base-url", - "--base_url", - "-w", - "--workspace", - "--region", - "--timeout", - "-o", - "--output", - "-c", -} - -_ROOT_ALIASES = { - "--api_key": "--api-key", - "--base_url": "--base-url", -} - -_OPTION_ALIASES = { - **_ROOT_ALIASES, - "--training_file_ids": "--training-file-ids", - "--validation_file_ids": "--validation-file-ids", - "--n_epochs": "--n-epochs", - "--batch_size": "--batch-size", - "--learning_rate": "--learning-rate", - "--prompt_loss": "--prompt-loss", - "--hyper_parameters": "--hyper-parameters", - "--file_id": "--file-id", - "--deployed_model": "--deployed-model", - "--start_page": "--start-page", - "--page_size": "--page-size", -} - -_VALUE_OPTIONS = frozenset( - { - *_ROOT_VALUE_OPTIONS, - "-a", - "-b", - "-d", - "-e", - "-f", - "-i", - "-l", - "-m", - "-n", - "-p", - "-q", - "-s", - "-t", - "-v", - "--app-id", - "--model", - "--prompt", - "--messages", - "--system", - "--tools", - "--temperature", - "--top-p", - "--top-k", - "--max-tokens", - "--stop", - "--seed", - "--repetition-penalty", - "--presence-penalty", - "--enable-search", - "--n", - "--result-format", - "--history", - "--parameters", - "--session-id", - "--memory-id", - "--biz-params", - "--image", - "--file", - "--rag-options", - "--doc-tag-code", - "--doc-reference-type", - "--negative-prompt", - "--size", - "--page", - "--page-size", - "--start-page", - "--image-url", - "--function", - "--style", - "--extra-input", - "--submit-timeout", - "--model-name", - "--api-key-id", - "--status", - "--start-time", - "--end-time", - "--img-url", - "--first-frame-url", - "--last-frame-url", - "--duration", - "--resolution", - "--ratio", - "--media", - "--file-url", - "--phrase-id", - "--channel-id", - "--speaker-count", - "--special-word-filter", - "--audio-format", - "--format", - "--sample-rate", - "--voice", - "--url", - "--volume", - "--rate", - "--pitch", - "--language-type", - "--training-file-ids", - "--validation-file-ids", - "--mode", - "--hyper-parameters", - "--n-epochs", - "--batch-size", - "--learning-rate", - "--prompt-loss", - "--finetuned-model", - "--purpose", - "--description", - "--file-id", - "--deployed-model", - "--capacity", - "--model-version", - "--suffix", - "--plan", - "--template-id", - "--deploy-spec", - "--billing-method", - "--input", - "--text-type", - "--dimension", - "--output-type", - "--output-format", - "--provider", - "--instruct", - "--text", - "--audio", - "--video", - "--fps", - "--res-level", - "--max-video-frames", - "--query", - "--documents", - "--document", - "--top-n", - "--rollout-classpaths", - "--reward-classpaths", - "--group-reward-classpaths", - "--rollout", - "--reward", - "--group-reward", - "--workspace-dir", - "--training-files", - "--validation-files", - "--training-file", - "--validation-file", - "--teacher-model", - "--job-name", - "--set", - "--instance-id", - "--type", - "--offset", - "--lines", - "--config", - "--key", - }, -) - -_COMMON_FLAGS = frozenset( - { - "-h", - "--help", - "--stream", - "--yes", - "-y", - "--wait", - "--no-wait", - "--verbose", - "--has-thoughts", - "--no-has-thoughts", - "--enable-thinking", - "--no-enable-thinking", - "--no-enable-search", - "--enable-fusion", - "--disable-fusion", - "--no-enable-fusion", - "--return-documents", - "--no-return-documents", - "--prompt-extend", - "--no-prompt-extend", - "--watermark", - "--no-watermark", - "--lazy-load", - "--no-lazy-load", - "--pull-logs", - "--all", - "--enable-ssml", - "--no-enable-ssml", - "--disfluency-removal-enabled", - "--diarization-enabled", - "--timestamp-alignment-enabled", - "--audio-event-detection-enabled", - }, -) - -_HOISTED_OPTIONS = { - "-k": "--api-key", - "--api-key": "--api-key", - "-w": "--workspace", - "--workspace": "--workspace", - "-u": "--base-url", - "--base-url": "--base-url", -} - - -@dataclass -class _Parsed: - options: List[Tuple[str, Optional[str]]] = field(default_factory=list) - positionals: List[str] = field(default_factory=list) - - def has(self, *names: str) -> bool: - return any(name in names for name, _ in self.options) - - def take(self, *names: str) -> List[Tuple[str, Optional[str]]]: - taken = [item for item in self.options if item[0] in names] - self.options = [item for item in self.options if item[0] not in names] - return taken - - def values(self, *names: str) -> List[Optional[str]]: - return [value for _, value in self.take(*names)] - - def add(self, name: str, value: Optional[str] = None) -> None: - self.options.append((name, value)) - - def add_flag(self, name: str) -> None: - if not self.has(name): - self.add(name) - - def rename(self, mapping: Dict[str, str]) -> None: - self.options = [ - (mapping.get(name, name), value) for name, value in self.options - ] - - def emit(self) -> List[str]: - result: List[str] = [] - for name, value in self.options: - result.append(name) - if value is not None: - result.append(value) - result.extend(self.positionals) - return result - - -def _split_option(token: str) -> Tuple[str, Optional[str]]: - name, separator, value = token.partition("=") - alias = _OPTION_ALIASES.get(name) - if alias is None and name.startswith("--"): - alias = name.replace("_", "-") - alias = alias or name - return alias, value if separator else None - - -def _normalise_token(token: str) -> str: - name, value = _split_option(token) - return f"{name}={value}" if value is not None else name - - -def _command_index(argv: Sequence[str]) -> Optional[int]: - index = 0 - while index < len(argv): - token = argv[index] - name, value = _split_option(token) - if name in _ROOT_VALUE_OPTIONS: - index += 1 if value is not None else 2 - continue - if token.startswith("-"): - index += 1 - continue - return index - return None - - -def _parse(rest: Sequence[str], flags: Iterable[str]) -> _Parsed: - parsed = _Parsed() - flags = _COMMON_FLAGS | frozenset(flags) - index = 0 - while index < len(rest): - token = rest[index] - if token == "--": - parsed.positionals.extend(rest[index + 1 :]) - break - if not token.startswith("-"): - parsed.positionals.append(token) - index += 1 - continue - name, value = _split_option(token) - if ( - value is None - and name not in flags - and name in _VALUE_OPTIONS - and index + 1 < len(rest) - ): - value = rest[index + 1] - index += 1 - parsed.add(name, value) - index += 1 - return parsed - - -def _json_scalar(raw: Optional[str]) -> object: - if raw is None: - return True - try: - return json.loads(raw) - except json.JSONDecodeError: - return raw - - -def _boolean(raw: Optional[str], option: str) -> bool: - if raw is None: - return True - value = raw.strip().lower() - if value in {"1", "true", "yes", "on"}: - return True - if value in {"0", "false", "no", "off"}: - return False - raise usage_error(f"{option} expects true or false, got {raw!r}") - - -def _merge_json_option( - parsed: _Parsed, - option: str, - values: Dict[str, object], -) -> None: - merged: Dict[str, object] = {} - existing = parsed.values(option) - for raw in existing: - try: - value = json.loads(raw or "") - except json.JSONDecodeError as exc: - raise usage_error( - f"{option} must be an inline JSON object when combined with " - "a 1.x option", - ) from exc - if not isinstance(value, dict): - raise usage_error(f"{option} must contain a JSON object") - merged.update(value) - merged.update(values) - if merged or existing: - parsed.add(option, json.dumps(merged, ensure_ascii=False)) - - -def _one(values: List[Optional[str]]) -> Optional[str]: - return values[-1] if values else None - - -def _bool_value_flag(parsed: _Parsed, option: str, negative: str) -> None: - for raw in parsed.values(option): - parsed.add(option if _boolean(raw, option) else negative) - - -def _hoist_globals( - prefix: List[str], - parsed: _Parsed, - *, - auth_login: bool, - rl: bool, - speech: bool, -) -> List[str]: - hoisted: List[str] = [] - mapping = dict(_HOISTED_OPTIONS) - if speech: - mapping["--url"] = "--base-url" - if rl: - mapping.update( - { - "-o": "--output", - "--output-format": "--output", - }, - ) - for name, value in parsed.take(*mapping): - if auth_login and name == "-k": - parsed.add(name, value) - continue - target = mapping[name] - hoisted.append(target) - if value is not None: - hoisted.append(value) - if rl: - for name, _ in parsed.take("--verbose"): - hoisted.append(name) - if parsed.has("-v"): - parsed.take("-v") - hoisted.append("--verbose") - return prefix + hoisted - - -def _normalize_generation(parsed: _Parsed) -> None: - parsed.rename({"-p": "--prompt", "-s": "--stream"}) - stops = parsed.values("--stop") - for raw in stops: - for stop in (raw or "").split(","): - if stop.strip(): - parsed.add("--stop", stop.strip()) - _bool_value_flag(parsed, "--enable-search", "--no-enable-search") - result_format = _one(parsed.values("--result-format")) - if result_format is not None: - _merge_json_option( - parsed, - "--parameters", - {"result_format": result_format}, - ) - parsed.take("--history") - - -def _normalize_multimodal_conversation(parsed: _Parsed) -> None: - text = _one(parsed.values("-t", "--text")) - images = parsed.values("--image") - content = [{"image": image} for image in images if image is not None] - if text is not None: - content.append({"text": text}) - parsed.add( - "--messages", - json.dumps( - [{"role": "user", "content": content}], - ensure_ascii=False, - ), - ) - result_format = _one(parsed.values("--result-format")) - if result_format is not None: - _merge_json_option( - parsed, - "--parameters", - {"result_format": result_format}, - ) - - -def _normalize_image_generation(parsed: _Parsed, subcommand: str) -> None: - if subcommand != "create": - return - parsed.rename( - {"-t": "--prompt", "--text": "--prompt", "--image": "--image-url"}, - ) - max_images = _one(parsed.values("--max-images")) - if max_images is not None: - _merge_json_option( - parsed, - "--parameters", - {"max_images": _json_scalar(max_images)}, - ) - - -def _normalize_video(parsed: _Parsed, subcommand: str) -> None: - if subcommand != "create": - return - parsed.rename({"--img-url": "--image-url"}) - extra: Dict[str, object] = {"extend_prompt": True} - for option, key in ( - ("--negative-prompt", "negative_prompt"), - ("--first-frame-url", "first_frame_url"), - ("--last-frame-url", "last_frame_url"), - ): - value = _one(parsed.values(option)) - if value is not None: - extra[key] = value - _merge_json_option(parsed, "--extra-input", extra) - - -def _normalize_transcription(parsed: _Parsed, subcommand: str) -> None: - if subcommand != "create": - return - urls = [ - value for value in parsed.values("--file-url") if value is not None - ] - parsed.positionals = urls + parsed.positionals - parsed.add_flag("--no-wait") - channels = [_json_scalar(value) for value in parsed.values("--channel-id")] - parameters: Dict[str, object] = {} - if channels: - parameters["channel_id"] = channels - for option in ( - "--disfluency-removal-enabled", - "--diarization-enabled", - "--timestamp-alignment-enabled", - "--audio-event-detection-enabled", - ): - if parsed.has(option): - parsed.take(option) - parameters[option[2:].replace("-", "_")] = True - for option in ("--speaker-count", "--special-word-filter"): - value = _one(parsed.values(option)) - if value is not None: - parameters[option[2:].replace("-", "_")] = _json_scalar(value) - _merge_json_option(parsed, "--parameters", parameters) - - -def _normalize_speech(parsed: _Parsed) -> None: - text = _one(parsed.values("-t", "--text")) - if text is not None: - parsed.positionals.insert(0, text) - parsed.rename({"--audio-format": "--format"}) - if not parsed.has("--format"): - parsed.add("--format", "wav") - if not parsed.has("--sample-rate"): - parsed.add("--sample-rate", "24000") - extra: Dict[str, object] = {} - for option in ("--volume", "--rate", "--pitch"): - value = _one(parsed.values(option)) - if value is not None: - extra[option[2:]] = _json_scalar(value) - _merge_json_option(parsed, "--extra-input", extra) - - -def _normalize_ft(parsed: _Parsed, subcommand: str) -> None: - if subcommand == "create": - parsed.add_flag("--wait") - if subcommand == "list": - parsed.rename( - { - "-p": "--page", - "-s": "--size", - "--page-size": "--size", - "--start-page": "--page", - }, - ) - if subcommand in {"cancel", "delete"}: - parsed.add_flag("--yes") - - -def _normalize_files(parsed: _Parsed, subcommand: str) -> None: - if subcommand == "upload": - parsed.rename({"-p": "--purpose", "-d": "--description"}) - file_path = _one(parsed.values("-f", "--file")) - if file_path is not None: - parsed.positionals.insert(0, file_path) - if subcommand in {"get", "delete"}: - file_id = _one(parsed.values("--file-id")) - if file_id is not None: - parsed.positionals.insert(0, file_id) - if subcommand == "list": - parsed.rename( - { - "-p": "--page", - "-s": "--size", - "--page-size": "--size", - "--start-page": "--page", - }, - ) - if subcommand == "delete": - parsed.add_flag("--yes") - - -def _normalize_deployments(parsed: _Parsed, subcommand: str) -> None: - parsed.rename({"-c": "--capacity"}) - if subcommand in {"call", "create"}: - parsed.rename({"-s": "--suffix"}) - parsed.add_flag("--wait") - if subcommand == "list": - parsed.rename( - { - "-p": "--page", - "-s": "--size", - "--page-size": "--size", - "--start-page": "--page", - }, - ) - if subcommand in {"get", "scale", "delete"}: - deployed_model = _one(parsed.values("--deployed-model")) - if deployed_model is not None: - parsed.positionals.insert(0, deployed_model) - if subcommand in {"scale", "delete"}: - parsed.add_flag("--yes") - - -def _normalize_models(parsed: _Parsed, subcommand: str) -> None: - if subcommand == "list": - parsed.rename( - { - "-p": "--page", - "-s": "--size", - "--page-size": "--size", - "--start-page": "--page", - }, - ) - - -def _normalize_embeddings(parsed: _Parsed, subcommand: str) -> None: - if subcommand != "create": - return - parsed.rename({"-i": "--input", "-t": "--text-type"}) - inputs = [value for value in parsed.values("--input") if value is not None] - if len(inputs) == 1: - parsed.add("--input", inputs[0]) - elif inputs: - parsed.add("--input", json.dumps(inputs, ensure_ascii=False)) - - -def _normalize_rl(parsed: _Parsed, subcommand: str) -> None: - if subcommand == "register-functions": - parsed.rename( - { - "--rollout-classpaths": "--rollout", - "--reward-classpaths": "--reward", - "--group-reward-classpaths": "--group-reward", - }, - ) - elif subcommand == "test-functions": - parsed.rename({"-t": "--type", "-i": "--input"}) - if parsed.positionals: - parsed.add("--instance-id", parsed.positionals.pop(0)) - elif subcommand == "upload-data": - parsed.rename( - { - "--training-files": "--training-file", - "--validation-files": "--validation-file", - }, - ) - elif subcommand == "run": - parsed.rename({"-c": "--config"}) - teacher_model = _one(parsed.values("--teacher-model")) - if teacher_model is not None: - parsed.add("--set", f"teacher_model={teacher_model}") - elif subcommand == "list": - parsed.rename({"-p": "--page", "-s": "--size"}) - if subcommand == "cancel": - parsed.add_flag("--yes") - - -def _flags_for(group: str, subcommand: str) -> frozenset: - flags = set() - if group == "generation": - flags.update({"-s"}) - if group == "tokenization": - flags.add("--enable-search") - if group == "rl" and subcommand == "run": - flags.add("-v") - return frozenset(flags) - - -# pylint: disable-next=too-many-return-statements -def _target(group: str, subcommand: str) -> Tuple[str, List[str], str]: - if group == "generation": - return "chat", ["create"], "create" - if group == "application": - return "apps", ["call"], "call" - if group in {"image-generation", "image-synthesis"}: - verbs = {"fetch": "get"} - verb = verbs.get(subcommand, subcommand) - return "images", ["jobs", verb], verb - if group == "video-synthesis": - verbs = {"fetch": "get"} - verb = verbs.get(subcommand, subcommand) - return "videos", ["jobs", verb], verb - if group == "multimodal-conversation": - return "multimodal", ["create"], "create" - if group == "multimodal-embedding": - return "multimodal", ["embeddings", "create"], "create" - if group == "transcription": - verbs = {"create": "transcribe", "fetch": "get"} - verb = verbs.get(subcommand, subcommand) - return "audio", ["asr", verb], verb - if group == "speech-synthesis": - return "audio", ["tts", "http"], "http" - if group == "tokenization": - return "tokenization", ["count"], "count" - verb = "create" if subcommand == "call" else subcommand - if group in {"rl", "agentic-rl"}: - verb = verb.replace("_", "-") - function_verbs = { - "register-functions": "register", - "test-functions": "test", - "upload-data": "upload-data", - } - if verb in function_verbs: - return "rl", ["functions", function_verbs[verb]], verb - if verb == "run": - return "rl", ["jobs", "create"], verb - return "rl", ["jobs", verb], verb - return _GROUP_ALIASES.get(group, group), [verb], verb - - -# pylint: disable-next=too-many-branches,too-many-statements -def normalize_argv(argv: Sequence[str]) -> List[str]: - """Return *argv* in v2 form, accepting every registered 1.x command.""" - args = list(argv) - index = _command_index(args) - if index is None: - return [_normalise_token(arg) for arg in args] - prefix = [_normalise_token(arg) for arg in args[:index]] - token = args[index] - if token == "expert" or ( - token not in _GROUPS - and token not in _DOTTED_COMMANDS - and token not in _TOP_LEVEL_AUTH_COMMANDS - ): - return prefix + args[index:] - if token in _TOP_LEVEL_AUTH_COMMANDS: - group, subcommand = "auth", token - rest = args[index + 1 :] - elif token in _DOTTED_COMMANDS: - group, subcommand = _DOTTED_COMMANDS[token] - rest = args[index + 1 :] - else: - group = token - rest = args[index + 1 :] - if ( - group in {"rl", "agentic-rl"} - and rest - and rest[0] in {"jobs", "functions"} - ): - return prefix + ["rl"] + rest - subcommand = "" - if rest and not rest[0].startswith("-"): - subcommand = rest.pop(0) - if not subcommand: - target = _GROUP_HELP_TARGETS.get( - group, - _GROUP_ALIASES.get(group, group), - ) - return prefix + [target] + rest - - original_subcommand = subcommand - target_group, target_path, normalized_subcommand = _target( - group, - subcommand, - ) - parsed = _parse(rest, _flags_for(group, original_subcommand)) - root = _hoist_globals( - prefix, - parsed, - auth_login=group == "auth" and original_subcommand == "login", - rl=group in {"rl", "agentic-rl"}, - speech=group == "speech-synthesis", - ) - - if group == "generation": - _normalize_generation(parsed) - elif group == "application": - parsed.rename({"-a": "--app-id", "-p": "--prompt"}) - elif group == "multimodal-conversation": - _normalize_multimodal_conversation(parsed) - elif group == "multimodal-embedding": - parsed.rename({"--disable-fusion": "--no-enable-fusion"}) - elif group in {"image-generation", "image-synthesis"}: - _normalize_image_generation(parsed, normalized_subcommand) - elif group == "video-synthesis": - _normalize_video(parsed, normalized_subcommand) - elif group == "transcription": - _normalize_transcription(parsed, original_subcommand) - elif group == "speech-synthesis": - _normalize_speech(parsed) - elif group in {"ft", "fine-tunes"}: - _normalize_ft(parsed, normalized_subcommand) - elif group == "files": - _normalize_files(parsed, original_subcommand) - elif group == "deployments": - _normalize_deployments(parsed, original_subcommand) - elif group == "models": - _normalize_models(parsed, original_subcommand) - elif group == "embeddings": - _normalize_embeddings(parsed, original_subcommand) - elif group == "rerank": - parsed.rename({"-n": "--top-n", "-i": "--instruct"}) - elif group == "tokenization": - enable_search = parsed.has("--enable-search") - parsed.take("--enable-search") - if enable_search: - _merge_json_option( - parsed, - "--parameters", - {"enable_search": True}, - ) - parsed.rename({"-p": "--prompt"}) - elif group in {"rl", "agentic-rl"}: - _normalize_rl(parsed, normalized_subcommand) - - if group in {"image-generation", "image-synthesis", "video-synthesis"}: - parsed.rename( - { - "--model-name": "--model", - "-p": "--page", - "-s": "--size", - "--page-size": "--size", - }, - ) - if normalized_subcommand == "cancel": - parsed.add_flag("--yes") - if group == "transcription": - parsed.rename({"-p": "--page", "-s": "--size"}) - if normalized_subcommand == "cancel": - parsed.add_flag("--yes") - - return root + [target_group] + target_path + parsed.emit() diff --git a/dashscope/cli/main.py b/dashscope/cli/main.py index ebf0c1ee..8039e630 100644 --- a/dashscope/cli/main.py +++ b/dashscope/cli/main.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""Route ``dashscope`` to the SDK-shaped v2 tree, frozen 1.x commands, or agent. +"""Route ``dashscope`` to the SDK-shaped v2 tree or the bundled agent. -Legacy-only command names and dotted spellings keep their original 1.x -implementation. Shared names such as ``ft`` and ``files`` are served by v2 -with compatible option spellings. There is only one console entry point. +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 @@ -15,8 +15,7 @@ from rich.console import Console -from dashscope.cli.compat_1x import normalize_argv -from dashscope.cli.errors import CliError, fail +from dashscope.cli.errors import fail, usage_error NEW = "new" AGENT = "agent" @@ -58,10 +57,37 @@ }, ) -# Names this entry point serves itself. ``fine-tunes`` and ``agentic-rl`` -# are the hidden aliases the 1.x tree already used for ``ft`` and ``rl``, -# so muscle memory keeps working. Pinned to the groups ``app.py`` actually -# registers by a test. +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", @@ -121,10 +147,8 @@ { "-k", "--api-key", - "--api_key", "--protocol", "--base-url", - "--base_url", "--workspace", "--region", "--timeout", @@ -137,11 +161,11 @@ # 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", "--api_key") +_VALUE_OPTIONS = ("-k", "--api-key") -def _first_command_token(argv: Sequence[str]) -> Optional[str]: - """The first token that is not an option or an option's value.""" +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] @@ -157,7 +181,7 @@ def _first_command_token(argv: Sequence[str]) -> Optional[str]: # the latter case click produces the usage error. index += 1 continue - return arg + return index return None @@ -186,9 +210,9 @@ def _without_api_key_options(argv: Sequence[str]) -> List[str]: def _api_key_from(argv: Sequence[str]) -> Optional[str]: """The key the invocation passed, if it passed one. - The agent reads ``dashscope.api_key`` -- the module global the 1.x - entry point populated before handing over. Nothing else in the v2 tree - consults it; commands resolve credentials through ``ctx.obj``. + 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): @@ -219,27 +243,43 @@ def _classify_without_command(argv: Sequence[str]) -> str: 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 legacy-only names to 1.x and everything else to v2 or agent.""" - token = _first_command_token(argv) - if token is None: + """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 token == _AGENT_COMMAND: + if argv[index] == _AGENT_COMMAND: return AGENT - if token in LEGACY_GROUPS or token in LEGACY_DOTTED_COMMANDS: + if _legacy_command(argv) is not None: return LEGACY return NEW -def _run_legacy(argv: Sequence[str]) -> None: - previous = sys.argv - sys.argv = ["dashscope", *argv] - try: - from dashscope.legacy_1x.cli import main as legacy_main - - legacy_main(prog_name="dashscope") - finally: - sys.argv = previous +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: @@ -277,14 +317,8 @@ 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 follows the same CLI routing. - # Click signals a completion run through this env var, and a completion call - # often names no command, which would otherwise classify as a bare - # ``dashscope`` and open the agent instead of listing completions. + # Shell completion bypasses the agent and always uses the v2 command tree. if os.environ.get("_DASHSCOPE_COMPLETE"): - if classify(argv) is LEGACY: - _run_legacy(argv) - return from dashscope.cli.app import build_app build_app()(argv) @@ -292,14 +326,7 @@ def main(args: Optional[Sequence[str]] = None) -> None: target = classify(argv) if target is LEGACY: - _run_legacy(argv) - return - - try: - argv = normalize_argv(argv) - except CliError as exc: - fail(exc) - + _reject_legacy(argv) if target is AGENT: _run_agent(argv) return diff --git a/dashscope/legacy_1x/cli/__init__.py b/dashscope/legacy_1x/cli/__init__.py index 7d6c9b05..a8f92504 100644 --- a/dashscope/legacy_1x/cli/__init__.py +++ b/dashscope/legacy_1x/cli/__init__.py @@ -124,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].""" @@ -278,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", @@ -390,7 +390,7 @@ def _first_non_option(argv): def main(prog_name: Optional[str] = None): - """Entry point for the ``dashscope`` console script.""" + """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) diff --git a/examples/README.md b/examples/README.md index 8c4d4e1c..5a456d34 100644 --- a/examples/README.md +++ b/examples/README.md @@ -153,21 +153,23 @@ Read the two examples in table order. The first shows *what* comes out of each c ### Install and first call -The package registers a console script, so after `pip install -U dashscope` there is a `dashscope` command on your `PATH`: +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 script is not installed, the module entry point is identical: +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 this same `dashscope` entry point. Legacy-only names run against the frozen 1.x implementation; shared names accept their old option spellings through the 2.0 tree. See [`legacy_1x/`](./legacy_1x/) for the 1.x module-level API examples. +> 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: @@ -190,7 +192,7 @@ That last one is not a quirk — it is the single most common way to get a confu | 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`. Top-level `dashscope login` / `whoami` / `logout` are shortcuts | +| `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 | @@ -260,7 +262,7 @@ That last one is not a quirk — it is the single most common way to get a confu | `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 | -One `dashscope` entry point serves both generations. Legacy-only groups (`generation`, `application`, `image-synthesis`, `multimodal-conversation`, `speech-synthesis`, `transcription`, `video-synthesis`, `image-generation`, `multimodal-embedding`) and pre-Typer dotted spellings keep their original argv and run against the frozen 1.x implementation. Shared groups such as `ft`/`fine-tunes`, `files`, `deployments`, `models`, `rl`/`agentic-rl`, `auth`, and `oss` run through 2.0; the compatibility layer accepts the old option spellings and preserves old defaults where they differ. New 2.0 commands follow the SDK resource tree, so application invocation is `apps completions create`, not a shortened `apps call`. A bare `dashscope` with no subcommand opens the bundled `expert` agent interactively. +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. diff --git a/examples/README_zh.md b/examples/README_zh.md index 7651c499..57f93fab 100644 --- a/examples/README_zh.md +++ b/examples/README_zh.md @@ -153,11 +153,12 @@ examples/ ### 安装与第一次调用 -包里注册了 console script,所以 `pip install -U dashscope` 之后 `PATH` 上就有一个 `dashscope` 命令: +包里注册了两个 console script,所以 `pip install -U dashscope` 之后 `PATH` 上会有相互独立的 `dashscope`(2.0)和 `dashscope1`(1.x)命令: ```bash dashscope --help dashscope version +dashscope1 --help ``` 在没装 console script 的源码检出里,模块入口完全等价: @@ -165,9 +166,10 @@ dashscope version ```bash python -m dashscope.cli --help python -m dashscope.cli version +python -m dashscope.legacy_1x.cli --help ``` -> 现有 1.x 命令形式同样使用 `dashscope` 入口。1.x 专有名称仍由冻结的 1.x 实现执行,共享名称则通过 2.0 命令树兼容旧参数拼写。1.x 模块级 API 示例见 [`legacy_1x/`](./legacy_1x/)。 +> 现有 1.x 命令形式使用 `dashscope1`,由冻结的 1.x CLI 调用 1.x SDK。2.0 的 `dashscope` 入口不会向该实现分发命令,也不接受旧参数拼写;1.x 专有命令会以退出码 2 返回迁移提示。1.x 模块级 API 示例见 [`legacy_1x/`](./legacy_1x/)。 密钥配一次,两种方式都行: @@ -190,7 +192,7 @@ dashscope chat create -m qwen-plus --prompt "hi" --output json # exi | 命令 | 用途 | | --- | --- | | `dashscope version` | 打印已安装的 SDK 版本。什么都不碰,所以是你手上最便宜的冒烟测试 | -| `dashscope auth login` / `whoami` / `logout` | 保存、验证或删除 `~/.dashscope/api_key` 中的 API Key。顶层 `dashscope login` / `whoami` / `logout` 是等价简写 | +| `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` | 查看单个作业 | @@ -260,7 +262,7 @@ dashscope chat create -m qwen-plus --prompt "hi" --output json # exi | `dashscope config get` | 读回一个键(api_key 默认打码,除非 `--reveal`) | | `dashscope config list` | 每个键、它的生效值,以及这个值来自哪一层 | -一个 `dashscope` 入口同时服务两代命令。1.x 专有命令组(`generation`、`application`、`image-synthesis`、`multimodal-conversation`、`speech-synthesis`、`transcription`、`video-synthesis`、`image-generation`、`multimodal-embedding`)和 Typer 之前的点式拼写保留原始 argv,由冻结的 1.x 实现执行。`ft`/`fine-tunes`、`files`、`deployments`、`models`、`rl`/`agentic-rl`、`auth`、`oss` 等共享命令组由 2.0 执行;兼容层接受旧参数拼写,并在默认值不同时保留旧默认。新的 2.0 命令按 SDK 资源树命名,因此应用调用是 `apps completions create`,不是简化的 `apps call`。不带子命令的裸 `dashscope` 会交互式打开内置的 `expert` agent。 +两代命令使用相互独立的入口和实现。所有 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 暴露的其余东西都有命令了。 diff --git a/setup.py b/setup.py index 72f6f932..547e27d0 100644 --- a/setup.py +++ b/setup.py @@ -144,6 +144,7 @@ def run(self): entry_points={ "console_scripts": [ "dashscope = dashscope.cli.main:main", + "dashscope1 = dashscope.legacy_1x.cli:main", ], }, ) diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index 9a47e861..23c072c2 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -100,8 +100,7 @@ def test_router_and_callback_agree_on_value_taking_options(self, app): # click's own help option is added by the framework, not by the # callback, and takes no value. actual -= {"--help", "-h"} - legacy_aliases = {"--api_key", "--base_url"} - assert actual | legacy_aliases == set(_VALUE_TAKING_OPTIONS) + assert actual == set(_VALUE_TAKING_OPTIONS) class TestVersionCommand: diff --git a/tests/cli/test_compat_1x.py b/tests/cli/test_compat_1x.py deleted file mode 100644 index c4d9aa19..00000000 --- a/tests/cli/test_compat_1x.py +++ /dev/null @@ -1,188 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) Alibaba, Inc. and its affiliates. -"""Compatible option spellings on commands shared by 1.x and v2.""" - -import json - -import pytest - -from dashscope.cli.compat_1x import normalize_argv -from dashscope.cli.errors import CliError - - -@pytest.mark.parametrize( - ("argv", "prefix"), - [ - (["auth", "whoami"], ["auth", "whoami"]), - (["auth", "login"], ["auth", "login"]), - (["auth", "logout"], ["auth", "logout"]), - (["whoami"], ["auth", "whoami"]), - (["login"], ["auth", "login"]), - (["logout"], ["auth", "logout"]), - (["ft", "create"], ["ft", "create"]), - (["fine-tunes", "call"], ["ft", "create"]), - (["ft", "get"], ["ft", "get"]), - (["ft", "list"], ["ft", "list"]), - (["ft", "stream"], ["ft", "stream"]), - (["ft", "cancel"], ["ft", "cancel"]), - (["ft", "delete"], ["ft", "delete"]), - (["files", "upload"], ["files", "upload"]), - (["files", "get"], ["files", "get"]), - (["files", "list"], ["files", "list"]), - (["files", "delete"], ["files", "delete"]), - (["deployments", "create"], ["deployments", "create"]), - (["deployments", "call"], ["deployments", "create"]), - (["deployments", "get"], ["deployments", "get"]), - (["deployments", "list"], ["deployments", "list"]), - (["deployments", "scale"], ["deployments", "scale"]), - (["deployments", "delete"], ["deployments", "delete"]), - (["oss", "upload"], ["oss", "upload"]), - (["rerank", "create"], ["rerank", "create"]), - (["embeddings", "create"], ["embeddings", "create"]), - (["tokenization", "create"], ["tokenization", "count"]), - (["models", "list"], ["models", "list"]), - (["models", "get"], ["models", "get"]), - (["rl", "register_functions"], ["rl", "functions", "register"]), - (["agentic-rl", "test_functions"], ["rl", "functions", "test"]), - (["rl", "upload_data"], ["rl", "functions", "upload-data"]), - (["rl", "run"], ["rl", "jobs", "create"]), - (["rl", "get"], ["rl", "jobs", "get"]), - (["rl", "logs"], ["rl", "jobs", "logs"]), - (["rl", "list"], ["rl", "jobs", "list"]), - (["rl", "cancel"], ["rl", "jobs", "cancel"]), - ], -) -def test_shared_commands_map_to_v2(argv, prefix): - assert normalize_argv(argv)[: len(prefix)] == prefix - - -def test_root_equals_aliases_and_trailing_globals(): - assert normalize_argv(["--api_key=sk-1", "ft", "list"])[:2] == [ - "--api-key=sk-1", - "ft", - ] - assert normalize_argv(["ft", "list", "--base_url=https://example.com"])[ - :2 - ] == ["--base-url", "https://example.com"] - - -def test_shared_list_options_keep_legacy_spellings(): - for group in ("ft", "files", "deployments", "models"): - result = normalize_argv( - [group, "list", "--start_page", "2", "--page_size", "5"], - ) - assert result[result.index("--page") + 1] == "2" - assert result[result.index("--size") + 1] == "5" - - -def test_shared_legacy_defaults_are_preserved(): - assert "--wait" in normalize_argv(["deployments", "call", "-m", "model"]) - assert "--yes" in normalize_argv(["deployments", "delete", "name"]) - assert "--yes" in normalize_argv(["files", "delete", "file-1"]) - assert "--yes" in normalize_argv(["rl", "cancel", "job"]) - assert "--wait" in normalize_argv(["ft", "create"]) - assert "--wait" in normalize_argv(["fine-tunes", "create"]) - - -def test_ft_training_file_options_are_normalised(): - result = normalize_argv( - [ - "ft", - "call", - "--training_file_ids", - "train-1", - "--validation_file_ids", - "valid-1", - "--n_epochs", - "2", - "--learning_rate", - "0.0001", - ], - ) - for option in ( - "--training-file-ids", - "--validation-file-ids", - "--n-epochs", - "--learning-rate", - "--wait", - ): - assert option in result - - -def test_tokenization_legacy_create_and_enable_search(): - result = normalize_argv( - [ - "tokenization", - "create", - "-m", - "qwen-plus", - "-p", - "hello", - "--enable-search", - ], - ) - assert result[:3] == ["tokenization", "count", "-m"] - parameters = json.loads(result[result.index("--parameters") + 1]) - assert parameters == {"enable_search": True} - - -def test_rl_legacy_names_and_config_overrides(): - result = normalize_argv( - [ - "agentic-rl", - "run", - "--config", - "rl.toml", - "--teacher-model", - "qwen-plus", - "-o", - "json", - "-v", - ], - ) - assert result[:7] == [ - "--output", - "json", - "--verbose", - "rl", - "jobs", - "create", - "--config", - ] - assert result[result.index("--set") + 1] == "teacher_model=qwen-plus" - - -def test_sdk_shaped_v2_commands_are_not_rewritten(): - for argv in ( - ["apps", "completions", "create", "--app-id", "app-1"], - ["multimodal", "chat", "create", "-m", "qwen-vl-plus"], - ["rl", "jobs", "get", "job-1"], - ["rl", "functions", "logs", "instance-1", "--keyword", "error"], - ): - assert normalize_argv(argv) == argv - - -def test_value_options_on_shared_commands_keep_their_values(): - assert normalize_argv(["models", "list", "--provider", "dashscope"]) == [ - "models", - "list", - "--provider", - "dashscope", - ] - assert normalize_argv(["rl", "get", "job-1", "--output-format", "json"])[ - :2 - ] == ["--output", "json"] - - -def test_merging_legacy_fields_requires_an_inline_json_object(): - with pytest.raises(CliError) as caught: - normalize_argv( - [ - "tokenization", - "create", - "--parameters", - "@params.json", - "--enable-search", - ], - ) - assert caught.value.category == "usage" diff --git a/tests/cli/test_dispatch.py b/tests/cli/test_dispatch.py index f70f1d25..da2a4b7b 100644 --- a/tests/cli/test_dispatch.py +++ b/tests/cli/test_dispatch.py @@ -1,10 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""Routing at the ``dashscope`` console-script entry point. - -The single entry point serves SDK-shaped v2 commands, frozen 1.x commands, -and the bundled agent. Shared names use v2 with compatible option aliases. -""" +"""Routing at the independent ``dashscope`` console-script entry point.""" import sys from types import ModuleType @@ -31,9 +27,6 @@ class TestClassify: ["auth", "login", "--key", "sk-1"], ["auth", "whoami"], ["auth", "logout"], - ["login", "--key", "sk-1"], - ["whoami"], - ["logout"], ["oss", "upload", "-f", "input.png", "-m", "qwen-vl-max"], ["chat", "create", "-m", "qwen-plus"], ["ft", "list"], @@ -48,7 +41,7 @@ class TestClassify: ["tokenization", "count"], ["apps", "completions", "create", "--app-id", "app-1"], ["deployments", "list"], - ["rl", "list"], + ["rl", "jobs", "list"], ["audio", "tts", "synthesize", "hi"], ["config", "list"], ["version"], @@ -92,6 +85,25 @@ def test_legacy_groups(self, group): 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 @@ -104,7 +116,7 @@ def test_value_of_a_global_option_is_not_read_as_the_command(self): classify(["-c", "output=json", "generation", "create"]) == LEGACY ) assert classify(["--timeout", "30", "application", "create"]) == LEGACY - assert classify(["--api_key", "sk-1", "generation.call"]) == LEGACY + assert classify(["--api-key", "sk-1", "generation.call"]) == LEGACY class TestCommandNameSets: @@ -142,20 +154,6 @@ def app(argv): return calls -@pytest.fixture -def legacy_app(monkeypatch): - calls = [] - module = ModuleType("dashscope.legacy_1x.cli") - - def run(prog_name=None): - assert prog_name == "dashscope" - calls.append(list(sys.argv)) - - module.main = run - monkeypatch.setitem(sys.modules, "dashscope.legacy_1x.cli", module) - return calls - - class TestMainDispatch: def test_new_route_builds_and_calls_the_app(self, new_app, expert): main(["version"]) @@ -202,7 +200,7 @@ 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"]) + main(["--api-key=sk-agent", "expert", "hi"]) assert expert == [("hi", True)] assert dashscope.api_key == "sk-agent" @@ -216,103 +214,44 @@ def test_agent_help_is_answered_without_starting_the_agent( assert expert == [] assert "DashScope SDK Expert" in capsys.readouterr().out - def test_legacy_group_keeps_its_original_argv( + @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, - legacy_app, expert, + capsys, ): - argv = ["generation", "call", "-m", "qwen-plus", "-p", "hi", "-s"] - main(argv) - assert legacy_app == [["dashscope", *argv]] + 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 == [] - def test_dotted_command_keeps_its_original_argv( - self, - new_app, - legacy_app, - expert, - ): - argv = ["fine_tunes.list", "--page_size", "5", "-k", "sk-1"] - main(argv) - assert legacy_app == [["dashscope", *argv]] - assert new_app == [] - - def test_shared_ft_alias_uses_v2_with_legacy_options( - self, - new_app, - expert, - ): - main(["fine-tunes", "call", "-m", "base", "--training_file_ids", "f1"]) - assert new_app == [ - [ - "ft", - "create", - "-m", - "base", - "--training-file-ids", - "f1", - "--wait", - ], - ] - @pytest.mark.parametrize( - ("argv", "expected"), + "argv", [ - (["whoami"], ["auth", "whoami"]), - (["logout"], ["auth", "logout"]), - ( - ["login", "-k", "sk-secret"], - ["auth", "login", "-k", "sk-secret"], - ), + ["embeddings", "create", "-i", "hello"], + ["fine-tunes", "create", "--training_file_ids", "f1"], + ["deployments", "create", "--capacity", "2"], ], ) - def test_top_level_auth_shortcuts_use_v2( - self, - argv, - expected, - new_app, - legacy_app, - ): + def test_v2_argv_is_not_rewritten(self, argv, new_app, expert): main(argv) - assert new_app == [expected] - assert legacy_app == [] - - def test_shared_rl_run_uses_the_v2_job_resource(self, new_app, legacy_app): - main(["agentic-rl", "run", "-c", "rl.toml", "-o", "json", "-v"]) - assert new_app == [ - [ - "--output", - "json", - "--verbose", - "rl", - "jobs", - "create", - "--config", - "rl.toml", - ], - ] - assert legacy_app == [] - - def test_shared_deployment_call_preserves_waiting( - self, - new_app, - legacy_app, - ): - main(["deployments", "call", "-m", "model", "-c", "2"]) - assert new_app == [ - [ - "deployments", - "create", - "-m", - "model", - "--capacity", - "2", - "--wait", - ], - ] - assert legacy_app == [] + assert new_app == [argv] + assert expert == [] @pytest.mark.parametrize( "argv", @@ -322,7 +261,7 @@ def test_shared_deployment_call_preserves_waiting( ["rl", "functions", "register", "--rollout", "pkg.mod:Rollout"], ], ) - def test_sdk_shaped_commands_use_v2(self, argv, new_app, legacy_app): + def test_sdk_shaped_commands_use_v2(self, argv, new_app, expert): main(argv) assert new_app == [argv] - assert legacy_app == [] + assert expert == [] diff --git a/tests/cli/test_package_layout.py b/tests/cli/test_package_layout.py index 86a54d8a..e3d72fff 100644 --- a/tests/cli/test_package_layout.py +++ b/tests/cli/test_package_layout.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -"""Structural invariants of the CLI package layout. +"""Structural invariants of the independent CLI package trees. -``dashscope.cli`` is the v2 command tree and sole console entry point. It -routes frozen 1.x-only commands to ``dashscope.legacy_1x.cli`` lazily; v2 -commands never pay that import cost. There is only one console entry point. +``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 @@ -60,20 +59,20 @@ def test_v2_argv_executes_without_loading_the_legacy_cli(): assert result.returncode == 0, result.stdout + result.stderr -def test_legacy_argv_routes_to_the_legacy_cli(): +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 == 0\n" - "assert 'dashscope.legacy_1x.cli' in sys.modules\n", + " assert exc.code == 2\n" + "assert 'dashscope.legacy_1x.cli' not in sys.modules\n", ) assert result.returncode == 0, result.stdout + result.stderr - assert "Usage: dashscope generation call" in strip_ansi_codes( - result.stdout, - ) + 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): @@ -98,13 +97,11 @@ def _all_imports(path: Path): yield node.module -def test_only_the_entry_point_imports_legacy_1x(): +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") and not ( - path.name == "main.py" and name == "dashscope.legacy_1x.cli" - ): + if name.startswith("dashscope.legacy_1x"): offenders.append(f"{path.relative_to(PACKAGE_ROOT)}: {name}") assert offenders == [] @@ -132,25 +129,21 @@ def test_command_modules_never_import_the_sdk_at_module_scope(): assert offenders == [] -def test_console_script_target_resolves(): - """``setup.py`` points at ``dashscope.cli.main:main``. - - ``main`` there is a *module*, so the package must not bind the same - name to a function: whichever import ran first would win, and - ``from dashscope.cli import main`` would mean two different things. - """ +def test_console_script_targets_resolve(): + """Both console scripts resolve to independent CLI implementations.""" from importlib import import_module - module = import_module("dashscope.cli.main") - assert callable(module.main) + 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_shared_dashscope_name(): +def test_legacy_tree_uses_the_dashscope1_name(): from importlib import import_module module = import_module("dashscope.legacy_1x.cli") - assert callable(module.main) - assert module.app.info.name == "dashscope" + assert module.app.info.name == "dashscope1" def test_package_init_stays_lazy():