From edc4b5dfade1562228911f238b0ce4c72f4dcadd Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Thu, 4 Jun 2026 18:06:27 -0400 Subject: [PATCH 1/6] feat(auth): add Alibaba DashScope provider --- src/pythinker_code/auth/__init__.py | 2 + src/pythinker_code/auth/alibaba.py | 298 +++++++++++++++++++++ src/pythinker_code/llm.py | 32 ++- src/pythinker_code/ui/shell/oauth.py | 26 +- tests/auth/test_alibaba_auth.py | 381 +++++++++++++++++++++++++++ tests/core/test_create_llm.py | 48 ++++ 6 files changed, 774 insertions(+), 13 deletions(-) create mode 100644 src/pythinker_code/auth/alibaba.py create mode 100644 tests/auth/test_alibaba_auth.py diff --git a/src/pythinker_code/auth/__init__.py b/src/pythinker_code/auth/__init__.py index 5035f8b9..4e584739 100644 --- a/src/pythinker_code/auth/__init__.py +++ b/src/pythinker_code/auth/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +ALIBABA_PLATFORM_ID = "alibaba" PYTHINKER_CODE_PLATFORM_ID = "pythinker-code" OPENAI_API_PLATFORM_ID = "openai" OPENAI_CHATGPT_PLATFORM_ID = "openai-chatgpt" @@ -14,6 +15,7 @@ ZAI_PLATFORM_ID = "z-ai" __all__ = [ + "ALIBABA_PLATFORM_ID", "ANTHROPIC_PLATFORM_ID", "DEEPSEEK_PLATFORM_ID", "LM_STUDIO_PLATFORM_ID", diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py new file mode 100644 index 00000000..0c17d9af --- /dev/null +++ b/src/pythinker_code/auth/alibaba.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import os +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any, cast + +import aiohttp +from pydantic import SecretStr + +from pythinker_code.auth import ALIBABA_PLATFORM_ID +from pythinker_code.auth.oauth import OAuthEvent +from pythinker_code.auth.platforms import managed_provider_key +from pythinker_code.config import Config, LLMModel, LLMProvider, save_config +from pythinker_code.llm import ModelCapability +from pythinker_code.thinking import apply_login_thinking_defaults +from pythinker_code.utils.aiohttp import new_client_session + +ALIBABA_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" +ALIBABA_PROVIDER_KEY = managed_provider_key(ALIBABA_PLATFORM_ID) +ALIBABA_DEFAULT_MODEL_ALIAS = f"{ALIBABA_PLATFORM_ID}/qwen3.6-plus" +ALIBABA_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10) + + +@dataclass(frozen=True, slots=True) +class AlibabaModel: + model_id: str + alias_suffix: str + display_name: str + provider_key: str = ALIBABA_PROVIDER_KEY + max_context_size: int = 131_072 + capabilities: frozenset[ModelCapability] | None = None + + @property + def alias(self) -> str: + return f"{ALIBABA_PLATFORM_ID}/{self.alias_suffix}" + + +ALIBABA_MODELS: tuple[AlibabaModel, ...] = ( + AlibabaModel( + model_id="qwen3.7-max", + alias_suffix="qwen3.7-max", + display_name="Qwen3.7 Max", + max_context_size=262_144, + capabilities=frozenset[ModelCapability]({"thinking"}), + ), + AlibabaModel( + model_id="qwen3.6-plus", + alias_suffix="qwen3.6-plus", + display_name="Qwen3.6 Plus", + max_context_size=1_000_000, + capabilities=frozenset[ModelCapability]({"thinking", "image_in"}), + ), + AlibabaModel( + model_id="qwen3.6-flash", + alias_suffix="qwen3.6-flash", + display_name="Qwen3.6 Flash", + max_context_size=1_000_000, + capabilities=frozenset[ModelCapability]({"thinking", "image_in"}), + ), + AlibabaModel( + model_id="deepseek-v4-pro", + alias_suffix="deepseek-v4-pro", + display_name="DeepSeek V4 Pro", + max_context_size=128_000, + capabilities=frozenset[ModelCapability]({"thinking"}), + ), + AlibabaModel( + model_id="deepseek-v4-flash", + alias_suffix="deepseek-v4-flash", + display_name="DeepSeek V4 Flash", + max_context_size=128_000, + capabilities=frozenset[ModelCapability]({"thinking"}), + ), + AlibabaModel( + model_id="deepseek-v3.2", + alias_suffix="deepseek-v3.2", + display_name="DeepSeek V3.2", + max_context_size=128_000, + capabilities=frozenset[ModelCapability]({"thinking"}), + ), + AlibabaModel( + model_id="kimi-k2.6", + alias_suffix="kimi-k2.6", + display_name="Kimi K2.6", + max_context_size=262_144, + capabilities=frozenset[ModelCapability]({"thinking", "image_in"}), + ), + AlibabaModel( + model_id="kimi-k2.5", + alias_suffix="kimi-k2.5", + display_name="Kimi K2.5", + max_context_size=262_144, + capabilities=frozenset[ModelCapability]({"thinking", "image_in"}), + ), + AlibabaModel( + model_id="glm-5.1", + alias_suffix="glm-5.1", + display_name="GLM-5.1", + max_context_size=262_144, + capabilities=frozenset[ModelCapability]({"always_thinking"}), + ), + AlibabaModel( + model_id="glm-5", + alias_suffix="glm-5", + display_name="GLM-5", + max_context_size=262_144, + capabilities=frozenset[ModelCapability]({"always_thinking"}), + ), + AlibabaModel( + model_id="MiniMax-M2.5", + alias_suffix="minimax-m2.5", + display_name="MiniMax M2.5", + max_context_size=204_800, + capabilities=frozenset[ModelCapability]({"thinking"}), + ), +) + + +def get_alibaba_api_key_from_env() -> str | None: + for env_var in ("DASHSCOPE_API_KEY", "ALIBABA_API_KEY"): + value = os.getenv(env_var) + if value and value.strip(): + return value.strip() + return None + + +def _normalize_alibaba_base_url(value: str) -> str: + base_url = value.strip().rstrip("/") + if not base_url: + return ALIBABA_BASE_URL + if "://" not in base_url: + base_url = f"https://{base_url}" + if base_url.endswith("/api/v1"): + return f"{base_url.removesuffix('/api/v1')}/compatible-mode/v1" + if base_url.endswith("/compatible-mode/v1"): + return base_url + return f"{base_url}/compatible-mode/v1" + + +def get_alibaba_base_url_from_env() -> str: + for env_var in ("DASHSCOPE_BASE_URL", "ALIBABA_BASE_URL"): + value = os.getenv(env_var) + if value and value.strip(): + return _normalize_alibaba_base_url(value) + return ALIBABA_BASE_URL + + +def _apply_alibaba_config( + config: Config, + api_key: SecretStr, + models: tuple[AlibabaModel, ...] = ALIBABA_MODELS, +) -> None: + config.providers[ALIBABA_PROVIDER_KEY] = LLMProvider( + type="openai_legacy", + base_url=get_alibaba_base_url_from_env(), + api_key=api_key, + ) + + provider_keys = {ALIBABA_PROVIDER_KEY} + for key, model in list(config.models.items()): + if model.provider in provider_keys: + del config.models[key] + + for model in models: + config.models[model.alias] = LLMModel( + provider=model.provider_key, + model=model.model_id, + max_context_size=model.max_context_size, + capabilities=set(model.capabilities) if model.capabilities else None, + display_name=model.display_name, + ) + + fallback = next( + (m.alias for m in models), + next(iter(config.models), ""), + ) + if ALIBABA_DEFAULT_MODEL_ALIAS in config.models: + config.default_model = ALIBABA_DEFAULT_MODEL_ALIAS + else: + config.default_model = fallback + apply_login_thinking_defaults(config, thinking=True, effort="high") + + +def _model_by_id() -> dict[str, AlibabaModel]: + return {model.model_id: model for model in ALIBABA_MODELS} + + +def _parse_discovered_models(data: object) -> tuple[AlibabaModel, ...]: + if not isinstance(data, dict): + return () + d = cast(dict[str, Any], data) + items = d.get("data") + if not isinstance(items, list): + return () + catalog = _model_by_id() + results: list[AlibabaModel] = [] + for raw_item in cast(list[object], items): + if not isinstance(raw_item, dict): + continue + item = cast(dict[str, Any], raw_item) + model_id = item.get("id") + if not isinstance(model_id, str) or model_id not in catalog: + continue + base = catalog[model_id] + ctx = item.get("context_length") + max_ctx = base.max_context_size + if isinstance(ctx, int) and ctx > 0: + max_ctx = ctx + display_name_raw = item.get("display_name") + display_name = ( + display_name_raw + if isinstance(display_name_raw, str) and display_name_raw + else base.display_name + ) + results.append( + AlibabaModel( + model_id=base.model_id, + alias_suffix=base.alias_suffix, + display_name=display_name, + provider_key=base.provider_key, + max_context_size=max_ctx, + capabilities=base.capabilities, + ) + ) + return tuple(results) + + +async def _discover_alibaba_models(api_key: str) -> tuple[AlibabaModel, ...]: + async with ( + new_client_session(timeout=ALIBABA_MODEL_DISCOVERY_TIMEOUT) as session, + session.get( + f"{get_alibaba_base_url_from_env()}/models", + headers={"Authorization": f"Bearer {api_key}"}, + raise_for_status=True, + ) as response, + ): + payload = await response.json(content_type=None) + return _parse_discovered_models(payload) + + +async def login_alibaba_api_key( + config: Config, api_key: str | None = None +) -> AsyncIterator[OAuthEvent]: + if not config.is_from_default_location: + yield OAuthEvent( + "error", + "Login requires the default config file; restart without --config/--config-file.", + ) + return + + resolved_key = (api_key or get_alibaba_api_key_from_env() or "").strip() + if not resolved_key: + yield OAuthEvent("error", "Alibaba API key is required.") + return + + models = ALIBABA_MODELS + try: + discovered = await _discover_alibaba_models(resolved_key) + if discovered: + models = discovered + except aiohttp.ClientResponseError as exc: + if exc.status in {401, 403}: + yield OAuthEvent("error", "Invalid Alibaba API key; the key was not saved.") + return + yield OAuthEvent( + "info", + "Alibaba model listing is unavailable; using the built-in model list.", + ) + except (aiohttp.ClientError, TimeoutError, ValueError): + yield OAuthEvent( + "info", + "Alibaba model listing is unavailable; using the built-in model list.", + ) + + _apply_alibaba_config(config, SecretStr(resolved_key), models=models) + save_config(config) + yield OAuthEvent("success", f"Alibaba configured with model {config.default_model}.") + + +async def logout_alibaba(config: Config) -> AsyncIterator[OAuthEvent]: + if not config.is_from_default_location: + yield OAuthEvent( + "error", + "Logout requires the default config file; restart without --config/--config-file.", + ) + return + + provider_keys = {ALIBABA_PROVIDER_KEY} + config.providers.pop(ALIBABA_PROVIDER_KEY, None) + for key, model in list(config.models.items()): + if model.provider in provider_keys: + del config.models[key] + + if config.default_model not in config.models: + config.default_model = next(iter(config.models), "") + save_config(config) + yield OAuthEvent("success", "Logged out of Alibaba successfully.") diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index 737989df..4a484b36 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -352,21 +352,31 @@ def create_llm( thinking_on = thinking_effort_enabled(effective_effort) is_kimi_openai_legacy = provider.type == "openai_legacy" and _is_kimi_k2_model(model.model) - if effective_effort is not None and supports_thinking and not is_kimi_openai_legacy: + is_glm_openai_legacy = provider.type == "openai_legacy" and _is_glm_model(model.model) + if ( + effective_effort is not None + and supports_thinking + and not is_kimi_openai_legacy + and not is_glm_openai_legacy + ): # Only explicitly send thinking controls for models that advertise # reasoning. Some OpenAI-compatible non-reasoning models reject even a # null reasoning_effort field. chat_provider = chat_provider.with_thinking(effective_effort) - # Kimi K2.5/K2.6 use an OpenAI-compatible API but their thinking toggle is - # the provider-specific `thinking.type` body field rather than OpenAI's - # `reasoning_effort`. Kimi defaults thinking to enabled, so when Pythinker - # config says thinking is off we must send the explicit Kimi switch; - # otherwise multi-step tool calls can still enter thinking mode and require - # `reasoning_content` on replayed tool-call turns. - if is_kimi_openai_legacy and effective_effort is not None: + # Kimi K2.5/K2.6 and GLM models use OpenAI-compatible APIs but their thinking + # toggle is the provider-specific `thinking.type` body field rather than + # OpenAI's `reasoning_effort`. Send that field instead of `reasoning_effort`; + # otherwise providers such as Z.ai/GLM ignore the generic reasoning knob. + if (is_kimi_openai_legacy or is_glm_openai_legacy) and effective_effort is not None: + thinking_body: dict[str, object] = {"type": "enabled" if thinking_on else "disabled"} + if is_glm_openai_legacy and thinking_on: + # Z.ai documents Preserved Thinking for coding/agent scenarios as + # `clear_thinking: false`; OpenAILegacy already replays ThinkPart as + # `reasoning_content`, which is the required history field. + thinking_body["clear_thinking"] = False chat_provider = cast(Any, chat_provider).with_generation_kwargs( - extra_body={"thinking": {"type": "enabled" if thinking_on else "disabled"}} + extra_body={"thinking": thinking_body} ) # Apply Pythinker AI-specific ``thinking.keep`` (preserved thinking) only when @@ -453,6 +463,10 @@ def _is_kimi_k2_model(model_name: str) -> bool: return "kimi-k2" in model_name.lower().replace("_", "-") +def _is_glm_model(model_name: str) -> bool: + return model_name.lower().replace("_", "-").startswith("glm-") + + def _load_scripted_echo_scripts() -> list[str]: script_path = os.getenv("PYTHINKER_SCRIPTED_ECHO_SCRIPTS") if not script_path: diff --git a/src/pythinker_code/ui/shell/oauth.py b/src/pythinker_code/ui/shell/oauth.py index adee6b9b..c3ddc391 100644 --- a/src/pythinker_code/ui/shell/oauth.py +++ b/src/pythinker_code/ui/shell/oauth.py @@ -8,6 +8,7 @@ from rich.status import Status from pythinker_code.auth import ( + ALIBABA_PLATFORM_ID, ANTHROPIC_PLATFORM_ID, DEEPSEEK_PLATFORM_ID, LM_STUDIO_PLATFORM_ID, @@ -20,6 +21,11 @@ OPENROUTER_PLATFORM_ID, ZAI_PLATFORM_ID, ) +from pythinker_code.auth.alibaba import ( + ALIBABA_PROVIDER_KEY, + login_alibaba_api_key, + logout_alibaba, +) from pythinker_code.auth.anthropic_direct import ( ANTHROPIC_PROVIDER_KEY, login_anthropic_api_key, @@ -140,6 +146,7 @@ async def _prompt_api_key(label: str) -> str | None: OAuthProviderEntry(id="deepseek", name="DeepSeek", auth_type="api_key"), OAuthProviderEntry(id="z-ai", name="Z AI", auth_type="api_key"), OAuthProviderEntry(id="moonshot", name="Moonshot", auth_type="api_key"), + OAuthProviderEntry(id="alibaba", name="Alibaba (DashScope)", auth_type="api_key"), OAuthProviderEntry(id="anthropic", name="Anthropic", auth_type="api_key"), OAuthProviderEntry(id="openrouter", name="OpenRouter", auth_type="api_key"), OAuthProviderEntry(id="lm-studio", name="LM Studio", auth_type="api_key"), @@ -164,6 +171,7 @@ async def _prompt_api_key(label: str) -> str | None: "deepseek": (DEEPSEEK_PROVIDER_KEY,), "z-ai": (ZAI_PROVIDER_KEY,), "moonshot": (MOONSHOT_PROVIDER_KEY,), + "alibaba": (ALIBABA_PROVIDER_KEY,), "anthropic": (ANTHROPIC_PROVIDER_KEY,), "openrouter": (OPENROUTER_PROVIDER_KEY,), "lm-studio": (LM_STUDIO_PROVIDER_KEY,), @@ -179,6 +187,7 @@ async def _prompt_api_key(label: str) -> str | None: OAuthProviderEntry(id="deepseek", name="DeepSeek", auth_type="api_key"), OAuthProviderEntry(id="z-ai", name="Z AI", auth_type="api_key"), OAuthProviderEntry(id="moonshot", name="Moonshot", auth_type="api_key"), + OAuthProviderEntry(id="alibaba", name="Alibaba (DashScope)", auth_type="api_key"), OAuthProviderEntry(id="anthropic", name="Anthropic", auth_type="api_key"), OAuthProviderEntry(id="openrouter", name="OpenRouter", auth_type="api_key"), OAuthProviderEntry(id="lm-studio", name="LM Studio", auth_type="api_key"), @@ -270,6 +279,13 @@ async def login(app: Shell, args: str) -> None: return ok = await _render_oauth_events(login_moonshot_api_key(soul.runtime.config, api_key)) provider = MOONSHOT_PLATFORM_ID + elif mode == "alibaba": + api_key = await _prompt_api_key("Alibaba (DashScope)") + if not api_key: + console.print(f"[{_t.error}]No Alibaba API key entered.[/]") + return + ok = await _render_oauth_events(login_alibaba_api_key(soul.runtime.config, api_key)) + provider = ALIBABA_PLATFORM_ID elif mode == "anthropic": api_key = await _prompt_api_key("Anthropic") if not api_key: @@ -293,8 +309,8 @@ async def login(app: Shell, args: str) -> None: else: console.print( f"[{_t.error}]Usage: /login " - "[browser|headless|api-key|opencode-go|minimax|deepseek|z-ai|moonshot|anthropic|" - "openrouter|lm-studio|ollama][/]" + "[browser|headless|api-key|opencode-go|minimax|deepseek|z-ai|moonshot|alibaba|" + "anthropic|openrouter|lm-studio|ollama][/]" ) return if not ok: @@ -352,6 +368,8 @@ async def logout(app: Shell, args: str) -> None: ok = await _render_oauth_events(logout_z_ai(config)) elif mode == "moonshot": ok = await _render_oauth_events(logout_moonshot(config)) + elif mode == "alibaba": + ok = await _render_oauth_events(logout_alibaba(config)) elif mode == "minimax": ok = await _render_oauth_events(logout_minimax(config)) elif mode in ("opencode-go", "opencode", "go"): @@ -369,8 +387,8 @@ async def logout(app: Shell, args: str) -> None: else: console.print( f"[{_t.error}]Usage: /logout " - "[openai|opencode-go|minimax|deepseek|z-ai|moonshot|anthropic|openrouter|lm-studio|" - "ollama|github-feedback][/]" + "[openai|opencode-go|minimax|deepseek|z-ai|moonshot|alibaba|anthropic|openrouter|" + "lm-studio|ollama|github-feedback][/]" ) return if not ok: diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py new file mode 100644 index 00000000..a065070f --- /dev/null +++ b/tests/auth/test_alibaba_auth.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import aiohttp +import pytest +from multidict import CIMultiDict, CIMultiDictProxy +from pydantic import SecretStr +from yarl import URL + +from pythinker_code.config import Config, LLMModel, LLMProvider + + +def test_alibaba_model_catalog_contains_current_models(): + from pythinker_code.auth.alibaba import ALIBABA_MODELS + + aliases = {model.alias for model in ALIBABA_MODELS} + assert aliases == { + "alibaba/qwen3.7-max", + "alibaba/qwen3.6-plus", + "alibaba/qwen3.6-flash", + "alibaba/deepseek-v4-pro", + "alibaba/deepseek-v4-flash", + "alibaba/deepseek-v3.2", + "alibaba/kimi-k2.6", + "alibaba/kimi-k2.5", + "alibaba/glm-5.1", + "alibaba/glm-5", + "alibaba/minimax-m2.5", + } + + api_ids = {m.alias: m.model_id for m in ALIBABA_MODELS} + assert api_ids == { + "alibaba/qwen3.7-max": "qwen3.7-max", + "alibaba/qwen3.6-plus": "qwen3.6-plus", + "alibaba/qwen3.6-flash": "qwen3.6-flash", + "alibaba/deepseek-v4-pro": "deepseek-v4-pro", + "alibaba/deepseek-v4-flash": "deepseek-v4-flash", + "alibaba/deepseek-v3.2": "deepseek-v3.2", + "alibaba/kimi-k2.6": "kimi-k2.6", + "alibaba/kimi-k2.5": "kimi-k2.5", + "alibaba/glm-5.1": "glm-5.1", + "alibaba/glm-5": "glm-5", + "alibaba/minimax-m2.5": "MiniMax-M2.5", + } + + assert all(m.provider_key == "managed:alibaba" for m in ALIBABA_MODELS) + + by_alias = {m.alias: m for m in ALIBABA_MODELS} + assert by_alias["alibaba/qwen3.7-max"].capabilities == frozenset({"thinking"}) + assert by_alias["alibaba/qwen3.7-max"].max_context_size == 262_144 + assert by_alias["alibaba/qwen3.6-plus"].capabilities == frozenset({"thinking", "image_in"}) + assert by_alias["alibaba/qwen3.6-plus"].max_context_size == 1_000_000 + assert by_alias["alibaba/qwen3.6-flash"].capabilities == frozenset({"thinking", "image_in"}) + assert by_alias["alibaba/deepseek-v4-pro"].capabilities == frozenset({"thinking"}) + assert by_alias["alibaba/deepseek-v4-flash"].capabilities == frozenset({"thinking"}) + assert by_alias["alibaba/deepseek-v3.2"].capabilities == frozenset({"thinking"}) + assert by_alias["alibaba/deepseek-v3.2"].max_context_size == 128_000 + assert by_alias["alibaba/kimi-k2.6"].capabilities == frozenset({"thinking", "image_in"}) + assert by_alias["alibaba/kimi-k2.5"].capabilities == frozenset({"thinking", "image_in"}) + assert by_alias["alibaba/glm-5.1"].capabilities == frozenset({"always_thinking"}) + assert by_alias["alibaba/glm-5"].capabilities == frozenset({"always_thinking"}) + assert by_alias["alibaba/minimax-m2.5"].capabilities == frozenset({"thinking"}) + + +def test_alibaba_env_key_prefers_dashscope_api_key(monkeypatch): + from pythinker_code.auth.alibaba import get_alibaba_api_key_from_env + + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.delenv("ALIBABA_API_KEY", raising=False) + assert get_alibaba_api_key_from_env() is None + + monkeypatch.setenv("DASHSCOPE_API_KEY", " sk-dashscope ") + assert get_alibaba_api_key_from_env() == "sk-dashscope" + + monkeypatch.setenv("DASHSCOPE_API_KEY", "") + monkeypatch.setenv("ALIBABA_API_KEY", "sk-alibaba") + assert get_alibaba_api_key_from_env() == "sk-alibaba" + + monkeypatch.delenv("ALIBABA_API_KEY", raising=False) + monkeypatch.setenv("DASHSCOPE_API_KEY", "") + assert get_alibaba_api_key_from_env() is None + + +def test_alibaba_base_url_env_normalizes_workspace_endpoints(monkeypatch): + from pythinker_code.auth.alibaba import ALIBABA_BASE_URL, get_alibaba_base_url_from_env + + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + assert get_alibaba_base_url_from_env() == ALIBABA_BASE_URL + + monkeypatch.setenv( + "DASHSCOPE_BASE_URL", + "ws-example.ap-southeast-1.maas.aliyuncs.com", + ) + assert ( + get_alibaba_base_url_from_env() + == "https://ws-example.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" + ) + + monkeypatch.setenv( + "DASHSCOPE_BASE_URL", + "https://ws-example.ap-southeast-1.maas.aliyuncs.com/api/v1", + ) + assert ( + get_alibaba_base_url_from_env() + == "https://ws-example.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" + ) + + +def test_apply_alibaba_config_writes_provider_and_default(): + from pythinker_code.auth.alibaba import ( + ALIBABA_BASE_URL, + ALIBABA_PROVIDER_KEY, + _apply_alibaba_config, + ) + + config = Config(is_from_default_location=True) + _apply_alibaba_config(config, SecretStr("sk-test")) + + assert set(config.providers) == {ALIBABA_PROVIDER_KEY} + provider = config.providers[ALIBABA_PROVIDER_KEY] + assert provider.type == "openai_legacy" + assert provider.base_url == ALIBABA_BASE_URL + assert provider.api_key.get_secret_value() == "sk-test" + + assert "alibaba/qwen3.6-plus" in config.models + assert config.models["alibaba/qwen3.6-plus"].provider == ALIBABA_PROVIDER_KEY + assert config.models["alibaba/qwen3.6-plus"].model == "qwen3.6-plus" + assert config.models["alibaba/qwen3.7-max"].capabilities == frozenset({"thinking"}) + assert config.models["alibaba/qwen3.6-plus"].capabilities == frozenset({"thinking", "image_in"}) + assert config.models["alibaba/qwen3.6-flash"].capabilities == frozenset( + {"thinking", "image_in"} + ) + assert config.models["alibaba/deepseek-v4-pro"].capabilities == frozenset({"thinking"}) + assert config.models["alibaba/kimi-k2.6"].capabilities == frozenset({"thinking", "image_in"}) + assert config.models["alibaba/glm-5.1"].capabilities == frozenset({"always_thinking"}) + assert config.models["alibaba/minimax-m2.5"].model == "MiniMax-M2.5" + assert config.default_model == "alibaba/qwen3.6-plus" + assert config.default_thinking is True + assert config.default_thinking_effort == "high" + + +def test_apply_alibaba_config_empty_catalog_preserves_non_alibaba_default(): + from pythinker_code.auth.alibaba import _apply_alibaba_config + + config = Config(is_from_default_location=True) + config.providers["managed:openai"] = LLMProvider( + type="openai_responses", + base_url="https://api.openai.com/v1", + api_key=SecretStr("sk-test"), + ) + config.models["openai/gpt-5.2"] = LLMModel( + provider="managed:openai", + model="gpt-5.2", + max_context_size=400_000, + ) + config.default_model = "openai/gpt-5.2" + + _apply_alibaba_config(config, SecretStr("sk-test"), models=()) + + assert config.default_model == "openai/gpt-5.2" + assert "openai/gpt-5.2" in config.models + assert not any(k.startswith("alibaba/") for k in config.models) + + +def _request_info(url: str) -> aiohttp.RequestInfo: + return aiohttp.RequestInfo( + url=URL(url), + method="GET", + headers=CIMultiDictProxy(CIMultiDict()), + real_url=URL(url), + ) + + +@pytest.mark.asyncio +async def test_login_alibaba_saves_static_models_when_discovery_fails(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + + async def fake_discover(api_key): + raise aiohttp.ClientConnectionError("models unavailable") + + monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + + events = [event async for event in login_alibaba_api_key(config, "sk-test")] + + assert [event.type for event in events] == ["info", "success"] + assert "sk-test" not in "\n".join(event.json for event in events) + assert config.default_model == "alibaba/qwen3.6-plus" + assert "alibaba/qwen3.7-max" in config.models + assert "alibaba/qwen3.6-plus" in config.models + assert (tmp_path / "config.toml").exists() + + +@pytest.mark.asyncio +async def test_login_alibaba_falls_back_on_non_auth_response_error(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + + async def fake_discover(api_key): + raise aiohttp.ClientResponseError( + _request_info("https://dashscope-us.aliyuncs.com/compatible-mode/v1/models"), + (), + status=503, + message="Service Unavailable", + ) + + monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + + events = [event async for event in login_alibaba_api_key(config, "sk-test")] + + assert [event.type for event in events] == ["info", "success"] + assert config.default_model == "alibaba/qwen3.6-plus" + + +@pytest.mark.asyncio +async def test_login_alibaba_rejects_401(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + + async def fake_discover(api_key): + raise aiohttp.ClientResponseError( + _request_info("https://dashscope-us.aliyuncs.com/compatible-mode/v1/models"), + (), + status=401, + message="Unauthorized", + ) + + monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + + events = [event async for event in login_alibaba_api_key(config, "bad-key")] + + assert events[-1].type == "error" + assert "Alibaba" in events[-1].message + assert "API key" in events[-1].message + assert config.providers == {} + assert config.models == {} + + +@pytest.mark.asyncio +async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import AlibabaModel, login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + + async def fake_discover(api_key): + return ( + AlibabaModel( + model_id="qwen3.7-max", + alias_suffix="qwen3.7-max", + display_name="Qwen3.7 Max", + max_context_size=512_000, + capabilities=frozenset({"thinking"}), + ), + ) + + monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + + events = [event async for event in login_alibaba_api_key(config, "sk-test")] + + assert events[-1].type == "success" + assert config.models["alibaba/qwen3.7-max"].max_context_size == 512_000 + assert config.models["alibaba/qwen3.7-max"].capabilities == frozenset({"thinking"}) + + +@pytest.mark.asyncio +async def test_login_alibaba_requires_key(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + monkeypatch.delenv("ALIBABA_API_KEY", raising=False) + config = Config(is_from_default_location=True) + + events = [event async for event in login_alibaba_api_key(config, "")] + + assert events[-1].type == "error" + assert "Alibaba" in events[-1].message + assert "API key" in events[-1].message + + +@pytest.mark.parametrize( + ("payload", "expected_aliases"), + [ + (None, set()), + ({}, set()), + ({"data": "not a list"}, set()), + ({"data": [{"context_length": 1000}]}, set()), + ({"data": [{"id": "unknown-model-xyz"}]}, set()), + ({"data": [{"id": "qwen3.7-max"}]}, {"alibaba/qwen3.7-max"}), + ({"data": [{"id": "deepseek-v3.2"}]}, {"alibaba/deepseek-v3.2"}), + ], +) +def test_parse_discovered_alibaba_models_handles_malformed_payloads(payload, expected_aliases): + from pythinker_code.auth.alibaba import _parse_discovered_models + + result = _parse_discovered_models(payload) + assert {m.alias for m in result} == expected_aliases + + +def test_parse_discovered_alibaba_models_overrides_context_length_only_for_positive_int(): + from pythinker_code.auth.alibaba import _parse_discovered_models + + payload = { + "data": [ + {"id": "qwen3.7-max", "context_length": "bogus"}, + {"id": "qwen3.6-plus", "context_length": -5}, + {"id": "deepseek-v3.2", "context_length": 512_000}, + ] + } + result = _parse_discovered_models(payload) + by_id = {m.model_id: m for m in result} + assert by_id["qwen3.7-max"].max_context_size == 262_144 + assert by_id["qwen3.6-plus"].max_context_size == 1_000_000 + assert by_id["deepseek-v3.2"].max_context_size == 512_000 + + +def test_parse_discovered_alibaba_models_preserves_capabilities(): + from pythinker_code.auth.alibaba import _parse_discovered_models + + payload = { + "data": [ + {"id": "qwen3.6-plus", "context_length": 900_000}, + {"id": "deepseek-v3.2"}, + ] + } + result = _parse_discovered_models(payload) + by_id = {m.model_id: m for m in result} + assert by_id["qwen3.6-plus"].capabilities == frozenset({"thinking", "image_in"}) + assert by_id["deepseek-v3.2"].capabilities == frozenset({"thinking"}) + + +@pytest.mark.asyncio +async def test_logout_alibaba_removes_only_alibaba(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import ( + ALIBABA_PROVIDER_KEY, + _apply_alibaba_config, + logout_alibaba, + ) + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + config.providers["managed:openai"] = LLMProvider( + type="openai_responses", + base_url="https://api.openai.com/v1", + api_key=SecretStr("sk-test"), + ) + config.models["openai/gpt-5.2"] = LLMModel( + provider="managed:openai", + model="gpt-5.2", + max_context_size=400_000, + ) + _apply_alibaba_config(config, SecretStr("sk-test")) + + events = [event async for event in logout_alibaba(config)] + + assert events[-1].type == "success" + assert ALIBABA_PROVIDER_KEY not in config.providers + assert not any(k.startswith("alibaba/") for k in config.models) + assert "managed:openai" in config.providers + assert "openai/gpt-5.2" in config.models + assert config.default_model == "openai/gpt-5.2" + + +@pytest.mark.asyncio +async def test_logout_alibaba_rejects_non_default_config_location(): + from pythinker_code.auth.alibaba import logout_alibaba + + config = Config(is_from_default_location=False) + + events = [event async for event in logout_alibaba(config)] + + assert events[-1].type == "error" + assert "default config file" in events[-1].message + assert config.providers == {} + assert config.models == {} diff --git a/tests/core/test_create_llm.py b/tests/core/test_create_llm.py index 7e1d7b02..6e5061d0 100644 --- a/tests/core/test_create_llm.py +++ b/tests/core/test_create_llm.py @@ -718,6 +718,54 @@ def test_create_llm_openai_legacy_kimi_sends_enabled_thinking_body(): } +def test_create_llm_openai_legacy_glm_sends_provider_thinking_body(): + provider = LLMProvider( + type="openai_legacy", + base_url="https://api.example.com/v1", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="glm-provider", + model="glm-5.1", + max_context_size=262_144, + capabilities={"thinking"}, + ) + + llm = create_llm(provider, model, thinking_effort="high") + assert llm is not None + assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.chat_provider.thinking_effort is None + assert llm.thinking is True + assert llm.thinking_effort == "high" + assert llm.chat_provider._generation_kwargs.get("extra_body") == { # pyright: ignore[reportPrivateUsage] + "thinking": {"type": "enabled", "clear_thinking": False} + } + + +def test_create_llm_openai_legacy_glm_sends_disabled_provider_thinking_body(): + provider = LLMProvider( + type="openai_legacy", + base_url="https://api.example.com/v1", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="glm-provider", + model="glm-5.1", + max_context_size=262_144, + capabilities={"thinking"}, + ) + + llm = create_llm(provider, model, thinking_effort="off") + assert llm is not None + assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.chat_provider.thinking_effort is None + assert llm.thinking is False + assert llm.thinking_effort == "off" + assert llm.chat_provider._generation_kwargs.get("extra_body") == { # pyright: ignore[reportPrivateUsage] + "thinking": {"type": "disabled"} + } + + def test_clone_llm_with_model_alias_preserves_kimi_thinking_disabled(): provider = LLMProvider( type="openai_legacy", From 0444a827a355bb6e79512bcf532b23f89ff778a6 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Thu, 4 Jun 2026 18:52:10 -0400 Subject: [PATCH 2/6] feat(auth): update MiniMax M3 catalog --- src/pythinker_code/auth/minimax.py | 16 ++++++++++++++-- tests/auth/test_minimax_auth.py | 29 ++++++++++++++++++++--------- tests/auth/test_platforms.py | 14 ++++++++++---- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/src/pythinker_code/auth/minimax.py b/src/pythinker_code/auth/minimax.py index 42e7ebff..0e4b13be 100644 --- a/src/pythinker_code/auth/minimax.py +++ b/src/pythinker_code/auth/minimax.py @@ -20,11 +20,16 @@ MINIMAX_ANTHROPIC_MODELS_URL = f"{MINIMAX_ANTHROPIC_BASE_URL}/v1/models" MINIMAX_OPENAI_MODELS_URL = f"{MINIMAX_OPENAI_BASE_URL}/models" MINIMAX_ANTHROPIC_PROVIDER_KEY = "managed:minimax-anthropic" -MINIMAX_DEFAULT_MODEL_ALIAS = "minimax/m2.7" -MINIMAX_DEFAULT_CONTEXT = 192_000 +MINIMAX_DEFAULT_MODEL_ALIAS = "minimax/m3" +# Fallback context size for models without explicit specification (non-M3 models) +MINIMAX_DEFAULT_CONTEXT = 204_800 MINIMAX_TOKEN_PLAN_KEY_PREFIX = "sk-cp-" MINIMAX_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10) MINIMAX_NATIVE_THINKING_CAPABILITIES: frozenset[ModelCapability] = frozenset({"always_thinking"}) +MINIMAX_M3_CAPABILITIES: frozenset[ModelCapability] = frozenset( + {"always_thinking", "image_in", "video_in"} +) +MINIMAX_M3_CONTEXT_SIZE = 1_000_000 @dataclass(frozen=True, slots=True) @@ -42,6 +47,13 @@ def alias(self) -> str: MINIMAX_MODELS: tuple[MiniMaxModel, ...] = ( + MiniMaxModel( + "MiniMax-M3", + "m3", + "MiniMax M3", + max_context_size=MINIMAX_M3_CONTEXT_SIZE, + capabilities=MINIMAX_M3_CAPABILITIES, + ), MiniMaxModel("MiniMax-M2.7", "m2.7", "MiniMax M2.7"), MiniMaxModel("MiniMax-M2.7-highspeed", "m2.7-highspeed", "MiniMax M2.7 High-Speed"), MiniMaxModel("MiniMax-M2.5", "m2.5", "MiniMax M2.5"), diff --git a/tests/auth/test_minimax_auth.py b/tests/auth/test_minimax_auth.py index c4b10a28..ae62d5d5 100644 --- a/tests/auth/test_minimax_auth.py +++ b/tests/auth/test_minimax_auth.py @@ -9,11 +9,12 @@ from pythinker_code.config import Config, LLMModel, LLMProvider -def test_minimax_model_catalog_contains_four_current_models(): +def test_minimax_model_catalog_contains_current_models(): from pythinker_code.auth.minimax import MINIMAX_MODELS aliases = {model.alias for model in MINIMAX_MODELS} assert aliases == { + "minimax/m3", "minimax/m2.7", "minimax/m2.7-highspeed", "minimax/m2.5", @@ -22,6 +23,7 @@ def test_minimax_model_catalog_contains_four_current_models(): api_ids = {m.alias: m.model_id for m in MINIMAX_MODELS} assert api_ids == { + "minimax/m3": "MiniMax-M3", "minimax/m2.7": "MiniMax-M2.7", "minimax/m2.7-highspeed": "MiniMax-M2.7-highspeed", "minimax/m2.5": "MiniMax-M2.5", @@ -29,7 +31,12 @@ def test_minimax_model_catalog_contains_four_current_models(): } assert all(m.provider_key == "managed:minimax-anthropic" for m in MINIMAX_MODELS) - assert all(m.capabilities == {"always_thinking"} for m in MINIMAX_MODELS) + m3 = next(m for m in MINIMAX_MODELS if m.model_id == "MiniMax-M3") + assert m3.capabilities == {"always_thinking", "image_in", "video_in"} + assert m3.max_context_size == 1_000_000 + non_m3 = [m for m in MINIMAX_MODELS if m.model_id != "MiniMax-M3"] + assert all(m.capabilities == {"always_thinking"} for m in non_m3) + assert all(m.max_context_size == 204_800 for m in non_m3) def test_minimax_env_key_uses_minimax_api_key(monkeypatch): @@ -61,10 +68,13 @@ def test_apply_minimax_config_writes_provider_and_default(): assert provider.type == "anthropic" assert provider.base_url == MINIMAX_ANTHROPIC_BASE_URL assert provider.api_key.get_secret_value() == "mx-test" - assert config.models["minimax/m2.7"].provider == MINIMAX_ANTHROPIC_PROVIDER_KEY - assert config.models["minimax/m2.7"].model == "MiniMax-M2.7" + assert config.models["minimax/m3"].provider == MINIMAX_ANTHROPIC_PROVIDER_KEY + assert config.models["minimax/m3"].model == "MiniMax-M3" + assert config.models["minimax/m3"].capabilities == {"always_thinking", "image_in", "video_in"} + assert config.models["minimax/m3"].max_context_size == 1_000_000 assert config.models["minimax/m2.7"].capabilities == {"always_thinking"} - assert config.default_model == "minimax/m2.7" + assert config.models["minimax/m2.7"].max_context_size == 204_800 + assert config.default_model == "minimax/m3" def test_apply_minimax_config_empty_catalog_preserves_non_minimax_default(): @@ -121,8 +131,9 @@ async def fake_discover(api_key): assert [event.type for event in events] == ["info", "success"] assert "mx-test" not in "\n".join(event.json for event in events) - assert config.default_model == "minimax/m2.7" + assert config.default_model == "minimax/m3" assert "minimax/m2.5-highspeed" in config.models + assert "minimax/m3" in config.models assert (tmp_path / "config.toml").exists() @@ -146,7 +157,7 @@ async def fake_discover(api_key): events = [event async for event in login_minimax_api_key(config, "mx-test")] assert [event.type for event in events] == ["info", "success"] - assert config.default_model == "minimax/m2.7" + assert config.default_model == "minimax/m3" @pytest.mark.asyncio @@ -328,8 +339,8 @@ def test_parse_discovered_minimax_models_overrides_context_length_only_for_posit } result = _parse_discovered_models(payload) by_id = {m.model_id: m for m in result} - assert by_id["MiniMax-M2.7"].max_context_size == 192_000 - assert by_id["MiniMax-M2.5"].max_context_size == 192_000 + assert by_id["MiniMax-M2.7"].max_context_size == 204_800 + assert by_id["MiniMax-M2.5"].max_context_size == 204_800 assert by_id["MiniMax-M2.5-highspeed"].max_context_size == 384_000 diff --git a/tests/auth/test_platforms.py b/tests/auth/test_platforms.py index ea0d1d30..d7175b40 100644 --- a/tests/auth/test_platforms.py +++ b/tests/auth/test_platforms.py @@ -751,7 +751,7 @@ def _make_minimax_config() -> Config: ) config = Config( - default_model="minimax/m2.7", + default_model="minimax/m3", default_thinking=True, providers={ MINIMAX_ANTHROPIC_PROVIDER_KEY: LLMProvider( @@ -761,15 +761,21 @@ def _make_minimax_config() -> Config: ) }, models={ + "minimax/m3": LLMModel( + provider=MINIMAX_ANTHROPIC_PROVIDER_KEY, + model="MiniMax-M3", + max_context_size=1_000_000, + capabilities={"always_thinking", "image_in", "video_in"}, + ), "minimax/m2.7": LLMModel( provider=MINIMAX_ANTHROPIC_PROVIDER_KEY, model="MiniMax-M2.7", - max_context_size=192_000, + max_context_size=204_800, ), "minimax/m2.7-highspeed": LLMModel( provider=MINIMAX_ANTHROPIC_PROVIDER_KEY, model="MiniMax-M2.7-highspeed", - max_context_size=192_000, + max_context_size=204_800, ), }, services=Services(), @@ -817,7 +823,7 @@ async def test_refresh_managed_models_refreshes_minimax_token_plan_without_relog assert "minimax/m2.7-highspeed" not in config.models assert config.models["minimax/m2.7"].max_context_size == 205_000 assert config.models["minimax/m3"].provider == MINIMAX_ANTHROPIC_PROVIDER_KEY - assert config.default_model == "minimax/m2.7" + assert config.default_model == "minimax/m3" assert config.default_thinking is True assert len(saved) == 1 assert "minimax/m3" in saved[0].models From f11c420dc53d819241d14bb411554c5956cbacf5 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Thu, 4 Jun 2026 19:04:25 -0400 Subject: [PATCH 3/6] test(auth): decouple Alibaba login discovery tests --- CHANGELOG.md | 1 + tests/auth/test_alibaba_auth.py | 62 ++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b903db..a16a6675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Alibaba DashScope provider and MiniMax M3 catalog.** `/login alibaba` now configures Alibaba Cloud Model Studio / DashScope Token Plan models, including workspace-compatible endpoints and native GLM thinking behavior. MiniMax API-key login now defaults to MiniMax M3 with its larger context and multimodal capabilities. - **Security review vulnerability intelligence.** `pythinker security-scan` can now parse dependency manifests, query OSV package advisories, look up CVE intelligence from NVD/EPSS/CISA KEV/GitHub/vendor feeds, and carry those leads into security-review prompts and reports as evidence-checked context. ## 0.34.0 (2026-06-03) diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index a065070f..ffd29165 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -162,6 +162,25 @@ def test_apply_alibaba_config_empty_catalog_preserves_non_alibaba_default(): assert not any(k.startswith("alibaba/") for k in config.models) +def test_alibaba_oauth_selector_status_uses_provider_key(): + from pythinker_code.auth.alibaba import ALIBABA_PROVIDER_KEY + from pythinker_code.ui.shell import oauth + + login_entries = {entry.id: entry for entry in oauth._SELECTOR_PROVIDER_ENTRIES} + logout_entries = {entry.id: entry for entry in oauth._LOGOUT_PROVIDER_ENTRIES} + assert login_entries["alibaba"].name == "Alibaba (DashScope)" + assert logout_entries["alibaba"].name == "Alibaba (DashScope)" + + config = Config(is_from_default_location=True) + assert oauth._get_provider_status(config, "alibaba").source == "unconfigured" + config.providers[ALIBABA_PROVIDER_KEY] = LLMProvider( + type="openai_legacy", + base_url="https://dashscope-us.aliyuncs.com/compatible-mode/v1", + api_key=SecretStr("sk-test"), + ) + assert oauth._get_provider_status(config, "alibaba").source == "configured" + + def _request_info(url: str) -> aiohttp.RequestInfo: return aiohttp.RequestInfo( url=URL(url), @@ -171,6 +190,20 @@ def _request_info(url: str) -> aiohttp.RequestInfo: ) +class _FakeAiohttpResponse: + def __init__(self, payload: object) -> None: + self._payload = payload + + async def __aenter__(self) -> _FakeAiohttpResponse: + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + async def json(self, *, content_type: str | None = None) -> object: + return self._payload + + @pytest.mark.asyncio async def test_login_alibaba_saves_static_models_when_discovery_fails(monkeypatch, tmp_path): from pythinker_code.auth.alibaba import login_alibaba_api_key @@ -178,10 +211,10 @@ async def test_login_alibaba_saves_static_models_when_discovery_fails(monkeypatc monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) config = Config(is_from_default_location=True) - async def fake_discover(api_key): + async def fake_request(*args: object, **kwargs: object) -> object: raise aiohttp.ClientConnectionError("models unavailable") - monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) events = [event async for event in login_alibaba_api_key(config, "sk-test")] @@ -200,7 +233,7 @@ async def test_login_alibaba_falls_back_on_non_auth_response_error(monkeypatch, monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) config = Config(is_from_default_location=True) - async def fake_discover(api_key): + async def fake_request(*args: object, **kwargs: object) -> object: raise aiohttp.ClientResponseError( _request_info("https://dashscope-us.aliyuncs.com/compatible-mode/v1/models"), (), @@ -208,7 +241,7 @@ async def fake_discover(api_key): message="Service Unavailable", ) - monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) events = [event async for event in login_alibaba_api_key(config, "sk-test")] @@ -223,7 +256,7 @@ async def test_login_alibaba_rejects_401(monkeypatch, tmp_path): monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) config = Config(is_from_default_location=True) - async def fake_discover(api_key): + async def fake_request(*args: object, **kwargs: object) -> object: raise aiohttp.ClientResponseError( _request_info("https://dashscope-us.aliyuncs.com/compatible-mode/v1/models"), (), @@ -231,7 +264,7 @@ async def fake_discover(api_key): message="Unauthorized", ) - monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) events = [event async for event in login_alibaba_api_key(config, "bad-key")] @@ -244,23 +277,18 @@ async def fake_discover(api_key): @pytest.mark.asyncio async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_path): - from pythinker_code.auth.alibaba import AlibabaModel, login_alibaba_api_key + from pythinker_code.auth.alibaba import login_alibaba_api_key monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) config = Config(is_from_default_location=True) - async def fake_discover(api_key): - return ( - AlibabaModel( - model_id="qwen3.7-max", - alias_suffix="qwen3.7-max", - display_name="Qwen3.7 Max", - max_context_size=512_000, - capabilities=frozenset({"thinking"}), - ), + async def fake_request(*args: object, **kwargs: object) -> object: + assert kwargs["headers"] == {"Authorization": "Bearer sk-test"} + return _FakeAiohttpResponse( + {"data": [{"id": "qwen3.7-max", "context_length": 512_000}]} ) - monkeypatch.setattr("pythinker_code.auth.alibaba._discover_alibaba_models", fake_discover) + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) events = [event async for event in login_alibaba_api_key(config, "sk-test")] From cae9c520ff400cda434510e5b555eb473e62e986 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Thu, 4 Jun 2026 19:06:00 -0400 Subject: [PATCH 4/6] style(auth): format Alibaba auth tests --- tests/auth/test_alibaba_auth.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index ffd29165..14ffb995 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -284,9 +284,7 @@ async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_pat async def fake_request(*args: object, **kwargs: object) -> object: assert kwargs["headers"] == {"Authorization": "Bearer sk-test"} - return _FakeAiohttpResponse( - {"data": [{"id": "qwen3.7-max", "context_length": 512_000}]} - ) + return _FakeAiohttpResponse({"data": [{"id": "qwen3.7-max", "context_length": 512_000}]}) monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) From aa9d3b94b9e15107287de6d2d76b88b7e1cf4f7c Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Thu, 4 Jun 2026 19:18:44 -0400 Subject: [PATCH 5/6] fix(test): use robust header assertion in Alibaba discovery test --- tests/auth/test_alibaba_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index 14ffb995..b081091f 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -283,7 +283,7 @@ async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_pat config = Config(is_from_default_location=True) async def fake_request(*args: object, **kwargs: object) -> object: - assert kwargs["headers"] == {"Authorization": "Bearer sk-test"} + assert kwargs["headers"].get("Authorization") == "Bearer sk-test" return _FakeAiohttpResponse({"data": [{"id": "qwen3.7-max", "context_length": 512_000}]}) monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) From 8d652f781faec703ce26f92e68dfb233be08f69c Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Thu, 4 Jun 2026 19:24:08 -0400 Subject: [PATCH 6/6] fix(test): satisfy pyright on header assertion type --- tests/auth/test_alibaba_auth.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index b081091f..85d60ae2 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + import aiohttp import pytest from multidict import CIMultiDict, CIMultiDictProxy @@ -283,7 +285,8 @@ async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_pat config = Config(is_from_default_location=True) async def fake_request(*args: object, **kwargs: object) -> object: - assert kwargs["headers"].get("Authorization") == "Bearer sk-test" + headers = cast("dict[str, object]", kwargs.get("headers", {})) + assert headers.get("Authorization") == "Bearer sk-test" return _FakeAiohttpResponse({"data": [{"id": "qwen3.7-max", "context_length": 512_000}]}) monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request)