From 6b7d55eefcc6ce52877aaf65d2bd725964d61003 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Sat, 5 Sep 2026 19:49:11 +0800 Subject: [PATCH 1/4] fix(cli): keep the extras name in the rl registration warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The warning quotes a literal 'dashscope[rl]', which rich reads as a style tag and drops — users were told to run `pip install 'dashscope'`, which installs none of the extras the message is about. Escape the exception text. Also drop the duplicated instruction: the reinforcement package's ImportError already says how to install, so the CLI appended a second copy. The hint is now added only when the underlying message lacks one. --- dashscope/cli/__init__.py | 16 ++++++++---- tests/unit/test_cli_main.py | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/dashscope/cli/__init__.py b/dashscope/cli/__init__.py index 151f120..2e3725b 100644 --- a/dashscope/cli/__init__.py +++ b/dashscope/cli/__init__.py @@ -18,6 +18,7 @@ ) import typer # noqa: E402 +from rich.markup import escape # noqa: E402 import dashscope # noqa: E402 from dashscope.cli.common import err_console # noqa: E402 @@ -437,16 +438,21 @@ def _register_rl_app(): hidden=True, ) except ImportError as exception: + # The message quotes a literal 'dashscope[rl]'; unescaped, rich reads + # [rl] as a style tag and prints an install command with no extras. + detail = escape(str(exception)) + if "pip install" not in detail: + detail += ( + ". Install the optional dependencies with: " + "[bold]pip install 'dashscope\\[rl]'[/bold]" + ) err_console.print( - "[yellow]Warning:[/yellow] Failed to register rl command: " - f"{exception}. " - "Install the optional dependencies with: " - "[bold]pip install 'dashscope[rl]'[/bold]", + "[yellow]Warning:[/yellow] Failed to register rl command: " f"{detail}", ) except Exception as exception: err_console.print( "[yellow]Warning:[/yellow] Failed to register rl command: " - f"{exception}", + f"{escape(str(exception))}", ) diff --git a/tests/unit/test_cli_main.py b/tests/unit/test_cli_main.py index 60722b5..19b18f9 100644 --- a/tests/unit/test_cli_main.py +++ b/tests/unit/test_cli_main.py @@ -25,6 +25,21 @@ def strip_ansi_codes(text): return ansi_escape.sub("", text) +class _ImportErrorOnApp: + """Stand-in for a module whose ``from X import app`` raises ImportError. + + Lets the rl-registration warning be exercised whether or not the + optional dependencies happen to be installed in this environment. + """ + + def __init__(self, message): + self._message = message + + @property + def app(self): + raise ImportError(self._message) + + # pylint: disable=too-many-public-methods class TestCliMain: def test_main_prints_authentication_error_without_traceback( @@ -245,6 +260,42 @@ def test_agentic_rl_hidden_alias_help(self): assert result.exit_code == 0 assert "register_functions" in result.output + def test_rl_warning_keeps_the_extras_name(self, monkeypatch, capsys): + # rich reads a literal [rl] as a style tag and drops it, which used to + # print `pip install 'dashscope'` — a command that installs none of + # the extras the warning is about. + monkeypatch.setitem( + sys.modules, + "dashscope.cli.agentic_rl", + _ImportErrorOnApp( + "Agentic RL fine-tuning needs optional dependencies. " + "Install them with: pip install 'dashscope[rl]'", + ), + ) + + dashscope.cli._register_rl_app() + + err = " ".join(capsys.readouterr().err.split()) + assert "pip install 'dashscope[rl]'" in err + assert err.count("Install") == 1 + + def test_rl_warning_adds_a_hint_when_the_error_has_none( + self, + monkeypatch, + capsys, + ): + monkeypatch.setitem( + sys.modules, + "dashscope.cli.agentic_rl", + _ImportErrorOnApp("No module named 'typer_x'"), + ) + + dashscope.cli._register_rl_app() + + err = " ".join(capsys.readouterr().err.split()) + assert "No module named 'typer_x'" in err + assert "pip install 'dashscope[rl]'" in err + def test_subcommand_api_key_option_is_not_consumed_by_global_parser( self, monkeypatch, From 5c7bf621c4e85c8e82876b7f01030b72a86820d3 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Sat, 5 Sep 2026 19:49:23 +0800 Subject: [PATCH 2/4] sync(acli): provider/key wizards no longer dead-end on a stale provider Mirrors agenticCLI 1acdd7b. A provider persisted from a directory that had a custom-extensions.toml (e.g. zhipu) cannot be built elsewhere, and both entry points mishandled it: startup demanded an API key for a provider with no definition and suggested an env var nothing reads, and /provider offered that unbuildable name as the Enter default so a bare Enter returned "Unknown provider: zhipu; cancelled". --- dashscope/acli/cli/handlers_key.py | 19 ++++++++++++++++--- dashscope/acli/cli/handlers_provider.py | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/dashscope/acli/cli/handlers_key.py b/dashscope/acli/cli/handlers_key.py index 829abf5..49342c2 100644 --- a/dashscope/acli/cli/handlers_key.py +++ b/dashscope/acli/cli/handlers_key.py @@ -81,12 +81,25 @@ def ensure_provider_key(config: Config, agent) -> bool: ext = find_provider(config.provider) targets = all_key_targets(config) key_info = targets.get(config.provider) + if ext is None and key_info is None: + # Neither a built-in nor a loaded extension: this directory cannot + # build that provider, so collecting a key is a dead end — and the + # "_API_KEY" env var we would suggest is read by nothing. + console.print( + f"\n[yellow]Configured provider '{config.provider}' is not " + "available here (no built-in or loaded extension by that " + "name), so an API key alone will not make it work.[/yellow]" + ) + console.print( + "[dim]Starting anyway; run /provider to pick an available " + "provider.[/dim]" + ) + return True + if key_info: env_name = key_info.get("env") or "" - elif ext is not None: - env_name = ext.api_key_env or "" else: - env_name = f"{config.provider.upper()}_API_KEY" + env_name = ext.api_key_env or "" console.print( f"\n[yellow]No API Key detected for " f"{config.provider}[/yellow]", diff --git a/dashscope/acli/cli/handlers_provider.py b/dashscope/acli/cli/handlers_provider.py index c91118e..a4f19fc 100644 --- a/dashscope/acli/cli/handlers_provider.py +++ b/dashscope/acli/cli/handlers_provider.py @@ -168,11 +168,24 @@ def _provider_wizard(agent: Agent, config: Config) -> bool: for err in loaded.errors: console.print(f"[yellow]custom-extensions.toml: {err}[/yellow]") - # 1) Provider — Enter keeps the current one. + # 1) Provider — Enter keeps the current one, but only when it is + # loadable. A persisted extension provider whose custom-extensions.toml + # is not present here is absent from `names`, so offering it as the + # default turns a bare Enter into "Unknown provider; cancelled". names = list(PROVIDER_MODELS) + [ p.name for p in loaded.providers if p.name not in PROVIDER_MODELS ] - provider = _numbered_pick("Available providers", names, config.provider) + current = config.provider if config.provider in names else "" + if config.provider and not current: + console.print( + f"[yellow]Configured provider '{config.provider}' is not " + "available here (no built-in or loaded extension by that " + "name), so Enter cannot keep it — pick one below.[/yellow]" + ) + provider = _numbered_pick("Available providers", names, current) + if not provider: + console.print("[dim]No provider chosen; cancelled[/dim]") + return True if provider not in names: console.print(f"[red]Unknown provider: {provider}; cancelled[/red]") return True From cbaa8967d646d63cca29b698d0ad7b077c0f2c1e Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Tue, 8 Sep 2026 20:14:19 +0800 Subject: [PATCH 3/4] feat(acli): tongyi native DashScope route + expert guide surfaces - providers/tongyi: switch from compatible-mode to the native generation route so calls land in the native SLS logstore; per-model text/multimodal-generation endpoint fallback covering both the HTTP-400 and the 200-SSE error-event variants of "url error"; content parts/tool calls converted to the native shape - embedded/sdk: new module + guide_url params; the dashscope expert entry stamps x-dashscope-sdk-client as acli//expert and the TUI/REPL startup banners show the locale-aware expert guide link - first-run surfaces: get-api-key and dashscope-sdk-expert links in the no-key gate, the example-download offer, and the example README - README/README_zh: how to obtain an API key + expert guide link - includes the pending acli sync baseline (provider/key wizard fixes, subagents/config/dev/session/adapter updates) --- README.md | 3 +- README_zh.md | 3 +- dashscope/acli/agents/subagents.py | 2 +- dashscope/acli/cli/examples.py | 6 + dashscope/acli/cli/handlers_key.py | 23 +- dashscope/acli/cli/handlers_provider.py | 10 +- dashscope/acli/cli/startup.py | 5 + dashscope/acli/config.py | 7 +- dashscope/acli/dev.py | 25 +- .../basic-chat/.acli/custom-extensions.toml | 86 +-- dashscope/acli/examples/basic-chat/README.md | 4 +- .../examples/dashscope-sdk-expert/README.md | 2 + dashscope/acli/providers/__init__.py | 2 + dashscope/acli/providers/adapter.py | 4 +- dashscope/acli/providers/anthropic.py | 4 +- dashscope/acli/providers/openai.py | 2 +- dashscope/acli/providers/profile.py | 4 +- dashscope/acli/providers/tongyi.py | 597 ++++++++++++------ dashscope/acli/sdk.py | 4 + dashscope/acli/tools/session.py | 5 +- dashscope/acli/ui/embedded.py | 9 + dashscope/acli/ui/tui.py | 51 +- dashscope/cli/__init__.py | 64 +- tests/unit/test_banner_guide.py | 57 ++ tests/unit/test_cli_main.py | 70 +- tests/unit/test_handlers_key_docs.py | 69 ++ tests/unit/test_sdk_headers.py | 43 ++ tests/unit/test_tongyi_native.py | 590 +++++++++++++++++ 28 files changed, 1428 insertions(+), 323 deletions(-) create mode 100644 tests/unit/test_banner_guide.py create mode 100644 tests/unit/test_handlers_key_docs.py create mode 100644 tests/unit/test_tongyi_native.py diff --git a/README.md b/README.md index 9b6075f..e2fad59 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ else: ## API Key Authentication -The SDK uses API key for authentication. Please refer to [official documentation for alibabacloud china](https://www.alibabacloud.com/help/en/model-studio/) and [official documentation for alibabacloud international](https://www.alibabacloud.com/help/en/model-studio/) regarding how to obtain your api-key. +The SDK uses API key for authentication. To obtain an API Key, see [How to get an API Key](https://help.aliyun.com/en/model-studio/get-api-key). Please refer to [official documentation for alibabacloud china](https://www.alibabacloud.com/help/en/model-studio/) and [official documentation for alibabacloud international](https://www.alibabacloud.com/help/en/model-studio/) regarding how to obtain your api-key. ### Using the API Key @@ -95,6 +95,7 @@ The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built - Run `dashscope` with no arguments to start the assistant. On first run it offers to install the SDK Expert knowledge pack (per-domain quick-reference skills: text, multimodal, speech, retrieval, fine-tuning, agent, cli), so guidance comes from the SDK's public interfaces — parameters, outputs, error codes — without reading the source - Ask it instead of reading docs — e.g. `dashscope "how do I stream Generation output"` or `dashscope "CLI command to cancel a fine-tuning job"`. Type `/help` inside the assistant to list available commands (`/setup`, `/skill`, `/stats`, ...); classic SDK subcommands still work, and unrecognized commands are routed to the assistant +- Full walkthrough: [DashScope SDK Expert guide](https://help.aliyun.com/en/model-studio/dashscope-sdk-expert) ## Supported Models diff --git a/README_zh.md b/README_zh.md index 800753a..3fd2238 100644 --- a/README_zh.md +++ b/README_zh.md @@ -51,7 +51,7 @@ else: ## API Key 鉴权 -SDK 使用 API Key 进行鉴权。获取 API Key 的方法请参考[阿里云百炼官方文档(国内站)](https://help.aliyun.com/zh/model-studio/)和[阿里云百炼官方文档(国际站)](https://www.alibabacloud.com/help/en/model-studio/)。 +SDK 使用 API Key 进行鉴权。获取 API Key 请参见[如何获取 API Key](https://help.aliyun.com/zh/model-studio/get-api-key),或参考[阿里云百炼官方文档(国内站)](https://help.aliyun.com/zh/model-studio/)和[阿里云百炼官方文档(国际站)](https://www.alibabacloud.com/help/en/model-studio/)。 ### 使用 API Key @@ -94,6 +94,7 @@ SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供 - 直接运行 `dashscope`(不带参数)即可启动助手。首次运行时会提示安装 SDK Expert 知识包(按领域划分的速查技能:文本、多模态、语音、检索、微调、Agent、CLI),使助手的指导来自 SDK 的公开接口——参数、输出、错误码——而无需阅读源码 - 直接提问代替翻文档——如 `dashscope "如何流式输出 Generation 结果"` 或 `dashscope "取消微调任务的 CLI 命令"`。在助手内输入 `/help` 可列出可用命令(`/setup`、`/skill`、`/stats` 等);经典 SDK 子命令依然可用,无法识别的命令会自动转给助手处理 +- 完整使用指南:[DashScope SDK Expert 文档](https://help.aliyun.com/zh/model-studio/dashscope-sdk-expert) ## 支持的模型 diff --git a/dashscope/acli/agents/subagents.py b/dashscope/acli/agents/subagents.py index ad16d56..01afaf6 100644 --- a/dashscope/acli/agents/subagents.py +++ b/dashscope/acli/agents/subagents.py @@ -4,7 +4,7 @@ Subagents are a subset of capabilities that function as autonomous workers you delegate tasks to (vs. tool capabilities that the main agent calls). Currently: local.subagent (built-in) + extension capabilities that act as -remote agents (e.g., Coze workflows). +remote agents (e.g., a hosted workflow engine). The /subagents command provides: - list: show all discovered subagents with status diff --git a/dashscope/acli/cli/examples.py b/dashscope/acli/cli/examples.py index e375426..bcd6633 100644 --- a/dashscope/acli/cli/examples.py +++ b/dashscope/acli/cli/examples.py @@ -256,6 +256,12 @@ def _copy_example_flat(src: Path, dst: Path, *, force: bool) -> bool: ) console.print(f"[green]✓ Example copied to: {acli_dir}[/green]") + if src.name == "dashscope-sdk-expert": + from dashscope.acli.cli.handlers_key import _GUIDE_DOC, _doc_locale + + console.print( + f"[dim]Guide: {_GUIDE_DOC.format(_doc_locale())}[/dim]", + ) if backup_dir is not None: console.print( f"[dim]Overwritten files backed up to: {backup_dir} " diff --git a/dashscope/acli/cli/handlers_key.py b/dashscope/acli/cli/handlers_key.py index 49342c2..f53493f 100644 --- a/dashscope/acli/cli/handlers_key.py +++ b/dashscope/acli/cli/handlers_key.py @@ -16,6 +16,21 @@ console = Console() +# Model Studio doc links for the no-key startup prompt; locale segment +# comes from _doc_locale(). +_GET_API_KEY_DOC = "https://help.aliyun.com/{}/model-studio/get-api-key" +_GUIDE_DOC = "https://help.aliyun.com/{}/model-studio/dashscope-sdk-expert" + + +def _doc_locale() -> str: + """Pick the help-center locale from the process locale env vars.""" + import os + + for var in ("LC_ALL", "LC_MESSAGES", "LANG"): + if "zh" in (os.environ.get(var) or "").lower(): + return "zh" + return "en" + def all_key_targets(config: Config | None = None) -> dict[str, dict]: """Merge KEY_TARGETS (built-in) with extension providers into one dict. @@ -104,6 +119,12 @@ def ensure_provider_key(config: Config, agent) -> bool: console.print( f"\n[yellow]No API Key detected for " f"{config.provider}[/yellow]", ) + if config.provider.lower() == "tongyi": + lang = _doc_locale() + console.print( + f"[dim]Get an API Key: {_GET_API_KEY_DOC.format(lang)}[/dim]", + ) + console.print(f"[dim]Guide: {_GUIDE_DOC.format(lang)}[/dim]") console.print("Choose how to set it up:") if env_name: console.print(f" [1] Set env var {env_name} (exit and set)") @@ -232,7 +253,7 @@ def _set_extension_provider_token( console.print("[dim]Cancelled[/dim]") return False - # Save to the provider's dynamic slot, e.g. ideatalk_api_key. + # Save to the provider's dynamic slot, i.e. _api_key. old_provider = config.provider try: config.provider = ext_prov.name diff --git a/dashscope/acli/cli/handlers_provider.py b/dashscope/acli/cli/handlers_provider.py index a4f19fc..c413b29 100644 --- a/dashscope/acli/cli/handlers_provider.py +++ b/dashscope/acli/cli/handlers_provider.py @@ -182,7 +182,15 @@ def _provider_wizard(agent: Agent, config: Config) -> bool: "available here (no built-in or loaded extension by that " "name), so Enter cannot keep it — pick one below.[/yellow]" ) - provider = _numbered_pick("Available providers", names, current) + provider = _numbered_pick( + "Available providers", + names, + current, + custom_hint=( + "Need one that is not listed? /dev provider add registers it " + "in custom-extensions.toml" + ), + ) if not provider: console.print("[dim]No provider chosen; cancelled[/dim]") return True diff --git a/dashscope/acli/cli/startup.py b/dashscope/acli/cli/startup.py index 760bf29..6989517 100644 --- a/dashscope/acli/cli/startup.py +++ b/dashscope/acli/cli/startup.py @@ -156,6 +156,11 @@ def _print_banner(config: Config | None = None) -> None: f"[dim]{', '.join(sdk_index)}[/dim]", ) + # Scenario doc link (embedded mode only) + guide_url = getattr(config, "_embedded_guide_url", "") + if guide_url: + console.print(f" [bold]Guide:[/bold] [dim]{guide_url}[/dim]") + console.print() console.print(" [dim]Session: /help /clear /exit[/dim]") diff --git a/dashscope/acli/config.py b/dashscope/acli/config.py index 0e5e56c..72c2c51 100644 --- a/dashscope/acli/config.py +++ b/dashscope/acli/config.py @@ -466,8 +466,8 @@ def _load_global(self): key_field = f"{prov}_api_key" if key_field in data and not getattr(self, key_field): setattr(self, key_field, decrypt_value(str(data[key_field]))) - # Extension providers (ideatalk/deepseek/zhipu/...) may also store keys - # as _api_key in the global config file. + # Extension providers may also store keys as _api_key in + # the global config file. for key, val in data.items(): if ( key.endswith("_api_key") @@ -740,8 +740,7 @@ def _global_lines(self) -> list[str]: lines.append( f"{prov}_api_key = {toml_str(encrypt_value(key_val))}", ) - # Extension provider keys stored as _api_key (e.g. - # ideatalk_api_key) + # Extension provider keys stored as _api_key for attr in self.__dict__: if attr.endswith("_api_key") and attr not in built_in_key_fields: key_val = getattr(self, attr, "") diff --git a/dashscope/acli/dev.py b/dashscope/acli/dev.py index ff4cd27..d0d737b 100644 --- a/dashscope/acli/dev.py +++ b/dashscope/acli/dev.py @@ -146,15 +146,14 @@ def _model_list(config: Config) -> None: > Wire a new LLM into acli's chat / stream / tool-call loop. > In most scenarios **no code is needed** — just fill in a TOML block. -acli ships only 3 protocol implementations; every provider (including -built-ins tongyi/anthropic/openai/deepseek/zhipu/ideatalk/ollama) is -configured via `custom-extensions.toml`: +acli ships 3 protocol implementations and 3 built-in providers +(tongyi / anthropic / openai), which need no TOML at all. Every other +provider is configured via `custom-extensions.toml`: | Protocol | Implementation | Use case | |-------------|---------------------|---------------------------------| | `openai` | `OpenAIProvider` | OpenAI-compatible endpoints | -| | | (Moonshot/Yi/Step/Deepseek/ | -| | | Zhipu/Ollama…) | +| | | (any vendor, or local Ollama…) | | `anthropic` | `AnthropicProvider` | Anthropic Messages API (Claude | | | | / proxied endpoints) | | `dashscope` | `TongyiProvider` | DashScope OpenAI-compat | @@ -168,13 +167,13 @@ def _model_list(config: Config) -> None: (global) or `./.acli/custom-extensions.toml` (workspace): ```toml -# Moonshot / Kimi — OpenAI compatible +# Any OpenAI-compatible endpoint [[providers]] -name = "moonshot" -base_url = "https://api.moonshot.cn/v1" -api_key_env = "MOONSHOT_API_KEY" -default_model = "kimi-k2" -models = ["kimi-k1"] +name = "my-llm" +base_url = "https://llm.example.com/v1" +api_key_env = "MY_LLM_API_KEY" +default_model = "my-model" +models = ["my-model", "my-model-lite"] protocol = "openai" # Access Qwen via an Anthropic-protocol proxy @@ -527,7 +526,7 @@ def _provider_add(config: Config) -> None: return api_key_enc = encrypt_for_toml(secret) else: - api_key_env = _prompt("Env var name (e.g. MOONSHOT_API_KEY)") + api_key_env = _prompt("Env var name (e.g. MY_LLM_API_KEY)") if not api_key_env: console.print("[red]Env var name must not be empty[/red]") return @@ -1102,7 +1101,7 @@ def handle_dev_command(cmd: str, config: Config) -> None: if len(parts) >= 5: _model_add(config, parts[3], parts[4]) elif len(parts) == 4: - # Allow shorthand: /dev model add glm-image + # Allow shorthand: /dev model add qwen-image model = parts[3] provider = _infer_provider_from_model(model) if provider is None: diff --git a/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml b/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml index 9592d28..9e53f3b 100644 --- a/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml +++ b/dashscope/acli/examples/basic-chat/.acli/custom-extensions.toml @@ -1,9 +1,9 @@ # acli custom extensions — basic-chat example # ============================================================================ # Minimal working config: declares only one tongyi provider, demonstrating how -# to write custom-extensions.toml. Add other providers (kimi / deepseek / -# zhipu / ollama / ...) by adding [[providers]] blocks following the same -# pattern. +# to write custom-extensions.toml. Add other providers (any OpenAI-compatible +# endpoint, or a local Ollama) by adding [[providers]] blocks following the +# same pattern. # # Load locations (either one; workspace takes precedence): # ~/.acli/custom-extensions.toml applies globally @@ -32,33 +32,20 @@ vision_models = ["qwen-vl-max", "qwen-vl-plus"] protocol = "openai" # ============================================================================ -# More provider templates (kept in sync with the project .acli/custom-extensions.toml) +# More provider templates # Uncomment the corresponding block and set the env var to use it. # ============================================================================ +# ---- Any OpenAI-compatible endpoint ---- # [[providers]] -# name = "kimi" -# base_url = "https://api.moonshot.cn/v1" -# api_key_env = "MOONSHOT_API_KEY" -# default_model = "kimi-k3" -# models = ["kimi-k3", "moonshot-v1-128k", "moonshot-v1-32k"] - -# [[providers]] -# name = "deepseek" -# base_url = "https://api.deepseek.com" -# api_key_env = "DEEPSEEK_API_KEY" -# default_model = "deepseek-v4-pro" -# models = ["deepseek-v4-pro", "deepseek-v4-flash"] -# protocol = "openai" - -# [[providers]] -# name = "zhipu" -# base_url = "https://open.bigmodel.cn/api/paas/v4" -# api_key_env = "ZHIPU_API_KEY" -# default_model = "glm-5.2" -# models = ["glm-5.2", "glm-4-plus", "glm-4", "glm-4v", "glm-4v-plus", "glm-4-flash"] +# name = "my-llm" +# base_url = "https://llm.example.com/v1" +# api_key_env = "MY_LLM_API_KEY" +# default_model = "my-model" +# models = ["my-model", "my-model-lite"] # protocol = "openai" +# ---- Local Ollama, no auth ---- # [[providers]] # name = "ollama" # base_url = "http://localhost:11434/v1" @@ -72,8 +59,8 @@ protocol = "openai" # ============================================================================ # Usage: # 1. Uncomment a [[capabilities]] block (including its [[capabilities.tools]]) -# 2. Set the corresponding env var (e.g. export ZHIPU_API_KEY=xxx) -# 3. Run /capability enable to activate it (e.g. /capability enable zhipu.image) +# 2. Set the corresponding env var (e.g. export DASHSCOPE_API_KEY=xxx) +# 3. Run /capability enable to activate it (e.g. /capability enable dashscope.image) # 4. Trigger it with natural language in chat ("draw a cat") or let the LLM call it autonomously # # Two tool types are supported: @@ -81,31 +68,10 @@ protocol = "openai" # - type = "vision" — calls a vision LLM to read images; requires provider + model # ============================================================================ -# ---- Example 1: Zhipu GLM image generation (HTTP tool) ---- -# export ZHIPU_API_KEY="xxx" -# /capability enable zhipu.image -# Say in chat: "draw a bicycle parked by a lake" -# [[capabilities]] -# key = "zhipu.image" -# display = "Zhipu GLM Image Generation" -# auth = "apikey-header:Authorization:$ZHIPU_API_KEY" -# -# [[capabilities.tools]] -# name = "generate_glm_image" -# description = "Generate an image from a text description with the Zhipu GLM-Image model" -# endpoint = "https://open.bigmodel.cn/api/paas/v4/images/generations" -# http_method = "POST" -# permission = "confirm" -# params = [ -# {name = "prompt", type = "string", required = true, description = "Image description, e.g. 'a mountain bike parked by a lake'"}, -# {name = "model", type = "string", required = false, default = "glm-image-4v", description = "Image generation model name"}, -# {name = "size", type = "string", required = false, default = "1024x1024", description = "Output image size"}, -# ] -# body_template = '{"model": {{model}}, "prompt": {{prompt}}, "size": {{size}}}' - -# ---- Example 2: Tongyi Wanxiang image generation (HTTP tool, reuses DASHSCOPE_API_KEY) ---- +# ---- Example 1: Tongyi Wanxiang image generation (HTTP tool, reuses DASHSCOPE_API_KEY) ---- # export DASHSCOPE_API_KEY="xxx" # /capability enable dashscope.image +# Say in chat: "draw a bicycle parked by a lake" # [[capabilities]] # key = "dashscope.image" # display = "Tongyi Wanxiang Image Generation" @@ -124,7 +90,7 @@ protocol = "openai" # ] # body_template = '{"model": {{model}}, "input": {"prompt": {{prompt}}}, "parameters": {"size": {{size}}}}' -# ---- Example 3: Tongyi vision understanding (vision tool, calls qwen-vl-max to read images) ---- +# ---- Example 2: Tongyi vision understanding (vision tool, calls qwen-vl-max to read images) ---- # Lets the text agent call a vision model on demand to understand images; no need to switch the main model. # Usage: /capability enable tongyi.vision, then say "look at this image @photo.png and describe it" # export DASHSCOPE_API_KEY="xxx" @@ -145,22 +111,22 @@ protocol = "openai" # {name = "question", type = "string", required = false, default = "Describe the content of this image", description = "Question about the image"}, # ] -# ---- Example 4: Coze workflow (HTTP tool, calls a Coze cloud agent) ---- -# export COZE_API_KEY="xxx" -# /capability enable coze.workflow +# ---- Example 3: External workflow engine (HTTP tool, calls a cloud agent) ---- +# export MY_WORKFLOW_API_KEY="xxx" +# /capability enable workflow.run # [[capabilities]] -# key = "coze.workflow" -# display = "Coze Workflow (cloud agent / sub-agent)" -# auth = "bearer:$COZE_API_KEY" +# key = "workflow.run" +# display = "External Workflow (cloud agent / sub-agent)" +# auth = "bearer:$MY_WORKFLOW_API_KEY" # # [[capabilities.tools]] -# name = "run_coze_workflow" -# description = "Run a Coze workflow to complete a specific task" -# endpoint = "https://api.coze.cn/v1/workflow/run" +# name = "run_workflow" +# description = "Run a remote workflow to complete a specific task" +# endpoint = "https://workflow.example.com/v1/workflow/run" # http_method = "POST" # permission = "confirm" # params = [ -# {name = "workflow_id", type = "string", required = true, description = "Coze workflow ID"}, +# {name = "workflow_id", type = "string", required = true, description = "Workflow ID"}, # {name = "parameters", type = "object", required = true, description = "Workflow input parameters (a JSON object matching the workflow definition)"}, # ] # body_template = '{"workflow_id": {{workflow_id}}, "parameters": {{parameters}}}' diff --git a/dashscope/acli/examples/basic-chat/README.md b/dashscope/acli/examples/basic-chat/README.md index c04445a..c96402c 100644 --- a/dashscope/acli/examples/basic-chat/README.md +++ b/dashscope/acli/examples/basic-chat/README.md @@ -57,7 +57,7 @@ vision_models = ["qwen-vl-max"] # ← tells acli these models accept imag protocol = "openai" # ← openai / anthropic / dashscope ``` -Want Claude / GPT / GLM? Just uncomment the corresponding `[[providers]]` block in the toml. +Want Claude / GPT / a local Ollama? Just uncomment the corresponding `[[providers]]` block in the toml. **Three ways to provide an API key** (in decreasing order of recommendation): @@ -102,7 +102,7 @@ memory_user_id = "acli-basic" ## Next Steps - **Add more providers**: add `[[providers]]` blocks in `custom-extensions.toml` -- **Add HTTP tools**: add `[[capabilities]]` + `[[capabilities.tools]]` blocks (e.g. calling Coze, Zhipu image generation, etc.) +- **Add HTTP tools**: add `[[capabilities]]` + `[[capabilities.tools]]` blocks (e.g. image generation, calling a remote workflow engine) - **Add vision capability**: add a capability tool with `type = "vision"` so the text agent can call a vision LLM on demand - **Add shell tools**: add `[[shell_tools]]` blocks to wrap common local commands - **Add hooks**: configure pre/post tool-call hooks in `.acli/hooks.toml` (e.g. auto `py_compile` after writing a `.py` file, confirm before `pip install`, block file deletion). See the template in `.acli/hooks.toml`, covering all 5 events (`before_tool_call` / `after_tool_call` / `on_error` / `on_message` / `on_response`) × 6 actions (run/block/confirm/warn/alert/log). diff --git a/dashscope/acli/examples/dashscope-sdk-expert/README.md b/dashscope/acli/examples/dashscope-sdk-expert/README.md index f8c1e89..c6040de 100644 --- a/dashscope/acli/examples/dashscope-sdk-expert/README.md +++ b/dashscope/acli/examples/dashscope-sdk-expert/README.md @@ -1,5 +1,7 @@ # DashScope SDK Expert — acli Configuration-Driven Example +**Online guide**: https://help.aliyun.com/en/model-studio/dashscope-sdk-expert + This example shows how to build a scenario-specific AI expert agent using **AgenticCLI (acli)**'s native configuration mechanisms. **Core idea: configuration-driven, zero Python glue.** The agent's identity, capabilities, skills, and knowledge index are all defined by files under `.acli/`; download the example and run `acli` directly to start. diff --git a/dashscope/acli/providers/__init__.py b/dashscope/acli/providers/__init__.py index 8c3b72b..7e7bb34 100644 --- a/dashscope/acli/providers/__init__.py +++ b/dashscope/acli/providers/__init__.py @@ -74,6 +74,7 @@ def _create_provider(profile: ProviderProfile): api_key=api_key, base_url=base_url, protocol=proto, + module=profile.module, ) if proto == "anthropic" or provider_name == "anthropic": from dashscope.acli.providers.anthropic import AnthropicProvider @@ -89,6 +90,7 @@ def _create_provider(profile: ProviderProfile): api_key=api_key, base_url=base_url, protocol=proto, + module=profile.module, ) from dashscope.acli.providers.openai import OpenAIProvider diff --git a/dashscope/acli/providers/adapter.py b/dashscope/acli/providers/adapter.py index 7759d39..d4881d5 100644 --- a/dashscope/acli/providers/adapter.py +++ b/dashscope/acli/providers/adapter.py @@ -3,8 +3,8 @@ Anthropic <-> OpenAI protocol adapter. Converts Anthropic Messages API format to OpenAI Chat Completions format -and vice versa, allowing providers like Tongyi (Qwen) and Zhipu to be -accessed using Anthropic protocol. +and vice versa, allowing an OpenAI-compatible backend such as Tongyi +(Qwen) to be accessed using the Anthropic protocol. """ # pylint: disable=too-many-branches,too-many-statements diff --git a/dashscope/acli/providers/anthropic.py b/dashscope/acli/providers/anthropic.py index 2705f23..837a8c2 100644 --- a/dashscope/acli/providers/anthropic.py +++ b/dashscope/acli/providers/anthropic.py @@ -261,7 +261,7 @@ async def chat_stream( elif event.type == "content_block_delta": delta = event.delta # Anthropic SDK uses "text_delta"; some - # OpenAI-compatible backends (e.g. ideatalk) emit + # proxied OpenAI-compatible backends emit # "text" or put text directly on the delta. # Accept any object that carries text. if getattr(delta, "type", "") == "text_delta": @@ -343,7 +343,7 @@ async def chat_stream( ) # Detect silent failures: API returned 200 but no content. - # Some backends (e.g. ideatalk rate-limit) send error in body + # Some backends (e.g. on rate-limit) send error in body # without raising, producing zero stream events. if not received_content: stop = None diff --git a/dashscope/acli/providers/openai.py b/dashscope/acli/providers/openai.py index ad5c1b1..e91355b 100644 --- a/dashscope/acli/providers/openai.py +++ b/dashscope/acli/providers/openai.py @@ -59,7 +59,7 @@ def _convert_messages(self, messages: list[dict]) -> list[dict]: "role": "assistant", "content": msg.get("content") or None, } - # Reasoning models (deepseek-v4, qwen-thinking, etc.) + # Reasoning models (qwen-thinking and the like) # require the reasoning_content from prior assistant # turns to be echoed back. if msg.get("reasoning_content"): diff --git a/dashscope/acli/providers/profile.py b/dashscope/acli/providers/profile.py index 83a4e21..a60886d 100644 --- a/dashscope/acli/providers/profile.py +++ b/dashscope/acli/providers/profile.py @@ -39,7 +39,7 @@ } DEFAULT_BASE_URLS = { - "tongyi": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "tongyi": "https://dashscope.aliyuncs.com", "anthropic": "https://api.anthropic.com", "openai": "https://api.openai.com/v1", } @@ -57,6 +57,7 @@ class ProviderProfile: timeout: float = 120.0 protocol: str = "openai" max_retries: int = 3 + module: str = "app" def _host_of(url: str | None) -> str: @@ -165,6 +166,7 @@ def _profile_for( base_url=base_url or None, timeout=timeout, protocol=protocol, + module=getattr(config, "_embedded_module", "") or "app", ) # Primary profile. diff --git a/dashscope/acli/providers/tongyi.py b/dashscope/acli/providers/tongyi.py index f1806f6..c3fefce 100644 --- a/dashscope/acli/providers/tongyi.py +++ b/dashscope/acli/providers/tongyi.py @@ -11,7 +11,21 @@ from dashscope.acli import SDK_SESSION_ID, __version__ from dashscope.acli.providers.base import LLMChunk, LLMResponse, ToolCall -DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +# Service root; the provider appends the native generation path. This is +# the DashScope native route (NOT the OpenAI-compatible one) so requests +# land in the native SLS logstore. +DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com" +# The gateway binds each model to exactly one aigc service path (newer +# models like qwen3.8-max live on multimodal-generation, classic text +# models on text-generation). The provider tries them in order and +# remembers the one that answers 200 — see _is_url_error. +_GENERATION_PATHS = ( + "/api/v1/services/aigc/text-generation/generation", + "/api/v1/services/aigc/multimodal-generation/generation", +) +# Historical/standard prefixes a persisted config may carry; the provider +# normalizes them back to the service root. +_LEGACY_PREFIXES = ("/compatible-mode/v1", "/api/v1") def _safe_get(obj, key, default=None): @@ -22,6 +36,83 @@ def _safe_get(obj, key, default=None): return getattr(obj, key, default) +def _is_url_error(status_code: int, body: str) -> bool: + """The gateway's "model is not served on this service path" signal. + + The message text ("url error, please check url") is misleading — it + really means the model is bound to the other aigc generation path. + """ + return status_code == 400 and "url error" in body + + +class _StreamAPIError(RuntimeError): + """Mid-stream native error (200 SSE body carrying {code, message}).""" + + def __init__(self, code, message): + super().__init__(f"DashScope API error: {code} - {message}") + self.code = code + self.message = message + + +def _is_url_error_message(message: str) -> bool: + """url-error check for the SSE variant, which arrives on a 200.""" + return "url error" in (message or "") + + +def _to_native_content(content): + """Convert OpenAI message content parts to the native shape. + + Text-only part lists flatten to a plain string (text-generation + expects string content). Lists carrying images become native + {"text"/"image"} parts — multimodal-generation rejects the OpenAI + {"type": "image_url", "image_url": {...}} shape outright. + """ + if not isinstance(content, list): + return content + parts = [] + for part in content: + if not isinstance(part, dict): + continue + ptype = part.get("type") + if ptype == "text": + parts.append({"text": part.get("text", "")}) + elif ptype == "image_url": + image_url = part.get("image_url") or {} + url = ( + _safe_get(image_url, "url", "") + if isinstance(image_url, dict) + else image_url + ) + parts.append({"image": url}) + elif "text" in part or "image" in part: + parts.append(part) # already native + if parts and all("text" in p for p in parts): + return "".join(p["text"] for p in parts) + return parts + + +def _to_native_messages(messages: list[dict]) -> list[dict]: + result = [] + for m in messages: + if isinstance(_safe_get(m, "content"), list): + m = {**m, "content": _to_native_content(m["content"])} + result.append(m) + return result + + +def _flatten_content(message: dict) -> dict: + """multimodal-generation returns content as [{"text": ...}] parts.""" + content = _safe_get(message, "content") + if isinstance(content, list): + message = dict(message) + message["content"] = "".join( + _safe_get(p, "text", "") or "" + for p in content + if isinstance(p, dict) + ) + return message + + def _extract_usage(response) -> dict | None: """Extract token usage from response dict.""" usage = _safe_get(response, "usage") @@ -40,6 +131,59 @@ def _extract_usage(response) -> dict | None: } +def _native_usage_to_openai(usage) -> dict | None: + """Map native usage names to the OpenAI names _extract_usage reads.""" + if not usage: + return None + result = { + "prompt_tokens": _safe_get(usage, "input_tokens", 0) or 0, + "completion_tokens": _safe_get(usage, "output_tokens", 0) or 0, + "total_tokens": _safe_get(usage, "total_tokens", 0) or 0, + } + details = _safe_get(usage, "prompt_tokens_details") + if details: + result["prompt_tokens_details"] = details + return result + + +def _native_to_openai_response(data) -> dict: + """Reshape a native generation response to OpenAI chat.completion.""" + output = _safe_get(data, "output") or {} + choices = [] + for choice in _safe_get(output, "choices") or []: + c = dict(choice) + if "message" in c: + c["message"] = _flatten_content(c["message"]) + choices.append(c) + result = {"choices": choices} + usage = _native_usage_to_openai(_safe_get(data, "usage")) + if usage: + result["usage"] = usage + return result + + +def _native_chunk_to_openai(chunk) -> dict: + """Reshape a native SSE chunk to the OpenAI delta shape. + + Native streams choices[].message; OpenAI streams choices[].delta. With + result_format=message + incremental_output=true the message holds only + the incremental delta, so a key rename plus content-list flattening is + the whole conversion. + """ + output = _safe_get(chunk, "output") or {} + choices = [] + for choice in _safe_get(output, "choices") or []: + c = dict(choice) + if "message" in c: + c["delta"] = _flatten_content(c.pop("message")) + choices.append(c) + result = {"choices": choices} + usage = _native_usage_to_openai(_safe_get(chunk, "usage")) + if usage: + result["usage"] = usage + return result + + class TongyiProvider: def __init__( self, @@ -54,8 +198,18 @@ def __init__( self.api_key = api_key self.request_timeout = request_timeout self.protocol = protocol - self.base_url = (base_url or DASHSCOPE_BASE_URL).rstrip("/") + base = (base_url or DASHSCOPE_BASE_URL).rstrip("/") + for prefix in _LEGACY_PREFIXES: + if base.endswith(prefix): + base = base[: -len(prefix)] + break + self.base_url = base self.module = module + self._generation_path = _GENERATION_PATHS[0] + + def _candidate_paths(self) -> list[str]: + others = [p for p in _GENERATION_PATHS if p != self._generation_path] + return [self._generation_path, *others] def _convert_tools(self, tools: list[dict] | None) -> list[dict] | None: if not tools: @@ -110,23 +264,36 @@ def _build_request_body( stream: bool = False, response_format: dict | None = None, ) -> dict: - """Build OpenAI-compatible request body for DashScope.""" - body = { - "model": self.model, - "messages": messages, - "stream": stream, - } + """Build native DashScope generation request body. + + result_format="message" makes the native route return OpenAI-like + output.choices[].message and is required for tool calling. The + stream flag mirrors what the python SDK sends; the SSE headers in + _get_headers are what actually switch the wire to SSE. + """ + parameters: dict = {"result_format": "message"} + if stream: + parameters["stream"] = True + parameters["incremental_output"] = True ds_tools = self._convert_tools(tools) if ds_tools: - body["tools"] = ds_tools + parameters["tools"] = ds_tools if response_format: - body["response_format"] = response_format - return body + parameters["response_format"] = response_format + return { + "model": self.model, + "input": {"messages": _to_native_messages(messages)}, + "parameters": parameters, + } - def _get_headers(self) -> dict: + def _get_headers(self, stream: bool = False) -> dict: headers = {"Content-Type": "application/json"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" + if stream: + headers["Accept"] = "text/event-stream" + headers["X-Accel-Buffering"] = "no" + headers["X-DashScope-SSE"] = "enable" if not os.environ.get("DASHSCOPE_DISABLE_SDK_HEADERS"): parts = ["acli", __version__] if self.module: @@ -173,11 +340,25 @@ async def chat( async with httpx.AsyncClient( timeout=self.request_timeout, ) as client: - response = await client.post( - f"{self.base_url}/chat/completions", - json=body, - headers=headers, - ) + paths = self._candidate_paths() + for i, path in enumerate(paths): + response = await client.post( + f"{self.base_url}{path}", + json=body, + headers=headers, + ) + if response.status_code == 200: + self._generation_path = path + break + if i + 1 < len(paths) and _is_url_error( + response.status_code, + response.text, + ): + continue # model lives on the other service path + raise RuntimeError( + f"DashScope API error: {response.status_code} - " + f"{response.text}", + ) except httpx.TimeoutException as e: raise RuntimeError( "API request timed out; check network or retry later", @@ -187,13 +368,17 @@ async def chat( "Cannot connect to API server; check network", ) from e - if response.status_code != 200: + raw = response.json() + if raw.get("code"): raise RuntimeError( - f"DashScope API error: {response.status_code} - " - f"{response.text}", + f"DashScope API error: {raw.get('code')} - " + f"{raw.get('message')}", + ) + data = _native_to_openai_response(raw) + if not data["choices"]: + raise RuntimeError( + f"DashScope API error: unexpected response: {raw}", ) - - data = response.json() # If protocol is anthropic, convert output from OpenAI to # Anthropic format @@ -268,181 +453,53 @@ async def chat_stream( stream=True, response_format=response_format, ) - headers = self._get_headers() - # Request incremental streaming for DashScope - body["stream_options"] = {"include_usage": True} + headers = self._get_headers(stream=True) try: async with httpx.AsyncClient( timeout=self.request_timeout, ) as client: - async with client.stream( - "POST", - f"{self.base_url}/chat/completions", - json=body, - headers=headers, - ) as response: - if response.status_code != 200: - error_body = await response.aread() - raise RuntimeError( - f"DashScope API error: {response.status_code} - " - f"{error_body.decode()}", - ) - - pending_tools: dict[int, dict] = {} - last_usage: dict | None = None - usage_sent = False - _json_buf: str | None = None - - async for line in response.aiter_lines(): - if not line.startswith("data: "): - continue - payload = line[6:].strip() - if payload == "[DONE]": - break - - # Buffer incomplete JSON across SSE lines - # (server may split tool argument strings across - # multiple data: lines or send multi-line JSON) - if _json_buf is not None: - payload = _json_buf + payload - _json_buf = None - - try: - chunk = json.loads(payload) - except json.JSONDecodeError: - _json_buf = payload - continue - - usage = _extract_usage(chunk) - if usage: - last_usage = usage - - if not chunk.get("choices"): - continue - - choice = chunk["choices"][0] - delta = choice.get("delta", {}) - finish = choice.get("finish_reason") - - content = delta.get("content", "") or "" - delta_reasoning = ( - delta.get("reasoning_content", "") or "" - ) - raw_calls = delta.get("tool_calls", []) or [] - - # Accumulate tool calls across chunks - for pos, call in enumerate(raw_calls): - slot = _safe_get(call, "index", pos) - func = _safe_get(call, "function", {}) or {} - if slot not in pending_tools: - pending_tools[slot] = { - "id": "", - "name": "", - "arguments": "", - } - call_id = _safe_get(call, "id", "") - if call_id: - pending_tools[slot]["id"] = call_id - func_name = _safe_get(func, "name", "") - if func_name: - pending_tools[slot]["name"] = func_name - args = _safe_get(func, "arguments", "") - if args: - if isinstance(args, str): - pending_tools[slot]["arguments"] += args - elif isinstance(args, dict): - pending_tools[slot][ - "arguments" - ] = json.dumps( - args, - ensure_ascii=False, - ) - - if content: - yield LLMChunk(delta_content=content) - if delta_reasoning: - yield LLMChunk( - delta_reasoning_content=delta_reasoning, + paths = self._candidate_paths() + for i, path in enumerate(paths): + async with client.stream( + "POST", + f"{self.base_url}{path}", + json=body, + headers=headers, + ) as response: + if response.status_code != 200: + error_body = await response.aread() + text = error_body.decode() + if i + 1 < len(paths) and _is_url_error( + response.status_code, + text, + ): + continue # model on the other service path + raise RuntimeError( + f"DashScope API error: " + f"{response.status_code} - {text}", ) - - if finish and finish != "null": - tool_calls = [] - for tool_data in pending_tools.values(): - if not tool_data["name"]: - continue - raw_args = tool_data["arguments"] - try: - args = ( - json.loads(raw_args) - if raw_args - else {} - ) - except json.JSONDecodeError: - # Try to repair truncated JSON - try: - args = json.loads(raw_args + '"}') - except json.JSONDecodeError: - args = {} - tool_calls.append( - ToolCall( - id=tool_data["id"], - name=tool_data["name"], - arguments=args, - ), - ) - if tool_calls: - yield LLMChunk( - tool_calls=tool_calls, - finish_reason=finish, - usage=last_usage, - ) - else: - yield LLMChunk( - finish_reason=finish, - usage=last_usage, - ) - usage_sent = usage_sent or last_usage is not None - # Prevent re-emission by later finish chunks or - # the orphan flush below (duplicate tool calls). - pending_tools.clear() - - # Flush pending tool calls if stream ended without - # finish_reason (network drop, rate limit, truncated - # response). - if pending_tools: - orphan_calls = [] - for tool_data in pending_tools.values(): - if not tool_data["name"]: + # The url error also arrives as a 200 SSE error + # event, so commit to (and cache) this path only + # after the first real chunk; before that, trying + # the other path is still safe. + stream = self._iter_stream(response) + try: + first = await stream.__anext__() + except StopAsyncIteration: + self._generation_path = path + return + except _StreamAPIError as e: + if i + 1 < len(paths) and _is_url_error_message( + e.message, + ): continue - raw_args = tool_data["arguments"] - try: - args = json.loads(raw_args) if raw_args else {} - except json.JSONDecodeError: - try: - args = json.loads(raw_args + '"}') - except json.JSONDecodeError: - args = {} - orphan_calls.append( - ToolCall( - id=tool_data["id"], - name=tool_data["name"], - arguments=args, - ), - ) - if orphan_calls: - yield LLMChunk( - tool_calls=orphan_calls, - finish_reason="stop", - usage=last_usage, - ) - usage_sent = usage_sent or last_usage is not None - - # include_usage payload arrives as a separate tail - # chunk after finish; last_usage was still None when - # the finish block above yielded, so flush it here. - if last_usage and not usage_sent: - yield LLMChunk(usage=last_usage) + raise + self._generation_path = path + yield first + async for chunk in stream: + yield chunk + return except httpx.TimeoutException as e: raise RuntimeError( "API request timed out; check network or retry later", @@ -451,3 +508,163 @@ async def chat_stream( raise RuntimeError( "Cannot connect to API server; check network", ) from e + + async def _iter_stream(self, response) -> AsyncIterator[LLMChunk]: + pending_tools: dict[int, dict] = {} + last_usage: dict | None = None + usage_sent = False + _json_buf: str | None = None + + async for line in response.aiter_lines(): + # Native SSE sends "data:{...}" with no space; + # compatible-mode sends "data: {...}". + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if payload == "[DONE]": + break + + # Buffer incomplete JSON across SSE lines + # (server may split tool argument strings across + # multiple data: lines or send multi-line JSON) + if _json_buf is not None: + payload = _json_buf + payload + _json_buf = None + + try: + raw_chunk = json.loads(payload) + except json.JSONDecodeError: + _json_buf = payload + continue + + # Mid-stream native errors arrive as {code, message} + # data lines on a 200 response. + if raw_chunk.get("code"): + raise _StreamAPIError( + raw_chunk.get("code"), + raw_chunk.get("message"), + ) + + chunk = _native_chunk_to_openai(raw_chunk) + + usage = _extract_usage(chunk) + if usage: + last_usage = usage + + if not chunk.get("choices"): + continue + + choice = chunk["choices"][0] + delta = choice.get("delta", {}) + finish = choice.get("finish_reason") + + content = delta.get("content", "") or "" + delta_reasoning = delta.get("reasoning_content", "") or "" + raw_calls = delta.get("tool_calls", []) or [] + + # Accumulate tool calls across chunks + for pos, call in enumerate(raw_calls): + slot = _safe_get(call, "index", pos) + func = _safe_get(call, "function", {}) or {} + if slot not in pending_tools: + pending_tools[slot] = { + "id": "", + "name": "", + "arguments": "", + } + call_id = _safe_get(call, "id", "") + if call_id: + pending_tools[slot]["id"] = call_id + func_name = _safe_get(func, "name", "") + if func_name: + pending_tools[slot]["name"] = func_name + args = _safe_get(func, "arguments", "") + if args: + if isinstance(args, str): + pending_tools[slot]["arguments"] += args + elif isinstance(args, dict): + pending_tools[slot]["arguments"] = json.dumps( + args, + ensure_ascii=False, + ) + + if content: + yield LLMChunk(delta_content=content) + if delta_reasoning: + yield LLMChunk( + delta_reasoning_content=delta_reasoning, + ) + + if finish and finish != "null": + tool_calls = [] + for tool_data in pending_tools.values(): + if not tool_data["name"]: + continue + raw_args = tool_data["arguments"] + try: + args = json.loads(raw_args) if raw_args else {} + except json.JSONDecodeError: + # Try to repair truncated JSON + try: + args = json.loads(raw_args + '"}') + except json.JSONDecodeError: + args = {} + tool_calls.append( + ToolCall( + id=tool_data["id"], + name=tool_data["name"], + arguments=args, + ), + ) + if tool_calls: + yield LLMChunk( + tool_calls=tool_calls, + finish_reason=finish, + usage=last_usage, + ) + else: + yield LLMChunk( + finish_reason=finish, + usage=last_usage, + ) + usage_sent = usage_sent or last_usage is not None + # Prevent re-emission by later finish chunks or + # the orphan flush below (duplicate tool calls). + pending_tools.clear() + + # Flush pending tool calls if stream ended without + # finish_reason (network drop, rate limit, truncated + # response). + if pending_tools: + orphan_calls = [] + for tool_data in pending_tools.values(): + if not tool_data["name"]: + continue + raw_args = tool_data["arguments"] + try: + args = json.loads(raw_args) if raw_args else {} + except json.JSONDecodeError: + try: + args = json.loads(raw_args + '"}') + except json.JSONDecodeError: + args = {} + orphan_calls.append( + ToolCall( + id=tool_data["id"], + name=tool_data["name"], + arguments=args, + ), + ) + if orphan_calls: + yield LLMChunk( + tool_calls=orphan_calls, + finish_reason="stop", + usage=last_usage, + ) + usage_sent = usage_sent or last_usage is not None + + # include_usage payload arrives as a separate tail + # chunk after finish; last_usage was still None when + # the finish block above yielded, so flush it here. + if last_usage and not usage_sent: + yield LLMChunk(usage=last_usage) diff --git a/dashscope/acli/sdk.py b/dashscope/acli/sdk.py index dffff67..2af4d6b 100644 --- a/dashscope/acli/sdk.py +++ b/dashscope/acli/sdk.py @@ -157,6 +157,8 @@ def run_interactive( prompt_symbol: str = "You> ", sdk_index: Optional[list[str]] = None, tui: Optional[bool] = None, + module: str = "", + guide_url: str = "", ) -> None: """Run the full acli interactive loop with a custom identity. @@ -176,6 +178,8 @@ def run_interactive( prompt_symbol=prompt_symbol, sdk_index=sdk_index, tui=tui, + module=module, + guide_url=guide_url, ) diff --git a/dashscope/acli/tools/session.py b/dashscope/acli/tools/session.py index d6c855d..29202e4 100644 --- a/dashscope/acli/tools/session.py +++ b/dashscope/acli/tools/session.py @@ -97,10 +97,7 @@ async def list_models() -> str: registry.register( ToolDefinition( name="switch_provider", - description=( - "Switch AI provider " - "(tongyi/anthropic/openai/deepseek/zhipu)" - ), + description=f"Switch AI provider ({'/'.join(PROVIDER_MODELS)})", permission=PermissionLevel.AUTO, func=switch_provider, parameters={ diff --git a/dashscope/acli/ui/embedded.py b/dashscope/acli/ui/embedded.py index faec249..ea14e0c 100644 --- a/dashscope/acli/ui/embedded.py +++ b/dashscope/acli/ui/embedded.py @@ -34,6 +34,8 @@ def run( prompt_symbol: str = "You> ", sdk_index: Optional[list[str]] = None, tui: Optional[bool] = None, + module: str = "", + guide_url: str = "", ): """Run the full acli agent loop with a custom identity. @@ -54,6 +56,9 @@ def run( sdk_index: List of SDK index files loaded (e.g., ["python-sdk", "python-cli"]). tui: If set, override config.tui. None = use config value. + module: Scenario segment for the x-dashscope-sdk-client header + (acli//); empty keeps the default "app". + guide_url: Scenario doc link shown in the startup banner. """ from dashscope.acli.config import Config @@ -77,6 +82,10 @@ def run( config._embedded_system_prompt = system_prompt config._embedded_app_name = app_name config._embedded_prompt_symbol = prompt_symbol + if module: + config._embedded_module = module + if guide_url: + config._embedded_guide_url = guide_url if sdk_index: config._embedded_sdk_index = sdk_index if tui is not None: diff --git a/dashscope/acli/ui/tui.py b/dashscope/acli/ui/tui.py index 4ab806b..aa72783 100644 --- a/dashscope/acli/ui/tui.py +++ b/dashscope/acli/ui/tui.py @@ -1684,6 +1684,10 @@ def __init__( self._inline_input_future: threading.Event | None = None self._inline_input_value: list[str] = [""] self._inline_input_active: bool = False + # Set on teardown: wizards parked in executor threads get an empty + # answer (and future prompts fail fast) instead of blocking the + # default-executor join at interpreter shutdown. + self._inline_input_aborted: bool = False # Calm streaming (old JediTerm fallback): writes during streaming # do not follow-scroll self._calm_streaming: bool = False @@ -2055,6 +2059,13 @@ def _render_banner(self) -> None: f"[bold]Tools:[/bold] [dim]{tool_count} registered[/dim]", ) + # Scenario doc link (embedded mode only) + guide_url = getattr(self.config, "_embedded_guide_url", "") + if guide_url: + info_lines.append( + f"[bold]Guide:[/bold] [dim]{guide_url}[/dim]", + ) + info_lines.append( "\n[dim]Input: Enter to submit; Ctrl+J newline; " "Ctrl+C cancel/quit [/dim]", @@ -2115,24 +2126,25 @@ def on_mount(self) -> None: # Monkey-patch builtins.input and getpass.getpass so that blocking # handlers (e.g. /key, /dev xxx add, /setup, /update without args) - # can prompt via the TUI modal instead of hanging on stdin. + # can prompt via the TUI modal instead of hanging on stdin. Never + # reverted — see on_unmount. import builtins import getpass - self._original_input = builtins.input - self._original_getpass = getpass.getpass builtins.input = self._tui_input getpass.getpass = self._tui_getpass def on_unmount(self) -> None: - """Restore original input() and getpass() on exit.""" - import builtins - import getpass + """Wake any wizard parked on input() in an executor thread. - if hasattr(self, "_original_input"): - builtins.input = self._original_input - if hasattr(self, "_original_getpass"): - getpass.getpass = self._original_getpass + The input()/getpass() patch deliberately stays installed: restoring + the originals here races the unwinding wizard, whose next prompt + could land on the real input() and block on stdin, stranding the + default executor at interpreter shutdown. Post-exit calls hit the + aborted flag in _tui_input and fail fast with EOFError. + """ + self._inline_input_aborted = True + self._cancel_inline_input() async def _tui_confirm_callback( self, @@ -2257,12 +2269,27 @@ async def _prompt_confirm( # How long an inline input() prompt waits before giving up. _INLINE_INPUT_TIMEOUT = 300.0 + def _cancel_inline_input(self) -> None: + """Wake a thread parked in _tui_input with an empty answer. Every + wizard treats empty as keep-default/cancel, so the thread unwinds + through its remaining steps without blocking again. The future + object is left in place; the waiter clears it.""" + with self._inline_input_lock: + future = self._inline_input_future + if future is not None: + self._inline_input_value[0] = "" + self._inline_input_active = False + future.set() + def _tui_input(self, prompt: str = "", password: bool = False) -> str: """Thread-safe replacement for builtins.input() in TUI mode. Writes prompt inline to output and reads from command input box. Blocks the calling thread until input is received.""" - if self._loop is None: - return "" + if self._loop is None or self._inline_input_aborted: + # App is exiting: nothing can answer this prompt. EOFError + # mirrors a closed stdin — wizard handlers catch it as + # keep-default/cancel and unwind without blocking. + raise EOFError("input() called after TUI exit") with self._inline_input_lock: if self._inline_input_active: diff --git a/dashscope/cli/__init__.py b/dashscope/cli/__init__.py index 2e3725b..0da6246 100644 --- a/dashscope/cli/__init__.py +++ b/dashscope/cli/__init__.py @@ -291,6 +291,7 @@ def _maybe_offer_example_download(): 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( @@ -299,6 +300,9 @@ def _maybe_offer_example_download(): "(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): @@ -323,6 +327,7 @@ def _maybe_offer_example_download(): def _route_to_expert(command, tui=None): """Run the vendored acli agent (dashscope with no/unknown subcommand).""" 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( @@ -359,6 +364,8 @@ def _route_to_expert(command, tui=None): api_key=dashscope.api_key or None, command=command, tui=tui, + module="expert", + guide_url=_GUIDE_DOC.format(_doc_locale()), ) except SystemExit: pass @@ -417,12 +424,25 @@ def callback( app.add_typer(speech_synthesis.app) +# Records why the Agentic-RL Typer app could not be registered, so the reason +# can be reported when `rl` is actually invoked instead of on every command. +_RL_IMPORT_ERROR: Optional[Exception] = None + +_RL_COMMAND_NAMES = ("rl", "agentic-rl") + + def _register_rl_app(): """Lazily import and register the Agentic-RL Typer app. Wrapped in a function so that a missing optional dependency won't crash the entire CLI at import time. + + The failure is recorded rather than reported: `rl` is an optional + extra, so a user running any other command has no reason to be told + about it. ``main()`` surfaces it when the rl command is invoked. """ + global _RL_IMPORT_ERROR # pylint: disable=global-statement + try: from dashscope.cli.agentic_rl import app as rl_app @@ -437,23 +457,21 @@ def _register_rl_app(): help="🚀 Agentic RL fine-tuning commands", hidden=True, ) - except ImportError as exception: - # The message quotes a literal 'dashscope[rl]'; unescaped, rich reads - # [rl] as a style tag and prints an install command with no extras. - detail = escape(str(exception)) - if "pip install" not in detail: - detail += ( - ". Install the optional dependencies with: " - "[bold]pip install 'dashscope\\[rl]'[/bold]" - ) - err_console.print( - "[yellow]Warning:[/yellow] Failed to register rl command: " f"{detail}", - ) - except Exception as exception: - err_console.print( - "[yellow]Warning:[/yellow] Failed to register rl command: " - f"{escape(str(exception))}", + except Exception as exception: # pylint: disable=broad-except + _RL_IMPORT_ERROR = exception + + +def _rl_unavailable_detail(exception: Exception) -> str: + """Render a deferred rl import failure as an actionable message.""" + # The message quotes a literal 'dashscope[rl]'; unescaped, rich reads + # [rl] as a style tag and prints an install command with no extras. + detail = escape(str(exception)) + if "pip install" not in detail: + detail += ( + ". Install the optional dependencies with: " + "[bold]pip install 'dashscope\\[rl]'[/bold]" ) + return detail _register_rl_app() @@ -493,6 +511,20 @@ def main(): forced_tui = True argv = [a for a in argv if a != "--tui"] + # `rl` is an optional extra: report a missing dependency only when the + # user asks for that command, never on unrelated invocations. Without + # this gate the dispatch below reaches typer, which only knows that `rl` + # is in _TOP_LEVEL_COMMANDS but was never registered — "No such command" + # does not tell the user how to get it. + if ( + _RL_IMPORT_ERROR is not None + and _first_non_option(argv) in _RL_COMMAND_NAMES + ): + err_console.print( + f"[red]Error:[/red] {_rl_unavailable_detail(_RL_IMPORT_ERROR)}", + ) + sys.exit(1) + # Top-level --help / -h → show help and exit normally if "--help" in argv or "-h" in argv: argv = _translate_help_shortcut(argv) diff --git a/tests/unit/test_banner_guide.py b/tests/unit/test_banner_guide.py new file mode 100644 index 0000000..0e0447a --- /dev/null +++ b/tests/unit/test_banner_guide.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""The startup banner surfaces the embedded host's guide link.""" + +from dashscope.acli.cli.startup import _print_banner +from dashscope.acli.config import Config + +GUIDE = "https://help.aliyun.com/en/model-studio/dashscope-sdk-expert" + + +def test_banner_shows_guide_url(capsys): + config = Config() + config._embedded_guide_url = GUIDE + _print_banner(config) + out = capsys.readouterr().out + assert "Guide:" in out + assert GUIDE in out + + +def test_banner_omits_guide_url_by_default(capsys): + _print_banner(Config()) + assert "Guide:" not in capsys.readouterr().out + + +def test_embedded_run_stores_guide_url(monkeypatch): + import dashscope.acli.ui.embedded as embedded + + captured = {} + monkeypatch.setattr( + Config, + "load", + classmethod(lambda cls, **kw: Config()), + ) + + async def fake_oneshot(config, prompt, system_prompt): + captured["config"] = config + + monkeypatch.setattr(embedded, "_run_oneshot_embedded", fake_oneshot) + embedded.run(command="hi", guide_url=GUIDE) + assert captured["config"]._embedded_guide_url == GUIDE + + +def test_embedded_run_defaults_to_no_guide_url(monkeypatch): + import dashscope.acli.ui.embedded as embedded + + captured = {} + monkeypatch.setattr( + Config, + "load", + classmethod(lambda cls, **kw: Config()), + ) + + async def fake_oneshot(config, prompt, system_prompt): + captured["config"] = config + + monkeypatch.setattr(embedded, "_run_oneshot_embedded", fake_oneshot) + embedded.run(command="hi") + assert getattr(captured["config"], "_embedded_guide_url", "") == "" diff --git a/tests/unit/test_cli_main.py b/tests/unit/test_cli_main.py index 19b18f9..2160445 100644 --- a/tests/unit/test_cli_main.py +++ b/tests/unit/test_cli_main.py @@ -28,7 +28,7 @@ def strip_ansi_codes(text): class _ImportErrorOnApp: """Stand-in for a module whose ``from X import app`` raises ImportError. - Lets the rl-registration warning be exercised whether or not the + Lets the deferred rl import failure be exercised whether or not the optional dependencies happen to be installed in this environment. """ @@ -260,10 +260,14 @@ def test_agentic_rl_hidden_alias_help(self): assert result.exit_code == 0 assert "register_functions" in result.output - def test_rl_warning_keeps_the_extras_name(self, monkeypatch, capsys): - # rich reads a literal [rl] as a style tag and drops it, which used to - # print `pip install 'dashscope'` — a command that installs none of - # the extras the warning is about. + def test_rl_import_failure_is_silent_until_the_command_is_used( + self, + monkeypatch, + capsys, + ): + # `rl` is an optional extra, so importing dashscope.cli must not tell + # the user about a dependency they may never ask for. + monkeypatch.setattr(dashscope.cli, "_RL_IMPORT_ERROR", None) monkeypatch.setitem( sys.modules, "dashscope.cli.agentic_rl", @@ -275,27 +279,71 @@ def test_rl_warning_keeps_the_extras_name(self, monkeypatch, capsys): dashscope.cli._register_rl_app() + assert capsys.readouterr().err == "" + assert isinstance(dashscope.cli._RL_IMPORT_ERROR, ImportError) + + def test_rl_command_keeps_the_extras_name(self, monkeypatch, capsys): + # rich reads a literal [rl] as a style tag and drops it, which used to + # print `pip install 'dashscope'` — a command that installs none of + # the extras the message is about. + monkeypatch.setattr( + dashscope.cli, + "_RL_IMPORT_ERROR", + ImportError( + "Agentic RL fine-tuning needs optional dependencies. " + "Install them with: pip install 'dashscope[rl]'", + ), + ) + monkeypatch.setattr(sys, "argv", ["dashscope", "rl", "list"]) + + with pytest.raises(SystemExit) as exception_info: + cli_main() + + assert exception_info.value.code == 1 err = " ".join(capsys.readouterr().err.split()) assert "pip install 'dashscope[rl]'" in err assert err.count("Install") == 1 - def test_rl_warning_adds_a_hint_when_the_error_has_none( + def test_rl_command_adds_a_hint_when_the_error_has_none( self, monkeypatch, capsys, ): - monkeypatch.setitem( - sys.modules, - "dashscope.cli.agentic_rl", - _ImportErrorOnApp("No module named 'typer_x'"), + monkeypatch.setattr( + dashscope.cli, + "_RL_IMPORT_ERROR", + ImportError("No module named 'typer_x'"), ) + monkeypatch.setattr(sys, "argv", ["dashscope", "agentic-rl", "list"]) - dashscope.cli._register_rl_app() + with pytest.raises(SystemExit): + cli_main() err = " ".join(capsys.readouterr().err.split()) assert "No module named 'typer_x'" in err assert "pip install 'dashscope[rl]'" in err + def test_unrelated_command_is_not_told_about_missing_rl_extras( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr( + dashscope.cli, + "_RL_IMPORT_ERROR", + ImportError("pip install 'dashscope[rl]'"), + ) + monkeypatch.setattr(sys, "argv", ["dashscope"]) + monkeypatch.setattr( + dashscope.cli, + "_route_to_expert", + lambda *args, **kwargs: None, + ) + + cli_main() + + assert capsys.readouterr().err == "" + def test_subcommand_api_key_option_is_not_consumed_by_global_parser( self, monkeypatch, diff --git a/tests/unit/test_handlers_key_docs.py b/tests/unit/test_handlers_key_docs.py new file mode 100644 index 0000000..4e75fbe --- /dev/null +++ b/tests/unit/test_handlers_key_docs.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""The no-key startup prompt points DashScope users at the doc links.""" + +from types import SimpleNamespace + +import pytest + +import dashscope.acli.cli.handlers_key as handlers_key + + +@pytest.fixture +def no_key_env(monkeypatch): + """Provider with no resolvable key; user picks 'set up later'.""" + monkeypatch.setattr( + handlers_key, + "build_profiles_from_config", + lambda config: [SimpleNamespace(api_key="")], + ) + monkeypatch.setattr(handlers_key, "find_provider", lambda name: None) + monkeypatch.setattr( + handlers_key, + "all_key_targets", + lambda config=None: dict(handlers_key.KEY_TARGETS), + ) + monkeypatch.setattr("builtins.input", lambda prompt="": "3") + + +def _config(provider): + return SimpleNamespace(provider=provider) + + +def test_tongyi_prompt_shows_zh_links(no_key_env, monkeypatch, capsys): + monkeypatch.setenv("LANG", "zh_CN.UTF-8") + monkeypatch.delenv("LC_ALL", raising=False) + monkeypatch.delenv("LC_MESSAGES", raising=False) + assert handlers_key.ensure_provider_key(_config("tongyi"), None) + out = capsys.readouterr().out + assert "https://help.aliyun.com/zh/model-studio/get-api-key" in out + assert ( + "https://help.aliyun.com/zh/model-studio/dashscope-sdk-expert" in out + ) + + +def test_tongyi_prompt_shows_en_links(no_key_env, monkeypatch, capsys): + monkeypatch.setenv("LANG", "en_US.UTF-8") + monkeypatch.delenv("LC_ALL", raising=False) + monkeypatch.delenv("LC_MESSAGES", raising=False) + assert handlers_key.ensure_provider_key(_config("tongyi"), None) + out = capsys.readouterr().out + assert "https://help.aliyun.com/en/model-studio/get-api-key" in out + assert ( + "https://help.aliyun.com/en/model-studio/dashscope-sdk-expert" in out + ) + + +def test_non_dashscope_provider_shows_no_links( + no_key_env, monkeypatch, capsys +): + monkeypatch.setenv("LANG", "zh_CN.UTF-8") + assert handlers_key.ensure_provider_key(_config("openai"), None) + assert "help.aliyun.com" not in capsys.readouterr().out + + +def test_lc_all_wins_over_lang(no_key_env, monkeypatch): + monkeypatch.setenv("LANG", "en_US.UTF-8") + monkeypatch.setenv("LC_ALL", "zh_CN.UTF-8") + assert ( + handlers_key._doc_locale() == "zh" + ) # pylint: disable=protected-access diff --git a/tests/unit/test_sdk_headers.py b/tests/unit/test_sdk_headers.py index 607ab0d..1b570a6 100644 --- a/tests/unit/test_sdk_headers.py +++ b/tests/unit/test_sdk_headers.py @@ -128,6 +128,49 @@ def test_cli_import_marks_process(): print(f"\nCLI process -> {result.stdout.strip()}") +def _embedded_config(module): + from types import SimpleNamespace + + return SimpleNamespace( + provider="tongyi", + model="qwen3.8-max", + tongyi_api_key="sk-x", + base_url="", + protocol="openai", + timeout=60, + fallback_providers=[], + anthropic_api_key="", + openai_api_key="", + _embedded_module=module, + ) + + +def _provider_for(config, monkeypatch): + # pylint: disable=protected-access + monkeypatch.setattr( + "dashscope.acli.extensions.find_provider", + lambda *a, **k: None, + ) + from dashscope.acli.providers import _create_provider + from dashscope.acli.providers.profile import build_profiles_from_config + + profile = build_profiles_from_config(config)[0] + return _create_provider(profile) + + +def test_embedded_module_segment_flows_to_header(monkeypatch): + provider = _provider_for(_embedded_config("expert"), monkeypatch) + value = provider._get_headers()[CLIENT_HEADER] + print(f"\nembedded expert -> {value}") + _check_client_header(value, "acli", acli_version, "expert") + + +def test_embedded_module_defaults_to_app(monkeypatch): + provider = _provider_for(_embedded_config(""), monkeypatch) + value = provider._get_headers()[CLIENT_HEADER] + _check_client_header(value, "acli", acli_version, "app") + + # --------------------------------------------------------------------------- # module segment: full API coverage checks # --------------------------------------------------------------------------- diff --git a/tests/unit/test_tongyi_native.py b/tests/unit/test_tongyi_native.py new file mode 100644 index 0000000..d31a373 --- /dev/null +++ b/tests/unit/test_tongyi_native.py @@ -0,0 +1,590 @@ +# -*- coding: utf-8 -*- +"""TongyiProvider talks to the DashScope native generation routes. + +The provider POSTs {model, input.messages, parameters} with +result_format=message. Each model is bound to exactly one aigc service +path (text-generation vs multimodal-generation); the provider falls back +on the gateway's "url error" 400 and caches the working path. Responses +and SSE chunks are reshaped to the OpenAI shape the parsing logic (and +the anthropic adapter path) was written against. +""" + +import json + +import pytest + +from dashscope.acli.providers.tongyi import ( + _GENERATION_PATHS, + TongyiProvider, +) + +TEXT_GEN, MULTIMODAL_GEN = _GENERATION_PATHS +ROOT = "https://dashscope.aliyuncs.com" + +MESSAGES = [{"role": "user", "content": "hi"}] + +TOOLS = [ + { + "name": "get_weather", + "description": "Get weather", + "parameters": { + "type": "object", + "properties": {"city": {"type": "string"}}, + }, + }, +] + +NATIVE_RESPONSE = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": {"role": "assistant", "content": "hello there"}, + }, + ], + }, + "usage": {"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}, + "request_id": "req-native-1", +} + +# multimodal-generation returns content as a list of parts +MULTIMODAL_RESPONSE = { + "output": { + "choices": [ + { + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": [{"text": "hello"}, {"text": " there"}], + "reasoning_content": "thinking...", + }, + }, + ], + }, + "usage": { + "input_tokens": 62, + "output_tokens": 28, + "total_tokens": 90, + "prompt_tokens_details": {"cached_tokens": 5}, + }, + "request_id": "req-mm-1", +} + +URL_ERROR_BODY = { + "code": "InvalidParameter", + "message": "url error, please check url! For details, see: " + "https://help.aliyun.com/zh/model-studio/error-code#error-url", + "request_id": "req-400", +} + +NATIVE_TOOL_RESPONSE = { + "output": { + "choices": [ + { + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "hz"}', + }, + }, + ], + }, + }, + ], + }, + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + "request_id": "req-native-2", +} + +NATIVE_SSE_LINES = [ + "id:1", + "event:result", + ":HTTP_STATUS/200", + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "content": "Hel"}}]}, "usage": {"input_tokens": ' + '11, "output_tokens": 1, "total_tokens": 12}, "request_id": "r1"}', + "", + "id:2", + "event:result", + ":HTTP_STATUS/200", + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "content": "lo"}}]}, "usage": {"input_tokens": ' + '11, "output_tokens": 2, "total_tokens": 13}, "request_id": "r1"}', + 'data:{"output": {"choices": [{"finish_reason": "stop", "message": ' + '{"role": "assistant", "content": ""}}]}, "usage": {"input_tokens": 11,' + ' "output_tokens": 2, "total_tokens": 13}, "request_id": "r1"}', +] + +# multimodal-generation streams content as part lists +MULTIMODAL_SSE_LINES = [ + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "content": [], "reasoning_content": "th"}}]}, ' + '"request_id": "r5"}', + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "content": [{"text": "Hel"}]}}]}, ' + '"request_id": "r5"}', + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "content": [{"text": "lo"}]}}]}, ' + '"request_id": "r5"}', + 'data:{"output": {"choices": [{"finish_reason": "stop", "message": ' + '{"role": "assistant", "content": []}}]}, "usage": {"input_tokens": 5,' + ' "output_tokens": 4, "total_tokens": 9}, "request_id": "r5"}', +] + +NATIVE_SSE_TOOL_LINES = [ + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "content": "", "tool_calls": [{"index": 0, "id":' + ' "call_1", "type": "function", "function": {"name": "get_weather", ' + '"arguments": ""}}]}}]}, "request_id": "r2"}', + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "tool_calls": [{"index": 0, "function": ' + '{"arguments": "{\\"ci"}}]}}]}, "request_id": "r2"}', + 'data:{"output": {"choices": [{"finish_reason": "null", "message": ' + '{"role": "assistant", "tool_calls": [{"index": 0, "function": ' + '{"arguments": "ty\\": \\"hz\\"}"}}]}}]}, "request_id": "r2"}', + 'data:{"output": {"choices": [{"finish_reason": "tool_calls", ' + '"message": {"role": "assistant", "content": ""}}]}, "usage": ' + '{"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, ' + '"request_id": "r2"}', +] + + +class _FakeResponse: + def __init__(self, json_data, status_code=200): + self._json_data = json_data + self.status_code = status_code + self.text = ( + json_data + if isinstance(json_data, str) + else json.dumps(json_data, ensure_ascii=False) + ) + + def json(self): + if isinstance(self._json_data, str): + raise ValueError("not json") + return self._json_data + + +class _FakeStreamResponse: + def __init__(self, lines, status_code=200, body=""): + self._lines = lines + self.status_code = status_code + self._body = body + + async def aiter_lines(self): + for line in self._lines: + yield line + + async def aread(self): + return self._body.encode() + + +class _FakeStreamCtx: + def __init__(self, response): + self._response = response + + async def __aenter__(self): + return self._response + + async def __aexit__(self, *args): + return False + + +class _FakeClient: + """Stand-in for httpx.AsyncClient; queues canned responses.""" + + requests = [] + _post_queue = [] + _stream_queue = [] + + def __init__(self, timeout=None): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + @classmethod + def reset(cls): + cls.requests = [] + cls._post_queue = [] + cls._stream_queue = [] + + @classmethod + def enqueue_post(cls, json_data, status_code=200): + cls._post_queue.append(_FakeResponse(json_data, status_code)) + + @classmethod + def enqueue_stream(cls, lines, status_code=200, body=""): + cls._stream_queue.append(_FakeStreamResponse(lines, status_code, body)) + + async def post(self, url, json=None, headers=None): + type(self).requests.append( + {"url": url, "body": json, "headers": headers}, + ) + return type(self)._post_queue.pop(0) + + def stream(self, method, url, json=None, headers=None): + type(self).requests.append( + { + "method": method, + "url": url, + "body": json, + "headers": headers, + }, + ) + return _FakeStreamCtx(type(self)._stream_queue.pop(0)) + + +@pytest.fixture +def fake_http(monkeypatch): + import httpx + + _FakeClient.reset() + monkeypatch.setattr(httpx, "AsyncClient", _FakeClient) + return _FakeClient + + +def _provider(**kwargs): + kwargs.setdefault("model", "qwen-plus") + kwargs.setdefault("api_key", "sk-x") + return TongyiProvider(**kwargs) + + +# --------------------------------------------------------------------------- +# request shape +# --------------------------------------------------------------------------- + + +async def test_chat_posts_native_body(fake_http): + fake_http.enqueue_post(NATIVE_RESPONSE) + resp = await _provider().chat(MESSAGES, tools=TOOLS) + + cap = fake_http.requests[0] + assert cap["url"] == ROOT + TEXT_GEN + body = cap["body"] + assert body["model"] == "qwen-plus" + assert body["input"] == {"messages": MESSAGES} + assert "messages" not in body and "tools" not in body + params = body["parameters"] + assert params["result_format"] == "message" + assert "stream" not in params + tool = params["tools"][0] + assert tool["type"] == "function" + assert tool["function"]["name"] == "get_weather" + # OpenAI-shaped tools pass through untouched + assert resp.content == "hello there" + + +async def test_chat_stream_posts_native_body_with_sse_headers(fake_http): + fake_http.enqueue_stream(NATIVE_SSE_LINES) + provider = _provider() + chunks = [c async for c in provider.chat_stream(MESSAGES, tools=TOOLS)] + + cap = fake_http.requests[0] + assert cap["url"] == ROOT + TEXT_GEN + params = cap["body"]["parameters"] + assert params["result_format"] == "message" + assert params["stream"] is True + assert params["incremental_output"] is True + assert params["tools"][0]["function"]["name"] == "get_weather" + assert "stream_options" not in cap["body"] + headers = cap["headers"] + assert headers["X-DashScope-SSE"] == "enable" + assert headers["Accept"] == "text/event-stream" + assert headers["x-dashscope-sdk-client"].startswith("acli/") + assert chunks + + +def test_openai_content_parts_converted_to_native(): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,AAA"}, + }, + ], + }, + { + "role": "user", + "content": [ + {"type": "text", "text": "just "}, + {"type": "text", "text": "text"}, + ], + }, + ] + body = _provider()._build_request_body( # pylint: disable=protected-access + messages, + None, + ) + converted = body["input"]["messages"] + # image list → native parts (multimodal-generation rejects image_url) + assert converted[0]["content"] == [ + {"text": "what is this?"}, + {"image": "data:image/png;base64,AAA"}, + ] + # text-only list flattens to a string (text-generation needs strings) + assert converted[1]["content"] == "just text" + + +# --------------------------------------------------------------------------- +# base_url normalization +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "given,expected", + [ + (None, ROOT), + ("https://dashscope.aliyuncs.com", ROOT), + ("https://dashscope.aliyuncs.com/compatible-mode/v1", ROOT), + ("https://dashscope.aliyuncs.com/api/v1", ROOT), + ("https://dashscope.aliyuncs.com/", ROOT), + ("http://127.0.0.1:9000", "http://127.0.0.1:9000"), + ], +) +def test_base_url_normalized_to_service_root(given, expected): + assert _provider(base_url=given).base_url == expected + + +# --------------------------------------------------------------------------- +# endpoint fallback: text-generation ⇄ multimodal-generation +# --------------------------------------------------------------------------- + + +async def test_chat_falls_back_to_multimodal_path(fake_http): + fake_http.enqueue_post(URL_ERROR_BODY, status_code=400) + fake_http.enqueue_post(MULTIMODAL_RESPONSE) + provider = _provider(model="qwen3.8-max") + resp = await provider.chat(MESSAGES) + + assert [r["url"] for r in fake_http.requests] == [ + ROOT + TEXT_GEN, + ROOT + MULTIMODAL_GEN, + ] + assert resp.content == "hello there" + assert resp.reasoning_content == "thinking..." + assert resp.usage["cached_tokens"] == 5 + # working path is cached: the next call skips the 400 round-trip + fake_http.enqueue_post(NATIVE_RESPONSE) + await provider.chat(MESSAGES) + assert fake_http.requests[-1]["url"] == ROOT + MULTIMODAL_GEN + + +async def test_chat_no_fallback_on_other_400(fake_http): + fake_http.enqueue_post( + {"code": "InvalidParameter", "message": "bad temperature"}, + status_code=400, + ) + with pytest.raises(RuntimeError, match="bad temperature"): + await _provider().chat(MESSAGES) + assert len(fake_http.requests) == 1 + + +async def test_stream_falls_back_to_multimodal_path(fake_http): + fake_http.enqueue_stream( + [], + status_code=400, + body=json.dumps(URL_ERROR_BODY, ensure_ascii=False), + ) + fake_http.enqueue_stream(MULTIMODAL_SSE_LINES) + provider = _provider(model="qwen3.8-max") + chunks = [c async for c in provider.chat_stream(MESSAGES)] + + assert [r["url"] for r in fake_http.requests] == [ + ROOT + TEXT_GEN, + ROOT + MULTIMODAL_GEN, + ] + reasoning = [c.delta_reasoning_content for c in chunks] + assert "th" in reasoning + contents = [c.delta_content for c in chunks if c.delta_content] + assert contents == ["Hel", "lo"] + last = chunks[-1] + assert last.finish_reason == "stop" + assert last.usage["total_tokens"] == 9 + + +async def test_stream_falls_back_on_sse_error_event(fake_http): + """The url error also arrives as a 200 SSE error event (event:error), + not a 400 — the fallback must trigger before any chunk is yielded.""" + fake_http.enqueue_stream( + [ + "id:1", + "event:error", + ":HTTP_STATUS/400", + "data:" + json.dumps(URL_ERROR_BODY, ensure_ascii=False), + ], + ) + fake_http.enqueue_stream(MULTIMODAL_SSE_LINES) + provider = _provider(model="qwen3.8-max") + chunks = [c async for c in provider.chat_stream(MESSAGES)] + + assert [r["url"] for r in fake_http.requests] == [ + ROOT + TEXT_GEN, + ROOT + MULTIMODAL_GEN, + ] + assert [c.delta_content for c in chunks if c.delta_content] == [ + "Hel", + "lo", + ] + # the working path is cached only after a real chunk arrived + assert provider._generation_path == MULTIMODAL_GEN + + +async def test_stream_sse_url_error_raises_after_paths_exhausted(fake_http): + event = ["data:" + json.dumps(URL_ERROR_BODY, ensure_ascii=False)] + fake_http.enqueue_stream(list(event)) + fake_http.enqueue_stream(list(event)) + provider = _provider(model="qwen3.8-max") + with pytest.raises(RuntimeError, match="url error"): + [c async for c in provider.chat_stream(MESSAGES)] + assert len(fake_http.requests) == 2 + + +async def test_stream_no_fallback_after_content_started(fake_http): + lines = NATIVE_SSE_LINES[:4] + [ + "data:" + json.dumps(URL_ERROR_BODY, ensure_ascii=False), + ] + fake_http.enqueue_stream(lines) + provider = _provider(model="qwen3.8-max") + with pytest.raises(RuntimeError, match="url error"): + [c async for c in provider.chat_stream(MESSAGES)] + assert len(fake_http.requests) == 1 + + +# --------------------------------------------------------------------------- +# non-stream response parsing +# --------------------------------------------------------------------------- + + +async def test_chat_parses_native_response(fake_http): + fake_http.enqueue_post(NATIVE_RESPONSE) + resp = await _provider().chat(MESSAGES) + assert resp.content == "hello there" + assert resp.tool_calls == [] + assert resp.usage == { + "input_tokens": 11, + "output_tokens": 7, + "total_tokens": 18, + "cached_tokens": 0, + } + + +async def test_chat_parses_native_tool_calls(fake_http): + fake_http.enqueue_post(NATIVE_TOOL_RESPONSE) + resp = await _provider().chat(MESSAGES, tools=TOOLS) + assert len(resp.tool_calls) == 1 + call = resp.tool_calls[0] + assert call.id == "call_1" + assert call.name == "get_weather" + assert call.arguments == {"city": "hz"} + + +async def test_chat_raises_on_native_error_body(fake_http): + fake_http.enqueue_post( + {"code": "InvalidApiKey", "message": "bad key", "request_id": "r3"}, + ) + with pytest.raises(RuntimeError, match="InvalidApiKey"): + await _provider().chat(MESSAGES) + + +async def test_anthropic_protocol_over_native_route(fake_http): + fake_http.enqueue_post(NATIVE_TOOL_RESPONSE) + provider = _provider(protocol="anthropic") + resp = await provider.chat( + [{"role": "user", "content": "weather?"}], + tools=TOOLS, + ) + # request was converted anthropic->openai then wrapped natively + body = fake_http.requests[0]["body"] + assert body["input"]["messages"][0]["role"] == "user" + assert body["parameters"]["tools"][0]["function"]["name"] == "get_weather" + assert resp.tool_calls[0].name == "get_weather" + assert resp.tool_calls[0].arguments == {"city": "hz"} + + +# --------------------------------------------------------------------------- +# streaming +# --------------------------------------------------------------------------- + + +async def test_stream_parses_native_chunks(fake_http): + fake_http.enqueue_stream(NATIVE_SSE_LINES) + provider = _provider() + chunks = [c async for c in provider.chat_stream(MESSAGES)] + + contents = [c.delta_content for c in chunks if c.delta_content] + assert contents == ["Hel", "lo"] + last = chunks[-1] + assert last.finish_reason == "stop" + assert last.usage == { + "input_tokens": 11, + "output_tokens": 2, + "total_tokens": 13, + "cached_tokens": 0, + } + + +async def test_stream_accumulates_native_tool_calls(fake_http): + fake_http.enqueue_stream(NATIVE_SSE_TOOL_LINES) + provider = _provider() + chunks = [c async for c in provider.chat_stream(MESSAGES, tools=TOOLS)] + + tool_chunks = [c for c in chunks if c.tool_calls] + assert len(tool_chunks) == 1 + call = tool_chunks[0].tool_calls[0] + assert call.id == "call_1" + assert call.name == "get_weather" + assert call.arguments == {"city": "hz"} + assert tool_chunks[0].finish_reason == "tool_calls" + assert tool_chunks[0].usage["input_tokens"] == 5 + + +async def test_stream_raises_on_midstream_error(fake_http): + fake_http.enqueue_stream( + [ + NATIVE_SSE_LINES[3], + 'data:{"code": "Throttling.AllocationQuotaExceeded", "message": ' + '"slow down", "request_id": "r9"}', + ], + ) + provider = _provider() + with pytest.raises(RuntimeError, match="AllocationQuotaExceeded"): + async for _ in provider.chat_stream(MESSAGES): + pass + + +async def test_stream_flushes_orphan_tool_calls_on_truncation(fake_http): + # stream ends (connection closed) without a finish chunk + fake_http.enqueue_stream(NATIVE_SSE_TOOL_LINES[:3]) + provider = _provider() + chunks = [c async for c in provider.chat_stream(MESSAGES, tools=TOOLS)] + tool_chunks = [c for c in chunks if c.tool_calls] + assert len(tool_chunks) == 1 + assert tool_chunks[0].tool_calls[0].arguments == {"city": "hz"} + assert tool_chunks[0].finish_reason == "stop" + + +def test_request_body_is_json_serializable(): + body = _provider()._build_request_body( # pylint: disable=protected-access + MESSAGES, + TOOLS, + stream=True, + response_format={"type": "json_object"}, + ) + parsed = json.loads(json.dumps(body)) + assert parsed["parameters"]["response_format"] == { + "type": "json_object", + } From 83731a26ad62c6b50537bddd665eaa2d41261ef6 Mon Sep 17 00:00:00 2001 From: "zhansheng.lzs" Date: Thu, 10 Sep 2026 11:24:08 +0800 Subject: [PATCH 4/4] style: fix pre-commit failures (pylint, flake8, black) Same fixes as dev/issues-8, sourced from agenticCLI main: trailing-comma drift and the _print_no_key_prompt extraction in the synced wizards, pylint hygiene in the unit tests, and whitespace around == inside f-strings (E225 under Python 3.12's PEP 701 tokenizer). --- dashscope/acli/cli/handlers_key.py | 41 +++++++++++-------- dashscope/acli/cli/handlers_provider.py | 2 +- .../finetune/reinforcement/common/model.py | 4 +- .../finetune/reinforcement/common/utils.py | 2 +- tests/unit/test_banner_guide.py | 6 ++- tests/unit/test_cli_main.py | 4 +- tests/unit/test_handlers_key_docs.py | 12 +++--- tests/unit/test_sdk_headers.py | 4 ++ tests/unit/test_tongyi_native.py | 8 +++- 9 files changed, 51 insertions(+), 32 deletions(-) diff --git a/dashscope/acli/cli/handlers_key.py b/dashscope/acli/cli/handlers_key.py index f53493f..afc4491 100644 --- a/dashscope/acli/cli/handlers_key.py +++ b/dashscope/acli/cli/handlers_key.py @@ -78,6 +78,26 @@ def _prompt_input(prompt: str, secret: bool = False) -> str: return "" +def _print_no_key_prompt(config: Config, env_name: str) -> None: + """Present the missing-key notice, doc links, and setup menu.""" + console.print( + f"\n[yellow]No API Key detected for " f"{config.provider}[/yellow]", + ) + if config.provider.lower() == "tongyi": + lang = _doc_locale() + console.print( + f"[dim]Get an API Key: {_GET_API_KEY_DOC.format(lang)}[/dim]", + ) + console.print(f"[dim]Guide: {_GUIDE_DOC.format(lang)}[/dim]") + console.print("Choose how to set it up:") + if env_name: + console.print(f" [1] Set env var {env_name} (exit and set)") + else: + console.print(" [1] Set corresponding env var (exit and set)") + console.print(" [2] Enter API Key now") + console.print(" [3] Set up later with /provider after startup") + + def ensure_provider_key(config: Config, agent) -> bool: """If the active provider has no resolvable key, prompt the user. @@ -103,11 +123,11 @@ def ensure_provider_key(config: Config, agent) -> bool: console.print( f"\n[yellow]Configured provider '{config.provider}' is not " "available here (no built-in or loaded extension by that " - "name), so an API key alone will not make it work.[/yellow]" + "name), so an API key alone will not make it work.[/yellow]", ) console.print( "[dim]Starting anyway; run /provider to pick an available " - "provider.[/dim]" + "provider.[/dim]", ) return True @@ -116,22 +136,7 @@ def ensure_provider_key(config: Config, agent) -> bool: else: env_name = ext.api_key_env or "" - console.print( - f"\n[yellow]No API Key detected for " f"{config.provider}[/yellow]", - ) - if config.provider.lower() == "tongyi": - lang = _doc_locale() - console.print( - f"[dim]Get an API Key: {_GET_API_KEY_DOC.format(lang)}[/dim]", - ) - console.print(f"[dim]Guide: {_GUIDE_DOC.format(lang)}[/dim]") - console.print("Choose how to set it up:") - if env_name: - console.print(f" [1] Set env var {env_name} (exit and set)") - else: - console.print(" [1] Set corresponding env var (exit and set)") - console.print(" [2] Enter API Key now") - console.print(" [3] Set up later with /provider after startup") + _print_no_key_prompt(config, env_name) choice = input("\nChoose [1/2/3]: ").strip() if choice == "1": diff --git a/dashscope/acli/cli/handlers_provider.py b/dashscope/acli/cli/handlers_provider.py index c413b29..f52b23a 100644 --- a/dashscope/acli/cli/handlers_provider.py +++ b/dashscope/acli/cli/handlers_provider.py @@ -180,7 +180,7 @@ def _provider_wizard(agent: Agent, config: Config) -> bool: console.print( f"[yellow]Configured provider '{config.provider}' is not " "available here (no built-in or loaded extension by that " - "name), so Enter cannot keep it — pick one below.[/yellow]" + "name), so Enter cannot keep it — pick one below.[/yellow]", ) provider = _numbered_pick( "Available providers", diff --git a/dashscope/finetune/reinforcement/common/model.py b/dashscope/finetune/reinforcement/common/model.py index 8f4123b..7c50ee9 100644 --- a/dashscope/finetune/reinforcement/common/model.py +++ b/dashscope/finetune/reinforcement/common/model.py @@ -256,7 +256,7 @@ def to_yaml(self, file_path: str, overwrite: bool = True) -> None: logger.debug( f"The struct of Models class: " f"" - f"{model_dict if LOG_LEVEL=='DEBUG' else deep_mask(model_dict)}", # noqa: E501 + f"{model_dict if LOG_LEVEL == 'DEBUG' else deep_mask(model_dict)}", # noqa: E501 ) with open(path, "w", encoding="utf-8") as f: @@ -821,7 +821,7 @@ async def load( f"InstanceID: {self.instance_id}, " f"Endpoint: {self.instance_url}, " f"Response: " - f"{result if LOG_LEVEL=='DEBUG' else deep_mask(result)}", + f"{result if LOG_LEVEL == 'DEBUG' else deep_mask(result)}", ) except Exception as e: diff --git a/dashscope/finetune/reinforcement/common/utils.py b/dashscope/finetune/reinforcement/common/utils.py index ffadda3..f51c2b2 100644 --- a/dashscope/finetune/reinforcement/common/utils.py +++ b/dashscope/finetune/reinforcement/common/utils.py @@ -752,7 +752,7 @@ def set_api_key(api_key: Optional[str] = None) -> None: os.environ["DASHSCOPE_API_KEY"] = api_key logger.debug( f"Set environ DASHSCOPE_API_KEY: " - f"{api_key if LOG_LEVEL=='DEBUG' else deep_mask(api_key)}", + f"{api_key if LOG_LEVEL == 'DEBUG' else deep_mask(api_key)}", ) return diff --git a/tests/unit/test_banner_guide.py b/tests/unit/test_banner_guide.py index 0e0447a..38bab6a 100644 --- a/tests/unit/test_banner_guide.py +++ b/tests/unit/test_banner_guide.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- """The startup banner surfaces the embedded host's guide link.""" +# pylint: disable=protected-access,unused-argument + from dashscope.acli.cli.startup import _print_banner from dashscope.acli.config import Config @@ -22,7 +24,7 @@ def test_banner_omits_guide_url_by_default(capsys): def test_embedded_run_stores_guide_url(monkeypatch): - import dashscope.acli.ui.embedded as embedded + from dashscope.acli.ui import embedded captured = {} monkeypatch.setattr( @@ -40,7 +42,7 @@ async def fake_oneshot(config, prompt, system_prompt): def test_embedded_run_defaults_to_no_guide_url(monkeypatch): - import dashscope.acli.ui.embedded as embedded + from dashscope.acli.ui import embedded captured = {} monkeypatch.setattr( diff --git a/tests/unit/test_cli_main.py b/tests/unit/test_cli_main.py index 2160445..a7f04ad 100644 --- a/tests/unit/test_cli_main.py +++ b/tests/unit/test_cli_main.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +# pylint: disable=protected-access + import os import re import subprocess @@ -40,7 +42,7 @@ def app(self): raise ImportError(self._message) -# pylint: disable=too-many-public-methods +# pylint: disable=too-many-public-methods,protected-access class TestCliMain: def test_main_prints_authentication_error_without_traceback( self, diff --git a/tests/unit/test_handlers_key_docs.py b/tests/unit/test_handlers_key_docs.py index 4e75fbe..3ebf0bc 100644 --- a/tests/unit/test_handlers_key_docs.py +++ b/tests/unit/test_handlers_key_docs.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- """The no-key startup prompt points DashScope users at the doc links.""" +# pylint: disable=redefined-outer-name,unused-argument,protected-access + from types import SimpleNamespace import pytest -import dashscope.acli.cli.handlers_key as handlers_key +from dashscope.acli.cli import handlers_key @pytest.fixture @@ -54,7 +56,9 @@ def test_tongyi_prompt_shows_en_links(no_key_env, monkeypatch, capsys): def test_non_dashscope_provider_shows_no_links( - no_key_env, monkeypatch, capsys + no_key_env, + monkeypatch, + capsys, ): monkeypatch.setenv("LANG", "zh_CN.UTF-8") assert handlers_key.ensure_provider_key(_config("openai"), None) @@ -64,6 +68,4 @@ def test_non_dashscope_provider_shows_no_links( def test_lc_all_wins_over_lang(no_key_env, monkeypatch): monkeypatch.setenv("LANG", "en_US.UTF-8") monkeypatch.setenv("LC_ALL", "zh_CN.UTF-8") - assert ( - handlers_key._doc_locale() == "zh" - ) # pylint: disable=protected-access + assert handlers_key._doc_locale() == "zh" diff --git a/tests/unit/test_sdk_headers.py b/tests/unit/test_sdk_headers.py index 1b570a6..e23451d 100644 --- a/tests/unit/test_sdk_headers.py +++ b/tests/unit/test_sdk_headers.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +# pylint: disable=protected-access + import pytest from dashscope import __version__ as sdk_version @@ -159,6 +161,7 @@ def _provider_for(config, monkeypatch): def test_embedded_module_segment_flows_to_header(monkeypatch): + # pylint: disable=protected-access provider = _provider_for(_embedded_config("expert"), monkeypatch) value = provider._get_headers()[CLIENT_HEADER] print(f"\nembedded expert -> {value}") @@ -166,6 +169,7 @@ def test_embedded_module_segment_flows_to_header(monkeypatch): def test_embedded_module_defaults_to_app(monkeypatch): + # pylint: disable=protected-access provider = _provider_for(_embedded_config(""), monkeypatch) value = provider._get_headers()[CLIENT_HEADER] _check_client_header(value, "acli", acli_version, "app") diff --git a/tests/unit/test_tongyi_native.py b/tests/unit/test_tongyi_native.py index d31a373..2fc4670 100644 --- a/tests/unit/test_tongyi_native.py +++ b/tests/unit/test_tongyi_native.py @@ -9,6 +9,8 @@ the anthropic adapter path) was written against. """ +# pylint: disable=redefined-outer-name,protected-access + import json import pytest @@ -449,7 +451,8 @@ async def test_stream_sse_url_error_raises_after_paths_exhausted(fake_http): fake_http.enqueue_stream(list(event)) provider = _provider(model="qwen3.8-max") with pytest.raises(RuntimeError, match="url error"): - [c async for c in provider.chat_stream(MESSAGES)] + async for _ in provider.chat_stream(MESSAGES): + pass assert len(fake_http.requests) == 2 @@ -460,7 +463,8 @@ async def test_stream_no_fallback_after_content_started(fake_http): fake_http.enqueue_stream(lines) provider = _provider(model="qwen3.8-max") with pytest.raises(RuntimeError, match="url error"): - [c async for c in provider.chat_stream(MESSAGES)] + async for _ in provider.chat_stream(MESSAGES): + pass assert len(fake_http.requests) == 1