diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 341ced49..f9889ebc 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -9,13 +9,13 @@ jobs: fail-fast: true env: OS: ubuntu-latest - PYTHON: '3.8' + PYTHON: '3.12' steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.8' + python-version: '3.12' - name: Update setuptools and wheel run: | pip install setuptools==68.2.2 wheel==0.41.2 diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index e41be964..657b7e95 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest ] - python-version: [ '3.8' ] + python-version: [ '3.9', '3.12', '3.13' ] steps: diff --git a/README.md b/README.md index 9b6075fc..f00a2507 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 @@ -89,12 +89,94 @@ save_api_key(api_key='YOUR-DASHSCOPE-API-KEY', ``` +## Region and Endpoint Configuration + +By default the SDK sends requests to the China (Beijing) public endpoint `dashscope.aliyuncs.com`. If your Model Studio (Bailian) workspace lives in another region, switch the endpoint before making calls. + +### Using `set_region` + +`dashscope.set_region(region, workspace_id)` points the HTTP, WebSocket and OpenAI-compatible base URLs at the given region in a single call. `workspace_id` is required and is used as the endpoint subdomain. + +```python +import dashscope + +# Switch to the Singapore region for workspace "ws-xxx123" +dashscope.set_region(region="ap-southeast-1", workspace_id="ws-xxx123") + +# All subsequent calls use: +# https://ws-xxx123.ap-southeast-1.maas.aliyuncs.com/api/v1 +print(dashscope.base_http_api_url) +``` + +Supported regions: + +| Region | Location | +|--------|----------| +| `cn-beijing` | China (Beijing) | +| `cn-hongkong` | China (Hong Kong) | +| `ap-southeast-1` | Singapore | +| `ap-northeast-1` | Japan (Tokyo) | +| `eu-central-1` | Germany (Frankfurt) | +| `us-east-1` | US (Virginia) | + +> **API keys are region-specific.** Each region issues its own API keys (`sk-` prefix) in its Model Studio console, and keys cannot be mixed across regions — using a key from another region fails with `401`. Switch `api_key` together with the region. + +Region-specific notes: + +- WebSocket endpoints (`wss://.../api-ws/v1/inference`) are only served in `cn-beijing` and `ap-southeast-1`. `set_region` still sets `base_websocket_api_url` for every region, but WebSocket-based realtime APIs (realtime speech recognition/synthesis, multimodal dialog, etc.) are not available in the other regions. +- `eu-central-1` / `ap-northeast-1`: the deployment scope (Global, or EU / Japan) is chosen when the workspace is created in the console, not per API call. +- `us-east-1`: model names with the `-us` suffix (e.g. `qwen-plus-us`) restrict inference to the US; names without the suffix default to global inference. +- Batch inference, model fine-tuning and application development are currently only available in `cn-beijing` and `ap-southeast-1`. + +> `set_region` updates process-wide globals, so it is not concurrency-safe when a single process talks to multiple regions at the same time. Call it once at startup, or re-call it before each switch. + +### Using environment variables + +You can also select the region without code: + +```shell +export DASHSCOPE_API_REGION='ap-southeast-1' # default: cn-beijing +export DASHSCOPE_WORKSPACE_ID='ws-xxx123' # used to resolve the endpoint subdomain +``` + +When a MaaS region is set via `DASHSCOPE_API_REGION`, the SDK builds the regional endpoints and substitutes `DASHSCOPE_WORKSPACE_ID` into them. You can also override each base URL directly: + +| Environment variable | Overrides | +|----------------------|-----------| +| `DASHSCOPE_HTTP_BASE_URL` | HTTP endpoint (`dashscope.base_http_api_url`) | +| `DASHSCOPE_WEBSOCKET_BASE_URL` | WebSocket endpoint (`dashscope.base_websocket_api_url`) | +| `DASHSCOPE_COMPATIBLE_BASE_URL` | OpenAI-compatible endpoint (`dashscope.base_compatible_api_url`) | + +`set_region` always builds workspace-exclusive endpoints. Some regions also offer shared domains without a workspace subdomain — `dashscope.aliyuncs.com` (Beijing), `dashscope-intl.aliyuncs.com` (Singapore) and `dashscope-us.aliyuncs.com` (US Virginia); use the override variables above to point at them. + +### OpenAI-compatible chat completions + +The SDK exposes an OpenAI-compatible chat completions entry that talks to `dashscope.base_compatible_api_url` (request path `chat/completions`) — no extra `openai` package required. It follows the region configured above. + +```python +import dashscope +from dashscope.aigc.chat_completion import Completions + +dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") + +response = Completions.create( + model="qwen-max", + messages=[{"role": "user", "content": "Hello"}], + api_key="YOUR-DASHSCOPE-API-KEY", + stream=False, # set True to get a generator of ChatCompletionChunk +) +print(response) +``` + +A complete runnable example is available in [`samples/set_region_example.py`](samples/set_region_example.py). + ## AI Assistant: DashScope SDK Expert The SDK ships with an interactive AI assistant, **DashScope SDK Expert**, built on the bundled Agentic CLI (`dashscope/acli`) framework. For DashScope SDK/CLI users it is the recommended way to get development consultation and AI coding help — answering SDK/API questions, generating runnable examples, showing CLI usage, and diagnosing errors, right in your terminal. - 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 800753ad..6dd4a961 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 @@ -88,12 +88,94 @@ save_api_key(api_key='YOUR-DASHSCOPE-API-KEY', ``` +## 区域与端点配置 + +默认情况下,SDK 将请求发往华北2(北京)公共端点 `dashscope.aliyuncs.com`。如果你的百炼(Model Studio)业务空间位于其他区域,请在调用前先切换端点。 + +### 使用 `set_region` + +`dashscope.set_region(region, workspace_id)` 会一次性把 HTTP、WebSocket 和 OpenAI-compatible 三个 base URL 指向指定区域。`workspace_id` 为必填项,会作为端点的子域名。 + +```python +import dashscope + +# 切换到新加坡区域,业务空间为 "ws-xxx123" +dashscope.set_region(region="ap-southeast-1", workspace_id="ws-xxx123") + +# 之后所有调用都会使用: +# https://ws-xxx123.ap-southeast-1.maas.aliyuncs.com/api/v1 +print(dashscope.base_http_api_url) +``` + +支持的区域: + +| 区域 | 地理位置 | +|--------|----------| +| `cn-beijing` | 华北2(北京) | +| `cn-hongkong` | 中国(香港) | +| `ap-southeast-1` | 新加坡 | +| `ap-northeast-1` | 日本(东京) | +| `eu-central-1` | 德国(法兰克福) | +| `us-east-1` | 美国(弗吉尼亚) | + +> **各地域 API Key 相互独立。**每个地域的 API Key(`sk-` 前缀)需在对应地域的百炼控制台创建,不可跨地域混用——使用其他地域的 Key 会返回 `401`。切换地域时请同步更换 `api_key`。 + +地域特殊说明: + +- WebSocket 端点(`wss://.../api-ws/v1/inference`)目前仅 `cn-beijing` 与 `ap-southeast-1` 提供。`set_region` 对所有地域都会设置 `base_websocket_api_url`,但实时语音识别/合成、多模态对话等基于 WebSocket 的实时 API 在其他地域不可用。 +- `eu-central-1` / `ap-northeast-1`:部署范围(全球,或欧盟 / 日本)在控制台创建业务空间时选择,不在 API 调用层配置。 +- `us-east-1`:模型名带 `-us` 后缀(如 `qwen-plus-us`)限定美国境内推理;不带后缀默认全球推理。 +- 批量推理、模型调优、应用开发等高级功能目前仅 `cn-beijing` 与 `ap-southeast-1` 支持。 + +> `set_region` 修改的是进程级全局变量,因此在单进程同时访问多个区域时并非并发安全。建议在启动时调用一次,或在每次切换前重新调用。 + +### 使用环境变量 + +也可以不写代码,直接通过环境变量选择区域: + +```shell +export DASHSCOPE_API_REGION='ap-southeast-1' # 默认:cn-beijing +export DASHSCOPE_WORKSPACE_ID='ws-xxx123' # 用于解析端点子域名 +``` + +当通过 `DASHSCOPE_API_REGION` 设置了 MaaS 区域时,SDK 会构造对应的区域端点,并把 `DASHSCOPE_WORKSPACE_ID` 代入其中。你也可以直接覆盖每一个 base URL: + +| 环境变量 | 覆盖的对象 | +|----------------------|-----------| +| `DASHSCOPE_HTTP_BASE_URL` | HTTP 端点(`dashscope.base_http_api_url`) | +| `DASHSCOPE_WEBSOCKET_BASE_URL` | WebSocket 端点(`dashscope.base_websocket_api_url`) | +| `DASHSCOPE_COMPATIBLE_BASE_URL` | OpenAI-compatible 端点(`dashscope.base_compatible_api_url`) | + +`set_region` 构造的始终是业务空间专属域名。部分地域还提供不含 workspace 子域名的共享域名——北京 `dashscope.aliyuncs.com`、新加坡 `dashscope-intl.aliyuncs.com`、美国 `dashscope-us.aliyuncs.com`,如需使用可通过上面的环境变量直接覆盖。 + +### OpenAI-compatible 对话补全 + +SDK 提供了 OpenAI-compatible 的对话补全入口,它会请求 `dashscope.base_compatible_api_url`(请求路径 `chat/completions`)——无需额外安装 `openai` 库,并自动跟随上面配置的区域。 + +```python +import dashscope +from dashscope.aigc.chat_completion import Completions + +dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") + +response = Completions.create( + model="qwen-max", + messages=[{"role": "user", "content": "你好"}], + api_key="YOUR-DASHSCOPE-API-KEY", + stream=False, # 设为 True 则返回 ChatCompletionChunk 生成器 +) +print(response) +``` + +完整可运行示例见 [`samples/set_region_example.py`](samples/set_region_example.py)。 + ## AI 助手:DashScope SDK Expert SDK 内置了交互式 AI 助手 **DashScope SDK Expert**,基于随包提供的 Agentic CLI(`dashscope/acli`)框架构建。对于 DashScope SDK/CLI 用户,它是获取开发咨询和 AI 编码帮助的推荐方式——直接在终端中解答 SDK/API 问题、生成可运行示例、展示 CLI 用法、诊断错误。 - 直接运行 `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/__init__.py b/dashscope/__init__.py index ecb297b6..7d4284b9 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -34,6 +34,7 @@ from dashscope.api_entities.http_request import close_shared_sync_session from dashscope.common.api_key import save_api_key from dashscope.common.env import ( + MAAS_REGIONS, api_key, api_key_file_path, base_compatible_api_url, @@ -81,6 +82,48 @@ list_tokenizers, ) + +# cn-beijing defaults to the legacy dashscope.aliyuncs.com endpoints; +# its MaaS URLs are only reachable via an explicit set_region() call. +_MAAS_REGIONS = {*MAAS_REGIONS, "cn-beijing"} + + +def set_region(region: str, workspace_id: str = None): + """Switch to a specific MaaS region. + + Updates base_http_api_url, base_compatible_api_url and + base_websocket_api_url to point to the MaaS endpoint for the + given region and workspace. + + Args: + region (str): The MaaS region, e.g. "ap-southeast-1", + "us-east-1", "cn-hongkong", "cn-beijing", "eu-central-1", + "ap-northeast-1". + workspace_id (str): The workspace ID, used as the subdomain + of the MaaS endpoint. + + Raises: + ValueError: If region is not supported or workspace_id is + empty. + """ + if region not in _MAAS_REGIONS: + raise ValueError( + f"Unsupported region '{region}'. " + f"Supported regions: {sorted(_MAAS_REGIONS)}", + ) + + if not workspace_id: + raise ValueError("workspace_id is required") + + global base_http_api_url, base_compatible_api_url + global base_websocket_api_url + + host = f"{workspace_id}.{region}.maas.aliyuncs.com" + base_http_api_url = f"https://{host}/api/v1" + base_compatible_api_url = f"https://{host}/compatible-mode/v1" + base_websocket_api_url = f"wss://{host}/api-ws/v1/inference" + + __all__ = [ "__version__", "base_compatible_api_url", @@ -141,6 +184,7 @@ "MessageFile", "AssistantFile", "VideoSynthesis", + "set_region", ] logging.getLogger(__name__).addHandler(NullHandler()) diff --git a/dashscope/__init__.pyi b/dashscope/__init__.pyi index 90df0ab3..8af2cc03 100644 --- a/dashscope/__init__.pyi +++ b/dashscope/__init__.pyi @@ -23,6 +23,7 @@ base_websocket_api_url: str def save_api_key(api_key: str, file_path: Optional[str] = ...) -> None: ... def close_shared_aio_session() -> None: ... def close_shared_sync_session() -> None: ... +def set_region(region: str, workspace_id: Optional[str] = ...) -> None: ... # --------------------------------------------------------------------------- # Response types diff --git a/dashscope/acli/__init__.py b/dashscope/acli/__init__.py index d8c0b174..29cd9e03 100644 --- a/dashscope/acli/__init__.py +++ b/dashscope/acli/__init__.py @@ -3,7 +3,7 @@ import uuid -__version__ = "0.6.4" +__version__ = "0.6.5" # Per-process identifier sent as x-dashscope-sdk-session-id so the # backend can group multi-turn requests from one CLI run. diff --git a/dashscope/acli/agents/subagents.py b/dashscope/acli/agents/subagents.py index ad16d567..01afaf68 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 e3754263..bcd66335 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 829abf5d..afc4491b 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. @@ -63,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. @@ -81,23 +116,27 @@ 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]", - ) - 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": @@ -219,7 +258,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 c91118e9..f52b23af 100644 --- a/dashscope/acli/cli/handlers_provider.py +++ b/dashscope/acli/cli/handlers_provider.py @@ -168,11 +168,32 @@ 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, + 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 if provider not in names: console.print(f"[red]Unknown provider: {provider}; cancelled[/red]") return True diff --git a/dashscope/acli/cli/startup.py b/dashscope/acli/cli/startup.py index 760bf293..6989517a 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 0e5e56c8..72c2c512 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 ff4cd273..d0d737b2 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 9592d287..9e53f3ba 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 c04445a7..c96402cd 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 f8c1e892..c6040deb 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 8c3b72b5..7e7bb34b 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 7759d391..d4881d57 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 2705f23e..837a8c2d 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 ad5c1b13..e91355b4 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 83a4e214..a60886df 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 f1806f61..c3fefcef 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 dffff67d..2af4d6bd 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 d6c855d5..29202e42 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 faec2495..ea14e0c6 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 4ab806b7..aa72783a 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/api_entities/api_request_factory.py b/dashscope/api_entities/api_request_factory.py index c1d26493..df1030d4 100644 --- a/dashscope/api_entities/api_request_factory.py +++ b/dashscope/api_entities/api_request_factory.py @@ -19,6 +19,7 @@ from dashscope.common.error import InputDataRequired, UnsupportedApiProtocol from dashscope.common.logging import logger from dashscope.common.utils import get_sdk_headers +from dashscope.common.env import resolve_base_url from dashscope.protocol.websocket import WebsocketStreamingMode @@ -132,9 +133,17 @@ def _build_api_request( # pylint: disable=too-many-branches encryption = None + # Resolve {workspace_id} placeholder for MaaS international regions + workspace = kwargs.pop("workspace", None) + if base_address is not None: + base_address = resolve_base_url(base_address, workspace) + if api_protocol in [ApiProtocol.HTTP, ApiProtocol.HTTPS]: if base_address is None: - base_address = dashscope.base_http_api_url + base_address = resolve_base_url( + dashscope.base_http_api_url, + workspace, + ) if not base_address.endswith("/"): http_url = base_address + "/" else: @@ -176,7 +185,10 @@ def _build_api_request( # pylint: disable=too-many-branches if base_address is not None: websocket_url = base_address else: - websocket_url = dashscope.base_websocket_api_url + websocket_url = resolve_base_url( + dashscope.base_websocket_api_url, + workspace, + ) request = WebSocketRequest( url=websocket_url, api_key=api_key, diff --git a/dashscope/api_entities/encryption.py b/dashscope/api_entities/encryption.py index 4848aa1c..6fafd996 100644 --- a/dashscope/api_entities/encryption.py +++ b/dashscope/api_entities/encryption.py @@ -18,6 +18,7 @@ DEFAULT_REQUEST_TIMEOUT_SECONDS, ) from dashscope.common.logging import logger +from dashscope.common.env import resolve_base_url from dashscope.common.utils import get_sdk_headers @@ -88,7 +89,10 @@ def get_base64_iv_str(self): @staticmethod def _get_public_keys(): - url = dashscope.base_http_api_url + "/public-keys/latest" + url = ( + resolve_base_url(dashscope.base_http_api_url) + + "/public-keys/latest" + ) headers = { "Authorization": f"Bearer {dashscope.api_key}", **get_sdk_headers(module="utils"), diff --git a/dashscope/audio/http_tts/http_speech_synthesizer.py b/dashscope/audio/http_tts/http_speech_synthesizer.py index fdf3b283..b2e3e84e 100644 --- a/dashscope/audio/http_tts/http_speech_synthesizer.py +++ b/dashscope/audio/http_tts/http_speech_synthesizer.py @@ -203,12 +203,12 @@ def _http_call( # Get base URL import dashscope + from dashscope.common.env import resolve_base_url from dashscope.common.utils import get_sdk_headers, join_url - if url: - base_url = url - else: - base_url = dashscope.base_http_api_url + if not url: + url = dashscope.base_http_api_url + base_url = resolve_base_url(url, workspace) url_for_call = join_url( base_url, "services/audio/tts/SpeechSynthesizer", diff --git a/dashscope/audio/tts_v2/speech_synthesizer.py b/dashscope/audio/tts_v2/speech_synthesizer.py index ab4ac343..eea32e3f 100644 --- a/dashscope/audio/tts_v2/speech_synthesizer.py +++ b/dashscope/audio/tts_v2/speech_synthesizer.py @@ -15,6 +15,7 @@ import dashscope from dashscope.common.constants import WEBSOCKET_ERROR_CODE +from dashscope.common.env import resolve_base_url from dashscope.common.error import ( InputRequired, InvalidTask, @@ -491,7 +492,7 @@ def __update_params( # pylint: disable=redefined-builtin raise InputRequired("format is required!") if url is None: url = dashscope.base_websocket_api_url - self.url = url + self.url = resolve_base_url(url, workspace) self.apikey = dashscope.api_key if self.apikey is None: raise InputRequired("apikey is required!") diff --git a/dashscope/cli/__init__.py b/dashscope/cli/__init__.py index 151f120c..0da62465 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 @@ -290,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( @@ -298,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): @@ -322,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( @@ -358,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 @@ -416,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 @@ -436,18 +457,21 @@ def _register_rl_app(): help="🚀 Agentic RL fine-tuning commands", hidden=True, ) - except ImportError as exception: - err_console.print( - "[yellow]Warning:[/yellow] Failed to register rl command: " - f"{exception}. " - "Install the optional dependencies with: " - "[bold]pip install 'dashscope[rl]'[/bold]", - ) - except Exception as exception: - err_console.print( - "[yellow]Warning:[/yellow] Failed to register rl command: " - f"{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() @@ -487,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/dashscope/client/base_api.py b/dashscope/client/base_api.py index 5ed14742..8ae18173 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -23,6 +23,7 @@ ) from dashscope.common.error import InvalidParameter, InvalidTask, ModelRequired from dashscope.common.logging import logger +from dashscope.common.env import resolve_base_url from dashscope.common.utils import ( _handle_http_failed_response, _handle_http_response, @@ -43,7 +44,12 @@ async def _get( **kwargs, ) -> DashScopeAPIResponse: base_url = kwargs.pop("base_address", None) - url = _normalization_url(base_url, "tasks", task_id) + url = _normalization_url( + base_url, + "tasks", + task_id, + workspace=workspace, + ) kwargs = cls._handle_kwargs(api_key, workspace, **kwargs) kwargs["base_address"] = url if not api_key: @@ -134,6 +140,7 @@ async def async_call( task=task, function=function, api_key=api_key, + workspace=workspace, sdk_module=get_api_module(cls.__module__), **kwargs, ) @@ -299,7 +306,13 @@ async def cancel( """ task_id = cls._get_task_id(task) base_url = kwargs.pop("base_address", None) - url = _normalization_url(base_url, "tasks", task_id, "cancel") + url = _normalization_url( + base_url, + "tasks", + task_id, + "cancel", + workspace=workspace, + ) kwargs = cls._handle_kwargs(api_key, workspace, **kwargs) kwargs["base_address"] = url kwargs["http_method"] = HTTPMethod.POST @@ -357,7 +370,7 @@ async def list( import aiohttp # pylint: disable=import-outside-toplevel base_url = kwargs.pop("base_address", None) - url = _normalization_url(base_url, "tasks") + url = _normalization_url(base_url, "tasks", workspace=workspace) params = {"page_no": page_no, "page_size": page_size} if start_time is not None: params["start_time"] = start_time @@ -488,6 +501,7 @@ async def call( task=task, function=function, api_key=api_key, + workspace=workspace, sdk_module=get_api_module(cls.__module__), **kwargs, ) @@ -555,6 +569,7 @@ def call( task=task, function=function, api_key=api_key, + workspace=workspace, sdk_module=get_api_module(cls.__module__), **kwargs, ) @@ -570,11 +585,11 @@ def _workspace_header(workspace) -> Dict: return headers -def _normalization_url(base_address, *args): +def _normalization_url(base_address, *args, workspace=None): if base_address: - url = base_address + url = resolve_base_url(base_address, workspace) else: - url = dashscope.base_http_api_url + url = resolve_base_url(dashscope.base_http_api_url, workspace) return join_url(url, *args) @@ -588,7 +603,12 @@ def _get( **kwargs, ) -> DashScopeAPIResponse: base_url = kwargs.pop("base_address", None) - status_url = _normalization_url(base_url, "tasks", task_id) + status_url = _normalization_url( + base_url, + "tasks", + task_id, + workspace=workspace, + ) custom_headers = kwargs.pop("headers", None) headers = { **_workspace_header(workspace), @@ -685,7 +705,13 @@ def cancel( """ task_id = cls._get_task_id(task) base_url = kwargs.pop("base_address", None) - url = _normalization_url(base_url, "tasks", task_id, "cancel") + url = _normalization_url( + base_url, + "tasks", + task_id, + "cancel", + workspace=workspace, + ) with requests.Session() as session: response = session.post( url, @@ -734,7 +760,7 @@ def list( DashScopeAPIResponse: The response data. """ base_url = kwargs.pop("base_address", None) - url = _normalization_url(base_url, "tasks") + url = _normalization_url(base_url, "tasks", workspace=workspace) params = {"page_no": page_no, "page_size": page_size} if start_time is not None: params["start_time"] = start_time @@ -936,6 +962,7 @@ def async_call( task=task, function=function, api_key=api_key, + workspace=workspace, async_request=True, query=False, sdk_module=get_api_module(cls.__module__), @@ -958,6 +985,7 @@ def _get( REQUEST_TIMEOUT_KEYWORD, DEFAULT_REQUEST_TIMEOUT_SECONDS, ) + url = resolve_base_url(url, workspace) with requests.Session() as session: logger.debug("Starting request: %s", url) response = session.get( @@ -974,11 +1002,11 @@ def _get( return _handle_http_response(response, flattened_output) -def _get_url(custom_base_url, default_path, path): +def _get_url(custom_base_url, default_path, path, workspace=None): if not custom_base_url: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) else: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) if path is not None: url = join_url(base_url, path) else: @@ -1014,7 +1042,12 @@ def list( Any: The object list. """ custom_base_url = kwargs.pop("base_address", None) - url = _get_url(custom_base_url, cls.SUB_PATH.lower(), path) + url = _get_url( + custom_base_url, + cls.SUB_PATH.lower(), + path, + workspace=workspace, + ) params = {} if limit is not None: if limit < 0: @@ -1060,7 +1093,12 @@ def list( DashScopeAPIResponse: The object list in output. """ custom_base_url = kwargs.pop("base_address", None) - url = _get_url(custom_base_url, cls.SUB_PATH.lower(), path) + url = _get_url( + custom_base_url, + cls.SUB_PATH.lower(), + path, + workspace=workspace, + ) params = {"page_no": page_no, "page_size": page_size} return _get( url, @@ -1100,6 +1138,7 @@ def logs( # pylint: disable=unused-argument custom_base_url, join_url(cls.SUB_PATH.lower(), job_id, "logs"), path, + workspace=workspace, ) params = {"offset": offset, "line": line} return _get( @@ -1136,9 +1175,9 @@ def get( """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) if path is not None: url = join_url(base_url, path) @@ -1178,9 +1217,9 @@ def get( """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) if path is not None: url = join_url(base_url, path) else: @@ -1219,9 +1258,9 @@ def delete( """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) if path is not None: url = join_url(base_url, path) else: @@ -1273,6 +1312,7 @@ def call( kwargs.pop("base_address", None), cls.SUB_PATH.lower(), path, + workspace=workspace, ) timeout = kwargs.pop( REQUEST_TIMEOUT_KEYWORD, @@ -1335,9 +1375,9 @@ def update( """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) if path is not None: url = join_url(base_url, path) else: @@ -1405,9 +1445,9 @@ def put( """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) if path is None: url = join_url(base_url, cls.SUB_PATH.lower(), target) else: @@ -1464,9 +1504,9 @@ def upload( # pylint: disable=unused-argument """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) url = join_url(base_url, cls.SUB_PATH.lower()) js = None if descriptions: @@ -1517,9 +1557,9 @@ def cancel( """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) if not path: url = join_url(base_url, cls.SUB_PATH.lower(), target, "cancel") else: @@ -1631,9 +1671,9 @@ def stream_events( """ custom_base_url = kwargs.pop("base_address", None) if custom_base_url: - base_url = custom_base_url + base_url = resolve_base_url(custom_base_url, workspace) else: - base_url = dashscope.base_http_api_url + base_url = resolve_base_url(dashscope.base_http_api_url, workspace) url = join_url(base_url, cls.SUB_PATH.lower(), target, "stream") timeout = kwargs.pop( REQUEST_TIMEOUT_KEYWORD, diff --git a/dashscope/common/env.py b/dashscope/common/env.py index bf9a0f97..c5863cc6 100644 --- a/dashscope/common/env.py +++ b/dashscope/common/env.py @@ -9,6 +9,20 @@ DASHSCOPE_API_REGION_ENV, DASHSCOPE_API_VERSION_ENV, ) +from dashscope.common.error import InputRequired + +# MaaS regions: region -> URL subdomain identifier +# cn-beijing is deliberately excluded: it is the default +# DASHSCOPE_API_REGION and must keep using the legacy +# dashscope.aliyuncs.com endpoints. cn-beijing MaaS URLs are only +# reachable via an explicit dashscope.set_region() call. +MAAS_REGIONS = { + "ap-southeast-1": "ap-southeast-1", + "us-east-1": "us-east-1", + "cn-hongkong": "cn-hongkong", + "eu-central-1": "eu-central-1", + "ap-northeast-1": "ap-northeast-1", +} api_region = os.environ.get(DASHSCOPE_API_REGION_ENV, "cn-beijing") api_version = os.environ.get(DASHSCOPE_API_VERSION_ENV, "v1") @@ -16,16 +30,63 @@ api_key = os.environ.get(DASHSCOPE_API_KEY_ENV) api_key_file_path = os.environ.get(DASHSCOPE_API_KEY_FILE_PATH_ENV) +# Optional default workspace id from environment (used to resolve MaaS URLs) +_default_workspace_id = os.environ.get("DASHSCOPE_WORKSPACE_ID") + # define api base url, ensure end / -base_http_api_url = os.environ.get( - "DASHSCOPE_HTTP_BASE_URL", - f"https://dashscope.aliyuncs.com/api/{api_version}", -) -base_websocket_api_url = os.environ.get( - "DASHSCOPE_WEBSOCKET_BASE_URL", - f"wss://dashscope.aliyuncs.com/api-ws/{api_version}/inference", -) -base_compatible_api_url = os.environ.get( - "DASHSCOPE_COMPATIBLE_BASE_URL", - f"https://dashscope.aliyuncs.com/compatible-mode/{api_version}", -) +if api_region in MAAS_REGIONS: + _maas_region_id = MAAS_REGIONS[api_region] + _ws = _default_workspace_id if _default_workspace_id else "{workspace_id}" + _maas_host = f"{_ws}.{_maas_region_id}.maas.aliyuncs.com" + base_http_api_url = os.environ.get( + "DASHSCOPE_HTTP_BASE_URL", + f"https://{_maas_host}/api/{api_version}", + ) + base_websocket_api_url = os.environ.get( + "DASHSCOPE_WEBSOCKET_BASE_URL", + f"wss://{_maas_host}/api-ws/{api_version}/inference", + ) + base_compatible_api_url = os.environ.get( + "DASHSCOPE_COMPATIBLE_BASE_URL", + f"https://{_maas_host}/compatible-mode/{api_version}", + ) +else: + base_http_api_url = os.environ.get( + "DASHSCOPE_HTTP_BASE_URL", + f"https://dashscope.aliyuncs.com/api/{api_version}", + ) + base_websocket_api_url = os.environ.get( + "DASHSCOPE_WEBSOCKET_BASE_URL", + f"wss://dashscope.aliyuncs.com/api-ws/{api_version}/inference", + ) + base_compatible_api_url = os.environ.get( + "DASHSCOPE_COMPATIBLE_BASE_URL", + f"https://dashscope.aliyuncs.com/compatible-mode/{api_version}", + ) + + +def resolve_base_url(url, workspace_id=None): + """Resolve {workspace_id} placeholder in base URL. + + Args: + url: The base URL, possibly containing {workspace_id}. + workspace_id: The workspace id to substitute. + + Returns: + The resolved URL string. + + Raises: + InputRequired: If the URL contains {workspace_id} but no + workspace id is available. + """ + if "{workspace_id}" not in url: + return url + ws = workspace_id or _default_workspace_id + if ws: + return url.replace("{workspace_id}", ws) + raise InputRequired( + "The base URL contains '{workspace_id}' but no workspace id " + "is available: pass workspace to the API call, set the " + "DASHSCOPE_WORKSPACE_ID environment variable, or call " + "dashscope.set_region(region, workspace_id).", + ) diff --git a/dashscope/finetune/reinforcement/common/model.py b/dashscope/finetune/reinforcement/common/model.py index 8f4123bf..7c50ee9a 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 ffadda37..f51c2b21 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/dashscope/multimodal/multimodal_dialog.py b/dashscope/multimodal/multimodal_dialog.py index 3ba7ad5e..63a04f84 100644 --- a/dashscope/multimodal/multimodal_dialog.py +++ b/dashscope/multimodal/multimodal_dialog.py @@ -7,6 +7,7 @@ import websocket import dashscope +from dashscope.common.env import resolve_base_url from dashscope.common.logging import logger from dashscope.common.error import InputRequired from dashscope.common.utils import get_sdk_headers, get_user_agent @@ -166,6 +167,7 @@ def __init__( raise InputRequired("request_params is required!") if url is None: url = dashscope.base_websocket_api_url + url = resolve_base_url(url, workspace_id) if api_key is None: api_key = dashscope.api_key diff --git a/dashscope/multimodal/tingwu/tingwu_realtime.py b/dashscope/multimodal/tingwu/tingwu_realtime.py index a9f77198..899c09e9 100644 --- a/dashscope/multimodal/tingwu/tingwu_realtime.py +++ b/dashscope/multimodal/tingwu/tingwu_realtime.py @@ -10,6 +10,7 @@ import dashscope from dashscope.client.base_api import BaseApi from dashscope.common.error import InvalidParameter, ModelRequired +from dashscope.common.env import resolve_base_url import websocket # pylint: disable=wrong-import-order # pylint: disable=ungrouped-imports @@ -96,9 +97,8 @@ def __init__( else: self.api_key = api_key # type: ignore[has-type] if base_address is None: - self.base_address = dashscope.base_websocket_api_url - else: - self.base_address = base_address # type: ignore[has-type] + base_address = dashscope.base_websocket_api_url + self.base_address = resolve_base_url(base_address, workspace) if model is None: raise ModelRequired("Model is required!") diff --git a/dashscope/version.py b/dashscope/version.py index dbdebdc7..e8204516 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "1.27.4" +__version__ = "1.27.5" diff --git a/requirements.txt b/requirements.txt index d4c9d4cc..9230e9d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,6 @@ cryptography certifi typer>=0.9.0 rich>=13.0.0 -httpx>=0.25.0 +httpx>=0.27.0 httpx-sse>=0.4.0 typing_extensions>=4.0 diff --git a/samples/set_region_example.py b/samples/set_region_example.py new file mode 100644 index 00000000..07c13608 --- /dev/null +++ b/samples/set_region_example.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Example demonstrating how to use set_region() to switch MaaS regions. + +This example shows how to configure the SDK to use different regional +endpoints for MaaS (Model as a Service). + +Note: API keys are region-specific. Each region requires its own key +created in that region's Model Studio console; using a key from another +region fails with 401. +""" + +import dashscope +from dashscope.aigc.chat_completion import Completions +from dashscope.aigc.generation import Generation + + +def example_basic_usage(): + """Basic usage: switch to a specific region.""" + # Switch to Singapore region + dashscope.set_region(region="ap-southeast-1", workspace_id="ws-xxx123") + + # Now all API calls will use the Singapore endpoint + print(f"HTTP API URL: {dashscope.base_http_api_url}") + print(f"Compatible API URL: {dashscope.base_compatible_api_url}") + + +def example_generation_call(): + """Example: Use Generation.call with a specific region.""" + # Switch to US East region + dashscope.set_region(region="us-east-1", workspace_id="ws-us-456") + + # Make a generation call + # This will use: https://ws-us-456.us-east-1.maas.aliyuncs.com/api/v1 + response = Generation.call( + model="qwen-max", + messages=[{"role": "user", "content": "Hello, world!"}], + api_key="your-api-key", # must be created in this region + ) + print(response) + + +def example_chat_completion(): + """Example: Use Completions.create (OpenAI-compatible) with a region.""" + # Switch to Hong Kong region + dashscope.set_region(region="cn-hongkong", workspace_id="ws-hk-789") + + # Make a chat completion call + # This will use: https://ws-hk-789.cn-hongkong.maas.aliyuncs.com/compatible-mode/v1 + response = Completions.create( + model="qwen-max", + messages=[{"role": "user", "content": "你好,世界!"}], + api_key="your-api-key", # must be created in this region + stream=False, + ) + print(response) + + +def example_streaming(): + """Example: Streaming response with a specific region.""" + # Switch to Europe region + dashscope.set_region(region="eu-central-1", workspace_id="ws-eu-001") + + # Stream the response + responses = Completions.create( + model="qwen-max", + messages=[{"role": "user", "content": "讲一个故事"}], + api_key="your-api-key", + stream=True, + ) + + for chunk in responses: + if chunk.choices: + print(chunk.choices[0].delta.content, end="", flush=True) + print() + + +def example_multiple_regions(): + """Example: Switch between multiple regions in the same session.""" + # First call to Singapore + dashscope.set_region(region="ap-southeast-1", workspace_id="ws-sg-111") + print(f"Region 1: {dashscope.base_http_api_url}") + + # Then switch to Tokyo + dashscope.set_region(region="ap-northeast-1", workspace_id="ws-jp-222") + print(f"Region 2: {dashscope.base_http_api_url}") + + # Finally switch to Beijing + dashscope.set_region(region="cn-beijing", workspace_id="ws-bj-333") + print(f"Region 3: {dashscope.base_http_api_url}") + + +def example_error_handling(): + """Example: Handle invalid region errors.""" + try: + # This will raise ValueError + dashscope.set_region(region="invalid-region", workspace_id="ws-xxx") + except ValueError as e: + print(f"Error: {e}") + + try: + # This will raise ValueError for missing workspace_id + dashscope.set_region(region="cn-beijing") + except ValueError as e: + print(f"Error: {e}") + + +if __name__ == "__main__": + print("=== Basic Usage ===") + example_basic_usage() + + print("\n=== Multiple Regions ===") + example_multiple_regions() + + print("\n=== Error Handling ===") + example_error_handling() + + # Uncomment to test actual API calls (requires valid API key) + # print("\n=== Generation Call ===") + # example_generation_call() + + # print("\n=== Chat Completion ===") + # example_chat_completion() + + # print("\n=== Streaming ===") + # example_streaming() diff --git a/setup.py b/setup.py index ca968fe8..791a1152 100644 --- a/setup.py +++ b/setup.py @@ -56,13 +56,14 @@ def readme(): "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], platforms="Posix; MacOS X; Windows", - python_requires=">=3.8.0", + python_requires=">=3.9", install_requires=get_dependencies(), include_package_data=True, extras_require={ @@ -70,24 +71,24 @@ def readme(): # Interactive AI assistant (dashscope.acli); typer/rich are core deps "acli": [ "prompt-toolkit>=3.0", - "textual>=0.50", + "textual>=0.50,<9", "PyYAML>=6.0", "tomli>=2.0; python_version < '3.11'", ], - "acli-anthropic": ["anthropic>=0.40"], - "acli-openai": ["openai>=1.30"], + "acli-anthropic": ["anthropic>=0.40,<2"], + "acli-openai": ["openai>=1.30,<4"], "acli-voice": ["sounddevice>=0.4", "numpy>=1.20"], - "acli-camera": ["opencv-python>=4.5"], + "acli-camera": ["opencv-python-headless>=4.5"], "acli-all": [ "prompt-toolkit>=3.0", - "textual>=0.50", + "textual>=0.50,<9", "PyYAML>=6.0", "tomli>=2.0; python_version < '3.11'", - "anthropic>=0.40", - "openai>=1.30", + "anthropic>=0.40,<2", + "openai>=1.30,<4", "sounddevice>=0.4", "numpy>=1.20", - "opencv-python>=4.5", + "opencv-python-headless>=4.5", ], # Agentic RL fine-tuning (dashscope.finetune.reinforcement) "rl": [ diff --git a/tests/unit/test_banner_guide.py b/tests/unit/test_banner_guide.py new file mode 100644 index 00000000..38bab6a9 --- /dev/null +++ b/tests/unit/test_banner_guide.py @@ -0,0 +1,59 @@ +# -*- 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 + +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): + from dashscope.acli.ui import 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): + from dashscope.acli.ui import 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 60722b50..a7f04ada 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 @@ -25,7 +27,22 @@ def strip_ansi_codes(text): return ansi_escape.sub("", text) -# pylint: disable=too-many-public-methods +class _ImportErrorOnApp: + """Stand-in for a module whose ``from X import app`` raises ImportError. + + Lets the deferred rl import failure 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,protected-access class TestCliMain: def test_main_prints_authentication_error_without_traceback( self, @@ -245,6 +262,90 @@ def test_agentic_rl_hidden_alias_help(self): assert result.exit_code == 0 assert "register_functions" in result.output + 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", + _ImportErrorOnApp( + "Agentic RL fine-tuning needs optional dependencies. " + "Install them with: pip install 'dashscope[rl]'", + ), + ) + + 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_command_adds_a_hint_when_the_error_has_none( + self, + monkeypatch, + capsys, + ): + monkeypatch.setattr( + dashscope.cli, + "_RL_IMPORT_ERROR", + ImportError("No module named 'typer_x'"), + ) + monkeypatch.setattr(sys, "argv", ["dashscope", "agentic-rl", "list"]) + + 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 00000000..3ebf0bc8 --- /dev/null +++ b/tests/unit/test_handlers_key_docs.py @@ -0,0 +1,71 @@ +# -*- 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 + +from dashscope.acli.cli import 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" diff --git a/tests/unit/test_maas_region.py b/tests/unit/test_maas_region.py new file mode 100644 index 00000000..4a474abc --- /dev/null +++ b/tests/unit/test_maas_region.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +"""Tests for MaaS international region URL resolution.""" +import os +import unittest +from unittest.mock import patch + +from dashscope.common.env import MAAS_REGIONS, resolve_base_url +from dashscope.common.error import InputRequired + + +class TestResolveBaseUrl(unittest.TestCase): + """Test resolve_base_url helper.""" + + def test_no_placeholder(self): + url = "https://dashscope.aliyuncs.com/api/v1" + self.assertEqual(resolve_base_url(url, "ws-123"), url) + + def test_resolve_with_workspace(self): + url = "https://{workspace_id}.ap-southeast-1.maas.aliyuncs.com/api/v1" + result = resolve_base_url(url, "ws-abc") + self.assertEqual( + result, + "https://ws-abc.ap-southeast-1.maas.aliyuncs.com/api/v1", + ) + + def test_resolve_without_workspace_raises(self): + import dashscope.common.env as env_mod + + url = "https://{workspace_id}.us-east-1.maas.aliyuncs.com/api/v1" + with patch.object(env_mod, "_default_workspace_id", None): + with self.assertRaises(InputRequired): + resolve_base_url(url, None) + + def test_resolve_with_env_default_workspace(self): + import dashscope.common.env as env_mod + + url = "https://{workspace_id}.us-east-1.maas.aliyuncs.com/api/v1" + with patch.object(env_mod, "_default_workspace_id", "ws-env"): + result = resolve_base_url(url, None) + self.assertEqual( + result, + "https://ws-env.us-east-1.maas.aliyuncs.com/api/v1", + ) + + def test_resolve_all_regions(self): + for region_id in MAAS_REGIONS.values(): + url = f"https://{{workspace_id}}.{region_id}.maas.aliyuncs.com/api/v1" + result = resolve_base_url(url, "my-ws") + self.assertEqual( + result, + f"https://my-ws.{region_id}.maas.aliyuncs.com/api/v1", + ) + + +class TestMaasRegions(unittest.TestCase): + """Test MAAS_REGIONS constant.""" + + def test_expected_regions(self): + expected = { + "ap-southeast-1", + "us-east-1", + "cn-hongkong", + "eu-central-1", + "ap-northeast-1", + } + self.assertEqual(set(MAAS_REGIONS.keys()), expected) + + +class TestMaasEnvLoading(unittest.TestCase): + """Test that env.py loads correct URLs for MaaS regions.""" + + def _reload_env(self, env_vars): + """Reload env module with given env vars.""" + import importlib + import dashscope.common.env as env_mod + + with patch.dict(os.environ, env_vars, clear=False): + importlib.reload(env_mod) + return env_mod + + def _restore_env(self): + """Restore env module to default state.""" + import importlib + import dashscope.common.env as env_mod + + with patch.dict( + os.environ, + {"DASHSCOPE_API_REGION": "cn-beijing"}, + clear=False, + ): + # Remove MaaS-related vars if present + for key in [ + "DASHSCOPE_WORKSPACE_ID", + "DASHSCOPE_HTTP_BASE_URL", + "DASHSCOPE_WEBSOCKET_BASE_URL", + "DASHSCOPE_COMPATIBLE_BASE_URL", + ]: + os.environ.pop(key, None) + importlib.reload(env_mod) + + def tearDown(self): + self._restore_env() + + def test_default_region_legacy_urls(self): + env = self._reload_env({"DASHSCOPE_API_REGION": "cn-beijing"}) + self.assertEqual( + env.base_http_api_url, + "https://dashscope.aliyuncs.com/api/v1", + ) + self.assertEqual( + env.base_compatible_api_url, + "https://dashscope.aliyuncs.com/compatible-mode/v1", + ) + + def test_maas_region_with_workspace(self): + env = self._reload_env( + { + "DASHSCOPE_API_REGION": "ap-southeast-1", + "DASHSCOPE_WORKSPACE_ID": "ws-test-123", + }, + ) + self.assertEqual( + env.base_http_api_url, + "https://ws-test-123.ap-southeast-1.maas.aliyuncs.com/api/v1", + ) + self.assertEqual( + env.base_compatible_api_url, + "https://ws-test-123.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + ) + + def test_maas_region_without_workspace(self): + env = self._reload_env( + {"DASHSCOPE_API_REGION": "us-east-1"}, + ) + self.assertIn("{workspace_id}", env.base_http_api_url) + self.assertIn("us-east-1.maas.aliyuncs.com", env.base_http_api_url) + + def test_maas_region_resolve_at_call_time(self): + """workspace passed at call time resolves the placeholder.""" + env = self._reload_env( + {"DASHSCOPE_API_REGION": "eu-central-1"}, + ) + resolved = env.resolve_base_url( + env.base_http_api_url, + workspace_id="ws-runtime", + ) + self.assertEqual( + resolved, + "https://ws-runtime.eu-central-1.maas.aliyuncs.com/api/v1", + ) + + def test_env_override_takes_precedence(self): + env = self._reload_env( + { + "DASHSCOPE_API_REGION": "ap-southeast-1", + "DASHSCOPE_HTTP_BASE_URL": "https://custom.host/api/v1", + }, + ) + self.assertEqual( + env.base_http_api_url, + "https://custom.host/api/v1", + ) + + +class TestMaasCallTimeResolution(unittest.TestCase): + """Per-call workspace must resolve the {workspace_id} placeholder + on the main request paths. + """ + + PLACEHOLDER_URL = ( + "https://{workspace_id}.ap-southeast-1.maas.aliyuncs.com/api/v1" + ) + + def setUp(self): + import dashscope + + self._orig_http_url = dashscope.base_http_api_url + dashscope.base_http_api_url = self.PLACEHOLDER_URL + + def tearDown(self): + import dashscope + + dashscope.base_http_api_url = self._orig_http_url + + def _capture_request_url(self, func): + from dashscope.client import base_api as base_api_mod + + class ShortCircuit(Exception): + pass + + captured = {} + orig_build = ( + base_api_mod._build_api_request # pylint: disable=protected-access + ) + + def spy(*args, **kwargs): + request = orig_build(*args, **kwargs) + captured["url"] = request.url + raise ShortCircuit() + + with patch.object(base_api_mod, "_build_api_request", spy): + with self.assertRaises(ShortCircuit): + func() + return captured["url"] + + def test_generation_call_resolves_workspace(self): + from dashscope import Generation + + url = self._capture_request_url( + lambda: Generation.call( + model="qwen-turbo", + prompt="hi", + workspace="ws-xyz", + api_key="sk-test", + ), + ) + self.assertEqual( + url, + "https://ws-xyz.ap-southeast-1.maas.aliyuncs.com/api/v1" + "/services/aigc/text-generation/generation", + ) + + def test_async_task_call_resolves_workspace(self): + from dashscope import ImageSynthesis + + url = self._capture_request_url( + lambda: ImageSynthesis.call( + model="wanx-v1", + prompt="hi", + workspace="ws-xyz", + api_key="sk-test", + ), + ) + self.assertEqual( + url, + "https://ws-xyz.ap-southeast-1.maas.aliyuncs.com/api/v1" + "/services/aigc/text2image/image-synthesis", + ) + + def test_call_without_workspace_raises_clear_error(self): + import dashscope.common.env as env_mod + from dashscope import Generation + + with patch.object(env_mod, "_default_workspace_id", None): + with self.assertRaises(InputRequired) as ctx: + Generation.call( + model="qwen-turbo", + prompt="hi", + api_key="sk-test", + ) + self.assertIn("workspace", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_sdk_headers.py b/tests/unit/test_sdk_headers.py index 607ab0d3..e23451d6 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 @@ -128,6 +130,51 @@ 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): + # pylint: disable=protected-access + 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): + # pylint: disable=protected-access + 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_set_region.py b/tests/unit/test_set_region.py new file mode 100644 index 00000000..dda52084 --- /dev/null +++ b/tests/unit/test_set_region.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Unit tests for dashscope.set_region() function.""" + +import pytest + +import dashscope + + +class TestSetRegion: + """Test suite for set_region() function.""" + + def setup_method(self): + """Reset to default URLs before each test.""" + # Store original values + self.original_http = dashscope.base_http_api_url + self.original_compatible = dashscope.base_compatible_api_url + self.original_websocket = dashscope.base_websocket_api_url + + def teardown_method(self): + """Restore original URLs after each test.""" + dashscope.base_http_api_url = self.original_http + dashscope.base_compatible_api_url = self.original_compatible + dashscope.base_websocket_api_url = self.original_websocket + + def test_set_region_ap_southeast_1(self): + """Test switching to Singapore region.""" + dashscope.set_region("ap-southeast-1", "ws-test123") + + assert ( + dashscope.base_http_api_url + == "https://ws-test123.ap-southeast-1.maas.aliyuncs.com/api/v1" + ) + assert ( + dashscope.base_compatible_api_url + == "https://ws-test123.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" + ) + + def test_set_region_us_east_1(self): + """Test switching to US East region.""" + dashscope.set_region("us-east-1", "ws-us-456") + + assert ( + dashscope.base_http_api_url + == "https://ws-us-456.us-east-1.maas.aliyuncs.com/api/v1" + ) + assert ( + dashscope.base_compatible_api_url + == "https://ws-us-456.us-east-1.maas.aliyuncs.com/compatible-mode/v1" + ) + + def test_set_region_cn_hongkong(self): + """Test switching to Hong Kong region.""" + dashscope.set_region("cn-hongkong", "ws-hk-789") + + assert ( + dashscope.base_http_api_url + == "https://ws-hk-789.cn-hongkong.maas.aliyuncs.com/api/v1" + ) + assert ( + dashscope.base_compatible_api_url + == "https://ws-hk-789.cn-hongkong.maas.aliyuncs.com/compatible-mode/v1" + ) + + def test_set_region_cn_beijing(self): + """Test switching to Beijing region.""" + dashscope.set_region("cn-beijing", "ws-bj-001") + + assert ( + dashscope.base_http_api_url + == "https://ws-bj-001.cn-beijing.maas.aliyuncs.com/api/v1" + ) + assert ( + dashscope.base_compatible_api_url + == "https://ws-bj-001.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" + ) + + def test_set_region_eu_central_1(self): + """Test switching to Europe region.""" + dashscope.set_region("eu-central-1", "ws-eu-123") + + assert ( + dashscope.base_http_api_url + == "https://ws-eu-123.eu-central-1.maas.aliyuncs.com/api/v1" + ) + assert ( + dashscope.base_compatible_api_url + == "https://ws-eu-123.eu-central-1.maas.aliyuncs.com/compatible-mode/v1" + ) + + def test_set_region_ap_northeast_1(self): + """Test switching to Japan region.""" + dashscope.set_region("ap-northeast-1", "ws-jp-456") + + assert ( + dashscope.base_http_api_url + == "https://ws-jp-456.ap-northeast-1.maas.aliyuncs.com/api/v1" + ) + assert ( + dashscope.base_compatible_api_url + == "https://ws-jp-456.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1" + ) + + def test_set_region_updates_websocket_url(self): + """Test that set_region also switches the websocket URL.""" + dashscope.set_region("ap-southeast-1", "ws-test123") + + assert dashscope.base_websocket_api_url == ( + "wss://ws-test123.ap-southeast-1.maas.aliyuncs.com" + "/api-ws/v1/inference" + ) + + def test_set_region_invalid_region(self): + """Test that invalid region raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + dashscope.set_region("mars-1", "ws-xxx") + + assert "Unsupported region 'mars-1'" in str(exc_info.value) + assert "Supported regions:" in str(exc_info.value) + + def test_set_region_missing_workspace_id(self): + """Test that missing workspace_id raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + dashscope.set_region("cn-beijing") + + assert "workspace_id is required" in str(exc_info.value) + + def test_set_region_empty_workspace_id(self): + """Test that empty workspace_id raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + dashscope.set_region("cn-beijing", "") + + assert "workspace_id is required" in str(exc_info.value) + + def test_set_region_none_workspace_id(self): + """Test that None workspace_id raises ValueError.""" + with pytest.raises(ValueError) as exc_info: + dashscope.set_region("cn-beijing", None) + + assert "workspace_id is required" in str(exc_info.value) + + def test_set_region_multiple_times(self): + """Test switching between multiple regions.""" + # First region + dashscope.set_region("ap-southeast-1", "ws-sg-111") + assert "ap-southeast-1" in dashscope.base_http_api_url + assert "ws-sg-111" in dashscope.base_http_api_url + + # Second region + dashscope.set_region("us-east-1", "ws-us-222") + assert "us-east-1" in dashscope.base_http_api_url + assert "ws-us-222" in dashscope.base_http_api_url + + # Third region + dashscope.set_region("cn-beijing", "ws-bj-333") + assert "cn-beijing" in dashscope.base_http_api_url + assert "ws-bj-333" in dashscope.base_http_api_url + + def test_set_region_url_format(self): + """Test that URLs follow the correct format.""" + dashscope.set_region("ap-southeast-1", "ws-test-123") + + # Check HTTP API URL format + assert dashscope.base_http_api_url.startswith("https://") + assert dashscope.base_http_api_url.endswith("/api/v1") + assert "ws-test-123.ap-southeast-1.maas.aliyuncs.com" in ( + dashscope.base_http_api_url + ) + + # Check Compatible API URL format + assert dashscope.base_compatible_api_url.startswith("https://") + assert dashscope.base_compatible_api_url.endswith( + "/compatible-mode/v1", + ) + assert "ws-test-123.ap-southeast-1.maas.aliyuncs.com" in ( + dashscope.base_compatible_api_url + ) + + def test_set_region_all_supported_regions(self): + """Test that all documented regions work.""" + expected_regions = { + "cn-beijing", + "ap-southeast-1", + "us-east-1", + "cn-hongkong", + "eu-central-1", + "ap-northeast-1", + } + + for region in expected_regions: + dashscope.set_region(region, "ws-test") + assert region in dashscope.base_http_api_url + + def test_set_region_exported(self): + """Test that set_region is exported in __all__.""" + assert "set_region" in dashscope.__all__ + + def test_set_region_does_not_affect_original_defaults(self): + """Test that original defaults are preserved after reset.""" + original_http = "https://dashscope.aliyuncs.com/api/v1" + original_compatible = ( + "https://dashscope.aliyuncs.com/compatible-mode/v1" + ) + + # Set to defaults + dashscope.base_http_api_url = original_http + dashscope.base_compatible_api_url = original_compatible + + # Change region + dashscope.set_region("ap-southeast-1", "ws-test") + + # Should have changed + assert dashscope.base_http_api_url != original_http + + # Manually reset (simulating what teardown does) + dashscope.base_http_api_url = original_http + dashscope.base_compatible_api_url = original_compatible + + # Should be back to original + assert dashscope.base_http_api_url == original_http + assert dashscope.base_compatible_api_url == original_compatible diff --git a/tests/unit/test_tongyi_native.py b/tests/unit/test_tongyi_native.py new file mode 100644 index 00000000..2fc4670f --- /dev/null +++ b/tests/unit/test_tongyi_native.py @@ -0,0 +1,594 @@ +# -*- 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. +""" + +# pylint: disable=redefined-outer-name,protected-access + +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"): + async for _ in provider.chat_stream(MESSAGES): + pass + 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"): + async for _ in provider.chat_stream(MESSAGES): + pass + 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", + }