From 7d84271944c955aaa9500ce49b32a5fa41ad8746 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 22:47:15 -0400 Subject: [PATCH 01/11] feat(auth): add ALIBABA_CHINA_BASE_URL and base_url param to _apply_alibaba_config --- src/pythinker_code/auth/alibaba.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py index 0c17d9af..e57ef463 100644 --- a/src/pythinker_code/auth/alibaba.py +++ b/src/pythinker_code/auth/alibaba.py @@ -17,6 +17,7 @@ from pythinker_code.utils.aiohttp import new_client_session ALIBABA_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" +ALIBABA_CHINA_BASE_URL = "https://dashscope.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) @@ -150,10 +151,11 @@ def _apply_alibaba_config( config: Config, api_key: SecretStr, models: tuple[AlibabaModel, ...] = ALIBABA_MODELS, + base_url: str | None = None, ) -> None: config.providers[ALIBABA_PROVIDER_KEY] = LLMProvider( type="openai_legacy", - base_url=get_alibaba_base_url_from_env(), + base_url=base_url if base_url is not None else get_alibaba_base_url_from_env(), api_key=api_key, ) From 48bfe3921a64feb58c8a9ffad374cbae80e7cfa6 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 22:48:08 -0400 Subject: [PATCH 02/11] feat(auth): probe China endpoint on 401 before failing Alibaba login --- src/pythinker_code/auth/alibaba.py | 68 +++++++++++++++++++++++++----- tests/auth/test_alibaba_auth.py | 17 ++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py index e57ef463..5bc50503 100644 --- a/src/pythinker_code/auth/alibaba.py +++ b/src/pythinker_code/auth/alibaba.py @@ -228,11 +228,11 @@ def _parse_discovered_models(data: object) -> tuple[AlibabaModel, ...]: return tuple(results) -async def _discover_alibaba_models(api_key: str) -> tuple[AlibabaModel, ...]: +async def _discover_alibaba_models(api_key: str, base_url: 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", + f"{base_url}/models", headers={"Authorization": f"Bearer {api_key}"}, raise_for_status=True, ) as response, @@ -256,26 +256,74 @@ async def login_alibaba_api_key( yield OAuthEvent("error", "Alibaba API key is required.") return + primary_url = get_alibaba_base_url_from_env() + active_url = primary_url models = ALIBABA_MODELS + try: - discovered = await _discover_alibaba_models(resolved_key) + discovered = await _discover_alibaba_models(resolved_key, primary_url) 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.", - ) + # Primary endpoint rejected the key — probe China as a region fallback. + china_url = ALIBABA_CHINA_BASE_URL + if china_url != primary_url: + try: + discovered = await _discover_alibaba_models(resolved_key, china_url) + active_url = china_url + if discovered: + models = discovered + yield OAuthEvent( + "info", + "Detected China-region DashScope key; configured for China (Beijing) endpoint.", + ) + except aiohttp.ClientResponseError as china_exc: + if china_exc.status in {401, 403}: + yield OAuthEvent( + "error", + "Alibaba API key was not accepted. Ensure your key is valid and " + "comes from the Alibaba Cloud Model Studio console " + "(https://bailian.console.aliyun.com). " + "Set DASHSCOPE_BASE_URL to override the endpoint if needed.", + ) + return + # Non-auth error from China — can't verify; point at China anyway + # since the US endpoint definitively rejected the key. + active_url = china_url + yield OAuthEvent( + "info", + "US endpoint rejected the key and China endpoint is unreachable — " + "configured for China (Beijing). Set DASHSCOPE_BASE_URL if issues persist.", + ) + except (aiohttp.ClientError, TimeoutError, ValueError): + active_url = china_url + yield OAuthEvent( + "info", + "US endpoint rejected the key and China endpoint is unreachable — " + "configured for China (Beijing). Set DASHSCOPE_BASE_URL if issues persist.", + ) + else: + # Primary is already the China URL and it returned 401 — key is invalid. + yield OAuthEvent( + "error", + "Alibaba API key was not accepted. Ensure your key is valid and " + "comes from the Alibaba Cloud Model Studio console " + "(https://bailian.console.aliyun.com).", + ) + return + else: + 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) + _apply_alibaba_config(config, SecretStr(resolved_key), models=models, base_url=active_url) save_config(config) yield OAuthEvent("success", f"Alibaba configured with model {config.default_model}.") diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index 85d60ae2..e2d32dea 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -206,6 +206,23 @@ async def json(self, *, content_type: str | None = None) -> object: return self._payload +@pytest.mark.asyncio +async def test_discover_alibaba_models_uses_provided_base_url(monkeypatch): + from pythinker_code.auth.alibaba import _discover_alibaba_models + + seen_urls: list[str] = [] + + async def fake_request(*args: object, **kwargs: object) -> object: + seen_urls.append(str(args[2])) + return _FakeAiohttpResponse({"data": []}) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + await _discover_alibaba_models("sk-test", "https://custom.example.com/compatible-mode/v1") + assert len(seen_urls) == 1 + assert "custom.example.com" in seen_urls[0] + + @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 From c2ee6850ba5d1f0e34e0c031fa1eae340f3e27fa Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 22:54:39 -0400 Subject: [PATCH 03/11] test(auth): add China-region fallback + workspace-key detection tests --- src/pythinker_code/auth/alibaba.py | 3 +- tests/auth/test_alibaba_auth.py | 91 ++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py index 5bc50503..e96a731a 100644 --- a/src/pythinker_code/auth/alibaba.py +++ b/src/pythinker_code/auth/alibaba.py @@ -276,7 +276,8 @@ async def login_alibaba_api_key( models = discovered yield OAuthEvent( "info", - "Detected China-region DashScope key; configured for China (Beijing) endpoint.", + "Detected China-region DashScope key; " + "configured for China (Beijing) endpoint.", ) except aiohttp.ClientResponseError as china_exc: if china_exc.status in {401, 403}: diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index e2d32dea..ac35141d 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -294,6 +294,97 @@ async def fake_request(*args: object, **kwargs: object) -> object: assert config.models == {} +@pytest.mark.asyncio +async def test_login_alibaba_china_key_auto_detected(monkeypatch, tmp_path): + """A China-region key that fails on US but succeeds on China is auto-configured.""" + from pythinker_code.auth.alibaba import ALIBABA_CHINA_BASE_URL, login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + url = str(args[2]) + if "dashscope-us" in url: + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + return _FakeAiohttpResponse({"data": [{"id": "qwen3.6-plus", "context_length": 1_000_000}]}) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [event async for event in login_alibaba_api_key(config, "sk-china-key")] + + types = [e.type for e in events] + assert types == ["info", "success"], types + assert "China" in events[0].message + provider = next(iter(config.providers.values())) + assert provider.base_url == ALIBABA_CHINA_BASE_URL + assert config.models["alibaba/qwen3.6-plus"].max_context_size == 1_000_000 + assert (tmp_path / "config.toml").exists() + + +@pytest.mark.asyncio +async def test_login_alibaba_china_probe_network_error_configures_china(monkeypatch, tmp_path): + """When US returns 401 and China is unreachable, we still configure for China.""" + from pythinker_code.auth.alibaba import ALIBABA_CHINA_BASE_URL, login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + url = str(args[2]) + if "dashscope-us" in url: + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + raise aiohttp.ClientConnectionError("China unreachable") + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [event async for event in login_alibaba_api_key(config, "sk-china-key")] + + types = [e.type for e in events] + assert types == ["info", "success"], types + assert "China" in events[0].message + provider = next(iter(config.providers.values())) + assert provider.base_url == ALIBABA_CHINA_BASE_URL + assert "alibaba/qwen3.6-plus" in config.models + assert (tmp_path / "config.toml").exists() + + +@pytest.mark.asyncio +async def test_login_alibaba_primary_already_china_401_fails(monkeypatch, tmp_path): + """When DASHSCOPE_BASE_URL is already China and it returns 401, fail without probing again.""" + from pythinker_code.auth.alibaba import ALIBABA_CHINA_BASE_URL, login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.setenv("DASHSCOPE_BASE_URL", ALIBABA_CHINA_BASE_URL) + config = Config(is_from_default_location=True) + + call_count = 0 + + async def fake_request(*args: object, **kwargs: object) -> object: + nonlocal call_count + call_count += 1 + url = str(args[2]) + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + 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 call_count == 1, "should not probe a second endpoint when primary is already China" + assert config.providers == {} + + @pytest.mark.asyncio async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_path): from pythinker_code.auth.alibaba import login_alibaba_api_key From 96ede6442df56f190166de085a5982a5f0138bdb Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 22:56:02 -0400 Subject: [PATCH 04/11] feat(auth): detect sk-ws- workspace keys and give targeted endpoint guidance --- src/pythinker_code/auth/alibaba.py | 12 +++++- tests/auth/test_alibaba_auth.py | 66 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py index e96a731a..2d141109 100644 --- a/src/pythinker_code/auth/alibaba.py +++ b/src/pythinker_code/auth/alibaba.py @@ -266,7 +266,17 @@ async def login_alibaba_api_key( models = discovered except aiohttp.ClientResponseError as exc: if exc.status in {401, 403}: - # Primary endpoint rejected the key — probe China as a region fallback. + if resolved_key.startswith("sk-ws-"): + # Workspace-scoped keys only work with their dedicated endpoint. + yield OAuthEvent( + "error", + "Workspace-scoped API keys (sk-ws-) require a dedicated endpoint. " + "Set DASHSCOPE_BASE_URL to the API host shown at key creation time. " + "Example: DASHSCOPE_BASE_URL=" + "ws-xxxx.ap-southeast-1.maas.aliyuncs.com", + ) + return + # Non-workspace key: probe China endpoint as a region fallback. china_url = ALIBABA_CHINA_BASE_URL if china_url != primary_url: try: diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index ac35141d..2ce956b4 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -385,6 +385,72 @@ async def fake_request(*args: object, **kwargs: object) -> object: assert config.providers == {} +@pytest.mark.asyncio +async def test_login_alibaba_workspace_key_gives_targeted_error(monkeypatch, tmp_path): + """sk-ws- workspace keys get a targeted error with DASHSCOPE_BASE_URL guidance.""" + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + call_count = 0 + + async def fake_request(*args: object, **kwargs: object) -> object: + nonlocal call_count + call_count += 1 + url = str(args[2]) + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [ + event + async for event in login_alibaba_api_key( + config, "sk-ws-H.HXDYIP.c9u4.abcdefghijklmnopqrstuvwxyz" + ) + ] + + assert events[-1].type == "error" + assert "sk-ws-" in events[-1].message + assert "DASHSCOPE_BASE_URL" in events[-1].message + assert call_count == 1, "should not probe China for workspace keys" + assert config.providers == {} + + +@pytest.mark.asyncio +async def test_login_alibaba_workspace_key_with_correct_base_url_succeeds(monkeypatch, tmp_path): + """sk-ws- key succeeds when DASHSCOPE_BASE_URL is set to the workspace endpoint.""" + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.setenv( + "DASHSCOPE_BASE_URL", + "ws-kopy0du82ky7144q.ap-southeast-1.maas.aliyuncs.com", + ) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + return _FakeAiohttpResponse({"data": [{"id": "qwen3.6-plus"}]}) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [ + event + async for event in login_alibaba_api_key( + config, "sk-ws-H.HXDYIP.c9u4.abcdefghijklmnopqrstuvwxyz" + ) + ] + + assert events[-1].type == "success" + provider = next(iter(config.providers.values())) + assert "ws-kopy0du82ky7144q" in provider.base_url + assert (tmp_path / "config.toml").exists() + + @pytest.mark.asyncio async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_path): from pythinker_code.auth.alibaba import login_alibaba_api_key From 7c9ac009aa7109ca6a1a30ab05affebd08dff664 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 23:16:19 -0400 Subject: [PATCH 05/11] feat(auth): interactive workspace endpoint prompt + update Alibaba model catalog - Prompt for workspace endpoint host in TUI when sk-ws- key is entered, eliminating the need to pre-set DASHSCOPE_BASE_URL for workspace keys - Add base_url param to login_alibaba_api_key for caller-supplied endpoint - Add qwen3.7-plus, qwen3-coder-plus, qwen3-coder-flash to catalog - Remove kimi-k2.5, glm-5, MiniMax-M2.5 (absent from live endpoint) - Correct qwen3.7-max context size to 1M (was 262k) --- src/pythinker_code/auth/alibaba.py | 54 +++++++++++++++------------- src/pythinker_code/ui/shell/oauth.py | 21 ++++++++++- tests/auth/test_alibaba_auth.py | 33 +++++++++-------- 3 files changed, 67 insertions(+), 41 deletions(-) diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py index 2d141109..f0ab1026 100644 --- a/src/pythinker_code/auth/alibaba.py +++ b/src/pythinker_code/auth/alibaba.py @@ -42,8 +42,15 @@ def alias(self) -> str: model_id="qwen3.7-max", alias_suffix="qwen3.7-max", display_name="Qwen3.7 Max", - max_context_size=262_144, - capabilities=frozenset[ModelCapability]({"thinking"}), + max_context_size=1_000_000, + capabilities=frozenset[ModelCapability]({"thinking", "image_in"}), + ), + AlibabaModel( + model_id="qwen3.7-plus", + alias_suffix="qwen3.7-plus", + display_name="Qwen3.7 Plus", + max_context_size=1_000_000, + capabilities=frozenset[ModelCapability]({"thinking", "image_in"}), ), AlibabaModel( model_id="qwen3.6-plus", @@ -59,6 +66,20 @@ def alias(self) -> str: max_context_size=1_000_000, capabilities=frozenset[ModelCapability]({"thinking", "image_in"}), ), + AlibabaModel( + model_id="qwen3-coder-plus", + alias_suffix="qwen3-coder-plus", + display_name="Qwen3 Coder Plus", + max_context_size=1_000_000, + capabilities=frozenset[ModelCapability]({"thinking"}), + ), + AlibabaModel( + model_id="qwen3-coder-flash", + alias_suffix="qwen3-coder-flash", + display_name="Qwen3 Coder Flash", + max_context_size=1_000_000, + capabilities=frozenset[ModelCapability]({"thinking"}), + ), AlibabaModel( model_id="deepseek-v4-pro", alias_suffix="deepseek-v4-pro", @@ -87,13 +108,6 @@ def alias(self) -> str: 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", @@ -101,20 +115,6 @@ def alias(self) -> str: 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"}), - ), ) @@ -242,7 +242,7 @@ async def _discover_alibaba_models(api_key: str, base_url: str) -> tuple[Alibaba async def login_alibaba_api_key( - config: Config, api_key: str | None = None + config: Config, api_key: str | None = None, base_url: str | None = None ) -> AsyncIterator[OAuthEvent]: if not config.is_from_default_location: yield OAuthEvent( @@ -256,7 +256,11 @@ async def login_alibaba_api_key( yield OAuthEvent("error", "Alibaba API key is required.") return - primary_url = get_alibaba_base_url_from_env() + primary_url = ( + _normalize_alibaba_base_url(base_url) + if base_url and base_url.strip() + else get_alibaba_base_url_from_env() + ) active_url = primary_url models = ALIBABA_MODELS diff --git a/src/pythinker_code/ui/shell/oauth.py b/src/pythinker_code/ui/shell/oauth.py index c3ddc391..cd653d79 100644 --- a/src/pythinker_code/ui/shell/oauth.py +++ b/src/pythinker_code/ui/shell/oauth.py @@ -137,6 +137,15 @@ async def _prompt_api_key(label: str) -> str | None: return value.strip() or None +async def _prompt_text(label: str) -> str | None: + session = PromptSession[str]() + try: + value = await session.prompt_async(f" {label}: ") + except (EOFError, KeyboardInterrupt): + return None + return value.strip() or None + + _SELECTOR_PROVIDER_ENTRIES: list[OAuthProviderEntry] = [ OAuthProviderEntry(id="browser", name="OpenAI ChatGPT (browser)", auth_type="oauth"), OAuthProviderEntry(id="headless", name="OpenAI ChatGPT (device code)", auth_type="oauth"), @@ -284,7 +293,17 @@ async def login(app: Shell, args: str) -> None: 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)) + workspace_endpoint: str | None = None + if api_key.startswith("sk-ws-"): + workspace_endpoint = await _prompt_text( + "Workspace endpoint host (e.g. ws-xxxx.ap-southeast-1.maas.aliyuncs.com)" + ) + if not workspace_endpoint: + console.print(f"[{_t.error}]No workspace endpoint entered.[/]") + return + ok = await _render_oauth_events( + login_alibaba_api_key(soul.runtime.config, api_key, base_url=workspace_endpoint) + ) provider = ALIBABA_PLATFORM_ID elif mode == "anthropic": api_key = await _prompt_api_key("Anthropic") diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index 2ce956b4..b8fbe705 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -17,50 +17,53 @@ def test_alibaba_model_catalog_contains_current_models(): aliases = {model.alias for model in ALIBABA_MODELS} assert aliases == { "alibaba/qwen3.7-max", + "alibaba/qwen3.7-plus", "alibaba/qwen3.6-plus", "alibaba/qwen3.6-flash", + "alibaba/qwen3-coder-plus", + "alibaba/qwen3-coder-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.7-plus": "qwen3.7-plus", "alibaba/qwen3.6-plus": "qwen3.6-plus", "alibaba/qwen3.6-flash": "qwen3.6-flash", + "alibaba/qwen3-coder-plus": "qwen3-coder-plus", + "alibaba/qwen3-coder-flash": "qwen3-coder-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.7-max"].capabilities == frozenset({"thinking", "image_in"}) + assert by_alias["alibaba/qwen3.7-max"].max_context_size == 1_000_000 + assert by_alias["alibaba/qwen3.7-plus"].capabilities == frozenset({"thinking", "image_in"}) + assert by_alias["alibaba/qwen3.7-plus"].max_context_size == 1_000_000 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/qwen3-coder-plus"].capabilities == frozenset({"thinking"}) + assert by_alias["alibaba/qwen3-coder-plus"].max_context_size == 1_000_000 + assert by_alias["alibaba/qwen3-coder-flash"].capabilities == frozenset({"thinking"}) + assert by_alias["alibaba/qwen3-coder-flash"].max_context_size == 1_000_000 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): @@ -127,7 +130,7 @@ def test_apply_alibaba_config_writes_provider_and_default(): 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.7-max"].capabilities == frozenset({"thinking", "image_in"}) assert config.models["alibaba/qwen3.6-plus"].capabilities == frozenset({"thinking", "image_in"}) assert config.models["alibaba/qwen3.6-flash"].capabilities == frozenset( {"thinking", "image_in"} @@ -135,7 +138,7 @@ def test_apply_alibaba_config_writes_provider_and_default(): 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 "alibaba/minimax-m2.5" not in config.models assert config.default_model == "alibaba/qwen3.6-plus" assert config.default_thinking is True assert config.default_thinking_effort == "high" @@ -469,7 +472,7 @@ async def fake_request(*args: object, **kwargs: object) -> object: 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"}) + assert config.models["alibaba/qwen3.7-max"].capabilities == frozenset({"thinking", "image_in"}) @pytest.mark.asyncio @@ -518,7 +521,7 @@ def test_parse_discovered_alibaba_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["qwen3.7-max"].max_context_size == 262_144 + assert by_id["qwen3.7-max"].max_context_size == 1_000_000 assert by_id["qwen3.6-plus"].max_context_size == 1_000_000 assert by_id["deepseek-v3.2"].max_context_size == 512_000 From b8783a80bbbf70b9c04e9bff8cb0a8d8f2565b3c Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 23:18:41 -0400 Subject: [PATCH 06/11] chore(release): prepare 0.36.0 --- CHANGELOG.md | 8 +++++ README.md | 44 +++++++++++------------ docs/en/guides/getting-started.md | 2 +- docs/en/release-notes/breaking-changes.md | 4 +++ docs/en/release-notes/changelog.md | 8 +++++ packages/linux-installer/README.md | 26 +++++++------- pyproject.toml | 2 +- uv.lock | 2 +- 8 files changed, 58 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99c7a3e7..fe70ec08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +## 0.36.0 (2026-06-05) + +- **Alibaba DashScope multi-region fallback.** Logging in with a China-region key (`dashscope.aliyuncs.com`) against the default US Virginia endpoint now auto-detects the mismatch and reconfigures for the correct endpoint rather than failing with a misleading "API key is wrong" error. +- **Workspace-scoped key support (`sk-ws-`).** `/login alibaba` now prompts for the dedicated workspace endpoint host when it detects a `sk-ws-` key, so users no longer need to pre-export `DASHSCOPE_BASE_URL`. The `login_alibaba_api_key` function also accepts an explicit `base_url` parameter. +- **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. + +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). + ## 0.35.0 (2026-06-04) - **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. diff --git a/README.md b/README.md index 3a8b8351..cdbe87c8 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,13 @@ It speaks the [**Agent Client Protocol (ACP)**](https://github.com/agentclientpr --- -## 🆕 What's New in 0.35.0 +## 🆕 What's New in 0.36.0 -- **`/stats` usage dashboard.** New slash command opens an interactive TUI showing token and cost breakdown by provider/model across Today / This Week / Last Week / All Time. Powered by a static pricing table and a session collector that walks `~/.pythinker/sessions/` wire files. -- **Z AI provider auth.** Login/logout via API key, model discovery, and OAuth selector wired into the TUI and `refresh_managed_models`. -- **Moonshot provider auth.** Login/logout via API key, model discovery (Kimi K2.x catalog), OAuth selector wired into the TUI and `refresh_managed_models`. +- **Alibaba DashScope multi-region fallback.** China-region keys now auto-detect the endpoint mismatch and reconfigure correctly instead of showing a misleading "API key is wrong" error. +- **Workspace-scoped key support (`sk-ws-`).** `/login alibaba` now prompts for the workspace endpoint host interactively — no need to pre-export `DASHSCOPE_BASE_URL`. +- **Alibaba model catalog refresh.** Qwen3.7 Plus, Qwen3 Coder Plus, and Qwen3 Coder Flash added; deprecated `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` removed. -Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.35.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). --- @@ -146,7 +146,7 @@ matches your OS — no Python, Node, or `uv` prerequisite. | Platform | Recommended install | Artifact source | |---|---|---| -| **🪟 Windows** | `irm https://pythinker.com/install.ps1 \| iex` | `PythinkerSetup-0.35.0.exe` from [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | +| **🪟 Windows** | `irm https://pythinker.com/install.ps1 \| iex` | `PythinkerSetup-0.36.0.exe` from [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | | **macOS / Linux** | `curl -fsSL https://pythinker.com/install.sh \| bash` | native tarball from [Releases](https://github.com/Pythoughts-labs/pythinker-code/releases/latest) | | **macOS — Homebrew** | `brew install Pythoughts-labs/pythinker/pythinker-code` | auto-published Homebrew tap | | **🐳 Docker** | `docker run --rm -it ghcr.io/pythoughts-labs/pythinker-code` | GHCR multi-arch image | @@ -174,7 +174,7 @@ pythinker # start the interactive TUI ### 🪟 Windows — native installer -`PythinkerSetup-0.35.0.exe` is a signed* Inno Setup wizard. Installs per-user +`PythinkerSetup-0.36.0.exe` is a signed* Inno Setup wizard. Installs per-user into `%LOCALAPPDATA%\Programs\Pythinker`, registers `pythinker` on your user PATH (`HKCU\Environment`), broadcasts `WM_SETTINGCHANGE` so new shells see the change. **No UAC prompt.** @@ -185,13 +185,13 @@ irm https://pythinker.com/install.ps1 | iex # Or manually download the installer + checksum from the Releases page, # verify with Get-FileHash, then run: -.\PythinkerSetup-0.35.0.exe +.\PythinkerSetup-0.36.0.exe # Open a fresh PowerShell pythinker --version ``` -**Per-machine install** (IT-managed boxes): `.\PythinkerSetup-0.35.0.exe /ALLUSERS` +**Per-machine install** (IT-managed boxes): `.\PythinkerSetup-0.36.0.exe /ALLUSERS` installs to `%ProgramFiles%\Pythinker` and writes PATH to HKLM (requires admin). **Upgrade:** `pythinker update` from inside the running app — it downloads @@ -242,26 +242,26 @@ attached to every GitHub Release. ```sh # Debian / Ubuntu (x86_64) -sudo dpkg -i pythinker-code_0.35.0_amd64.deb +sudo dpkg -i pythinker-code_0.36.0_amd64.deb sudo apt-get install -f # only if dpkg reports missing deps # Debian / Ubuntu (ARM64) -sudo dpkg -i pythinker-code_0.35.0_arm64.deb +sudo dpkg -i pythinker-code_0.36.0_arm64.deb # Fedora / RHEL / openSUSE (x86_64) -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code-0.35.0.x86_64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code-0.35.0.x86_64.rpm.sha256 -sha256sum -c pythinker-code-0.35.0.x86_64.rpm.sha256 +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code-0.36.0.x86_64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code-0.36.0.x86_64.rpm.sha256 +sha256sum -c pythinker-code-0.36.0.x86_64.rpm.sha256 # Fedora / RHEL: -sudo dnf install ./pythinker-code-0.35.0.x86_64.rpm +sudo dnf install ./pythinker-code-0.36.0.x86_64.rpm # openSUSE: -sudo zypper install ./pythinker-code-0.35.0.x86_64.rpm +sudo zypper install ./pythinker-code-0.36.0.x86_64.rpm # Fedora / RHEL (aarch64) -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code-0.35.0.aarch64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code-0.35.0.aarch64.rpm.sha256 -sha256sum -c pythinker-code-0.35.0.aarch64.rpm.sha256 -sudo dnf install ./pythinker-code-0.35.0.aarch64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code-0.36.0.aarch64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code-0.36.0.aarch64.rpm.sha256 +sha256sum -c pythinker-code-0.36.0.aarch64.rpm.sha256 +sudo dnf install ./pythinker-code-0.36.0.aarch64.rpm ``` Both packages drop a small `/usr/bin/pythinker` launcher that execs the real @@ -270,8 +270,8 @@ binary under `/usr/lib/pythinker/`, so your `$PATH` stays tidy. **Verify before install:** ```sh -sha256sum -c pythinker-code_0.35.0_amd64.deb.sha256 # Debian/Ubuntu -sha256sum -c pythinker-code-0.35.0.x86_64.rpm.sha256 # Fedora/RHEL +sha256sum -c pythinker-code_0.36.0_amd64.deb.sha256 # Debian/Ubuntu +sha256sum -c pythinker-code-0.36.0.x86_64.rpm.sha256 # Fedora/RHEL ``` **Upgrade:** download the new `.deb`/`.rpm` from Releases and `dpkg -i` / diff --git a/docs/en/guides/getting-started.md b/docs/en/guides/getting-started.md index c9fc968c..e34c51fc 100644 --- a/docs/en/guides/getting-started.md +++ b/docs/en/guides/getting-started.md @@ -44,7 +44,7 @@ On Windows, run the PowerShell bootstrap. It downloads the native installer, ver irm https://pythinker.com/install.ps1 | iex ``` -You can also download `PythinkerSetup-0.35.0.exe` manually from the [latest release](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). +You can also download `PythinkerSetup-0.36.0.exe` manually from the [latest release](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). Verify the installation: diff --git a/docs/en/release-notes/breaking-changes.md b/docs/en/release-notes/breaking-changes.md index 07f86020..8f7a3994 100644 --- a/docs/en/release-notes/breaking-changes.md +++ b/docs/en/release-notes/breaking-changes.md @@ -4,6 +4,10 @@ This page documents breaking changes in Pythinker Code releases and provides mig ## Unreleased +## 0.36.0 (2026-06-05) + +No breaking changes. This release is compatible with 0.35.0 user configuration, native installs, and session data. + ## 0.35.0 (2026-06-04) ## 0.34.0 (2026-06-03) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 00ccdf3a..0e0d62d7 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,14 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +## 0.36.0 (2026-06-05) + +- **Alibaba DashScope multi-region fallback.** Logging in with a China-region key (`dashscope.aliyuncs.com`) against the default US Virginia endpoint now auto-detects the mismatch and reconfigures for the correct endpoint rather than failing with a misleading "API key is wrong" error. +- **Workspace-scoped key support (`sk-ws-`).** `/login alibaba` now prompts for the dedicated workspace endpoint host when it detects a `sk-ws-` key, so users no longer need to pre-export `DASHSCOPE_BASE_URL`. The `login_alibaba_api_key` function also accepts an explicit `base_url` parameter. +- **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. + +Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). + ## 0.35.0 (2026-06-04) - **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. diff --git a/packages/linux-installer/README.md b/packages/linux-installer/README.md index d261dda4..b6dcce0a 100644 --- a/packages/linux-installer/README.md +++ b/packages/linux-installer/README.md @@ -8,19 +8,19 @@ End-user install from the current GitHub Release: ```sh # Debian / Ubuntu -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code_0.35.0_amd64.deb -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code_0.35.0_amd64.deb.sha256 -sha256sum -c pythinker-code_0.35.0_amd64.deb.sha256 -sudo dpkg -i pythinker-code_0.35.0_amd64.deb +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code_0.36.0_amd64.deb +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code_0.36.0_amd64.deb.sha256 +sha256sum -c pythinker-code_0.36.0_amd64.deb.sha256 +sudo dpkg -i pythinker-code_0.36.0_amd64.deb sudo apt-get install -f # only needed if dependencies fail to resolve # Fedora / RHEL / openSUSE -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code-0.35.0.x86_64.rpm -curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.35.0/pythinker-code-0.35.0.x86_64.rpm.sha256 -sha256sum -c pythinker-code-0.35.0.x86_64.rpm.sha256 -sudo dnf install ./pythinker-code-0.35.0.x86_64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code-0.36.0.x86_64.rpm +curl -LO https://github.com/Pythoughts-labs/pythinker-code/releases/download/v0.36.0/pythinker-code-0.36.0.x86_64.rpm.sha256 +sha256sum -c pythinker-code-0.36.0.x86_64.rpm.sha256 +sudo dnf install ./pythinker-code-0.36.0.x86_64.rpm # or, on openSUSE: -sudo zypper install ./pythinker-code-0.35.0.x86_64.rpm +sudo zypper install ./pythinker-code-0.36.0.x86_64.rpm ``` The package drops a single executable at `/usr/bin/pythinker` and a license @@ -36,17 +36,17 @@ file at `/usr/share/doc/pythinker-code/LICENSE`. ## Build ```sh -bash packages/linux-installer/build.sh 0.35.0 +bash packages/linux-installer/build.sh 0.36.0 ``` Outputs to `dist/`: -- `pythinker-code_0.35.0_amd64.deb` -- `pythinker-code-0.35.0.x86_64.rpm` +- `pythinker-code_0.36.0_amd64.deb` +- `pythinker-code-0.36.0.x86_64.rpm` The portable tarball used by `scripts/install-native.sh` is published by the existing `release-pythinker-cli.yml` workflow under the cargo-dist -target-triple naming (e.g. `pythinker-0.35.0-x86_64-unknown-linux-gnu.tar.gz`). +target-triple naming (e.g. `pythinker-0.36.0-x86_64-unknown-linux-gnu.tar.gz`). ## CI diff --git a/pyproject.toml b/pyproject.toml index 54fbf20b..3b45c02c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pythinker-code" -version = "0.35.0" +version = "0.36.0" description = "Pythinker — an agentic CLI developed by Pythoughts-labs." readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index 66d2a1b5..2fde3077 100644 --- a/uv.lock +++ b/uv.lock @@ -2515,7 +2515,7 @@ wheels = [ [[package]] name = "pythinker-code" -version = "0.35.0" +version = "0.36.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, From a15f19c7d41296d1bb73b8b955f129109cfc60db Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 23:28:38 -0400 Subject: [PATCH 07/11] fix(llm): use enable_thinking for Qwen models on DashScope DashScope's OpenAI-compatible API uses extra_body={"enable_thinking": true/false} for Qwen thinking models. Sending reasoning_effort triggers RouteError: Service route not found (HTTP 500). Treat Qwen like Kimi K2/GLM: skip with_thinking() and send the provider-specific toggle instead. --- src/pythinker_code/llm.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index 4a484b36..3323e1b0 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -353,11 +353,14 @@ 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) is_glm_openai_legacy = provider.type == "openai_legacy" and _is_glm_model(model.model) + # Qwen models on DashScope use enable_thinking, not OpenAI's reasoning_effort. + is_qwen_openai_legacy = provider.type == "openai_legacy" and _is_qwen_model(model.model) if ( effective_effort is not None and supports_thinking and not is_kimi_openai_legacy and not is_glm_openai_legacy + and not is_qwen_openai_legacy ): # Only explicitly send thinking controls for models that advertise # reasoning. Some OpenAI-compatible non-reasoning models reject even a @@ -379,6 +382,13 @@ def create_llm( extra_body={"thinking": thinking_body} ) + # Qwen models on DashScope's OpenAI-compatible endpoint use enable_thinking + # in extra_body. Sending reasoning_effort triggers RouteError: Service route not found. + if is_qwen_openai_legacy and effective_effort is not None: + chat_provider = cast(Any, chat_provider).with_generation_kwargs( + extra_body={"enable_thinking": thinking_on} + ) + # Apply Pythinker AI-specific ``thinking.keep`` (preserved thinking) only when # the model is actually in thinking mode; otherwise the API would see a # ``thinking.keep`` without an accompanying ``thinking.type`` it honors. @@ -467,6 +477,10 @@ def _is_glm_model(model_name: str) -> bool: return model_name.lower().replace("_", "-").startswith("glm-") +def _is_qwen_model(model_name: str) -> bool: + return model_name.lower().replace("_", "-").startswith("qwen") + + def _load_scripted_echo_scripts() -> list[str]: script_path = os.getenv("PYTHINKER_SCRIPTED_ECHO_SCRIPTS") if not script_path: From 350b3ce4deab1b93ac65da384594e152a53c1d8a Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Fri, 5 Jun 2026 23:33:24 -0400 Subject: [PATCH 08/11] fix(llm): use enable_thinking for all DashScope reasoning models Extend the Qwen-only fix to cover all models accessed via DashScope endpoints (kimi-k2.6, GLM, DeepSeek, etc.) using URL-based detection. --- src/pythinker_code/llm.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index 3323e1b0..c0c5366b 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -351,26 +351,36 @@ def create_llm( effective_effort = "off" if requested_effort is not None else None thinking_on = thinking_effort_enabled(effective_effort) - is_kimi_openai_legacy = provider.type == "openai_legacy" and _is_kimi_k2_model(model.model) - is_glm_openai_legacy = provider.type == "openai_legacy" and _is_glm_model(model.model) - # Qwen models on DashScope use enable_thinking, not OpenAI's reasoning_effort. - is_qwen_openai_legacy = provider.type == "openai_legacy" and _is_qwen_model(model.model) + # DashScope's OpenAI-compatible endpoint uses enable_thinking across ALL + # reasoning models (Qwen, Kimi, GLM, DeepSeek). Sending reasoning_effort + # or the Moonshot {"thinking": {"type": ...}} body triggers RouteError 500. + is_dashscope_legacy = provider.type == "openai_legacy" and _is_dashscope_endpoint( + provider.base_url or "" + ) + # Kimi K2 and GLM via non-DashScope providers (Z AI, Moonshot direct) use + # the provider-specific thinking.type body field. + is_kimi_openai_legacy = ( + provider.type == "openai_legacy" + and _is_kimi_k2_model(model.model) + and not is_dashscope_legacy + ) + is_glm_openai_legacy = ( + provider.type == "openai_legacy" and _is_glm_model(model.model) and not is_dashscope_legacy + ) if ( effective_effort is not None and supports_thinking and not is_kimi_openai_legacy and not is_glm_openai_legacy - and not is_qwen_openai_legacy + and not is_dashscope_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 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. + # Kimi K2.5/K2.6 and GLM on non-DashScope providers (Z AI, Moonshot) use + # the provider-specific `thinking.type` body field, not reasoning_effort. 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: @@ -382,9 +392,8 @@ def create_llm( extra_body={"thinking": thinking_body} ) - # Qwen models on DashScope's OpenAI-compatible endpoint use enable_thinking - # in extra_body. Sending reasoning_effort triggers RouteError: Service route not found. - if is_qwen_openai_legacy and effective_effort is not None: + # DashScope models (Qwen, Kimi K2, GLM, DeepSeek) all use enable_thinking. + if is_dashscope_legacy and effective_effort is not None: chat_provider = cast(Any, chat_provider).with_generation_kwargs( extra_body={"enable_thinking": thinking_on} ) @@ -477,8 +486,9 @@ def _is_glm_model(model_name: str) -> bool: return model_name.lower().replace("_", "-").startswith("glm-") -def _is_qwen_model(model_name: str) -> bool: - return model_name.lower().replace("_", "-").startswith("qwen") +def _is_dashscope_endpoint(base_url: str) -> bool: + """True for any Alibaba DashScope endpoint (standard, intl, workspace).""" + return "aliyuncs.com" in base_url def _load_scripted_echo_scripts() -> list[str]: From a6b83f5b36b0cf4a12b0b960a2f06db61eaf500f Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sat, 6 Jun 2026 04:42:15 -0400 Subject: [PATCH 09/11] fix(llm): use Moonshot thinking format for kimi-k2.6 on DashScope kimi-k2.6 uses {"thinking": {"type": ...}} on all providers including DashScope workspace; enable_thinking is Qwen-only. Remove the DashScope exclusion from is_kimi_openai_legacy/is_glm_openai_legacy so the correct Moonshot body format is applied regardless of endpoint. --- src/pythinker_code/llm.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index c0c5366b..b91fcc9c 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -351,22 +351,16 @@ def create_llm( effective_effort = "off" if requested_effort is not None else None thinking_on = thinking_effort_enabled(effective_effort) - # DashScope's OpenAI-compatible endpoint uses enable_thinking across ALL - # reasoning models (Qwen, Kimi, GLM, DeepSeek). Sending reasoning_effort - # or the Moonshot {"thinking": {"type": ...}} body triggers RouteError 500. + # DashScope's routing layer rejects reasoning_effort entirely; use + # model-specific body fields instead. is_dashscope_legacy = provider.type == "openai_legacy" and _is_dashscope_endpoint( provider.base_url or "" ) - # Kimi K2 and GLM via non-DashScope providers (Z AI, Moonshot direct) use - # the provider-specific thinking.type body field. - is_kimi_openai_legacy = ( - provider.type == "openai_legacy" - and _is_kimi_k2_model(model.model) - and not is_dashscope_legacy - ) - is_glm_openai_legacy = ( - provider.type == "openai_legacy" and _is_glm_model(model.model) and not is_dashscope_legacy - ) + # Kimi K2.x and GLM use {"thinking": {"type": ...}} on ALL providers + # (Moonshot direct, Z AI, and DashScope workspace). reasoning_effort is + # never sent for these models regardless of provider. + is_kimi_openai_legacy = provider.type == "openai_legacy" and _is_kimi_k2_model(model.model) + is_glm_openai_legacy = provider.type == "openai_legacy" and _is_glm_model(model.model) if ( effective_effort is not None and supports_thinking @@ -379,8 +373,8 @@ def create_llm( # null reasoning_effort field. chat_provider = chat_provider.with_thinking(effective_effort) - # Kimi K2.5/K2.6 and GLM on non-DashScope providers (Z AI, Moonshot) use - # the provider-specific `thinking.type` body field, not reasoning_effort. + # Kimi K2.x and GLM use {"thinking": {"type": ...}} on every provider + # (Moonshot, Z AI, and DashScope workspace all accept this format). 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: @@ -392,8 +386,14 @@ def create_llm( extra_body={"thinking": thinking_body} ) - # DashScope models (Qwen, Kimi K2, GLM, DeepSeek) all use enable_thinking. - if is_dashscope_legacy and effective_effort is not None: + # Qwen thinking models on DashScope use enable_thinking (not reasoning_effort). + # Other DashScope-proxied models (Kimi, GLM, DeepSeek) use their own formats above. + if ( + is_dashscope_legacy + and not is_kimi_openai_legacy + and not is_glm_openai_legacy + and effective_effort is not None + ): chat_provider = cast(Any, chat_provider).with_generation_kwargs( extra_body={"enable_thinking": thinking_on} ) From ee1cc74cf51e6ec896c469fabc509a954dd1be37 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sat, 6 Jun 2026 12:10:48 -0400 Subject: [PATCH 10/11] fix: harden Alibaba auth and agent guardrails --- CHANGELOG.md | 2 +- README.md | 2 +- docs/en/release-notes/changelog.md | 2 +- .../agents/default/code_reviewer.yaml | 2 + src/pythinker_code/agents/default/judge.yaml | 1 + src/pythinker_code/agents/default/review.yaml | 2 + .../agents/default/security_reviewer.yaml | 1 + src/pythinker_code/agents/default/system.md | 22 ++ src/pythinker_code/auth/alibaba.py | 164 ++++++++++--- src/pythinker_code/llm.py | 27 +- src/pythinker_code/ui/shell/oauth.py | 12 +- tasks/todo.md | 37 +++ tests/auth/test_alibaba_auth.py | 230 ++++++++++++++++-- tests/core/test_agent_spec.py | 20 ++ tests/core/test_create_llm.py | 46 ++++ tests/core/test_default_agent.py | 11 + tests/ui_and_conv/test_openai_shell_login.py | 22 ++ 17 files changed, 529 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe70ec08..f2a23ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## 0.36.0 (2026-06-05) - **Alibaba DashScope multi-region fallback.** Logging in with a China-region key (`dashscope.aliyuncs.com`) against the default US Virginia endpoint now auto-detects the mismatch and reconfigures for the correct endpoint rather than failing with a misleading "API key is wrong" error. -- **Workspace-scoped key support (`sk-ws-`).** `/login alibaba` now prompts for the dedicated workspace endpoint host when it detects a `sk-ws-` key, so users no longer need to pre-export `DASHSCOPE_BASE_URL`. The `login_alibaba_api_key` function also accepts an explicit `base_url` parameter. +- **Alibaba Token Plan compatibility (`sk-ws-`).** `/login alibaba` now requires the dedicated workspace Base URL shown in the Token Plan console instead of accepting a public `/models` response as credential validation. Dedicated workspace endpoints hide Kimi K2.6 when Alibaba advertises it without a working route, and use non-streaming Chat Completions for DeepSeek V3.2 because those endpoints return an empty SSE stream. Kimi requests on other Alibaba routes use DashScope's `enable_thinking` parameter. - **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). diff --git a/README.md b/README.md index cdbe87c8..472e6ecd 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ It speaks the [**Agent Client Protocol (ACP)**](https://github.com/agentclientpr ## 🆕 What's New in 0.36.0 - **Alibaba DashScope multi-region fallback.** China-region keys now auto-detect the endpoint mismatch and reconfigure correctly instead of showing a misleading "API key is wrong" error. -- **Workspace-scoped key support (`sk-ws-`).** `/login alibaba` now prompts for the workspace endpoint host interactively — no need to pre-export `DASHSCOPE_BASE_URL`. +- **Alibaba Token Plan compatibility (`sk-ws-`).** `/login alibaba` now asks for the dedicated workspace endpoint, avoids unroutable Kimi entries on those endpoints, and uses DeepSeek V3.2's working non-streaming mode. - **Alibaba model catalog refresh.** Qwen3.7 Plus, Qwen3 Coder Plus, and Qwen3 Coder Flash added; deprecated `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` removed. Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 0e0d62d7..75295dbb 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -20,7 +20,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## 0.36.0 (2026-06-05) - **Alibaba DashScope multi-region fallback.** Logging in with a China-region key (`dashscope.aliyuncs.com`) against the default US Virginia endpoint now auto-detects the mismatch and reconfigures for the correct endpoint rather than failing with a misleading "API key is wrong" error. -- **Workspace-scoped key support (`sk-ws-`).** `/login alibaba` now prompts for the dedicated workspace endpoint host when it detects a `sk-ws-` key, so users no longer need to pre-export `DASHSCOPE_BASE_URL`. The `login_alibaba_api_key` function also accepts an explicit `base_url` parameter. +- **Alibaba Token Plan compatibility (`sk-ws-`).** `/login alibaba` now requires the dedicated workspace Base URL shown in the Token Plan console instead of accepting a public `/models` response as credential validation. Dedicated workspace endpoints hide Kimi K2.6 when Alibaba advertises it without a working route, and use non-streaming Chat Completions for DeepSeek V3.2 because those endpoints return an empty SSE stream. Kimi requests on other Alibaba routes use DashScope's `enable_thinking` parameter. - **Alibaba model catalog refresh.** Added Qwen3.7 Plus (1M context), Qwen3 Coder Plus, and Qwen3 Coder Flash. Removed `kimi-k2.5`, `glm-5`, and `MiniMax-M2.5` (absent from the live endpoint). Corrected Qwen3.7 Max context window to 1M tokens. Upgrade with `pythinker update`, `pip install --upgrade pythinker-code==0.36.0`, or use the native installer for your platform from the [Releases page](https://github.com/Pythoughts-labs/pythinker-code/releases/latest). diff --git a/src/pythinker_code/agents/default/code_reviewer.yaml b/src/pythinker_code/agents/default/code_reviewer.yaml index d1ad020e..510423a2 100644 --- a/src/pythinker_code/agents/default/code_reviewer.yaml +++ b/src/pythinker_code/agents/default/code_reviewer.yaml @@ -26,6 +26,8 @@ agent: - Build a review context packet: base ref/diff scope or Reviewflow feature IDs, changed behavior, likely tests, user-visible impact, valid evidence paths, omitted/truncated context, and validation evidence. - Flag only issues introduced or made reachable by the diff. - Prefer no finding over vague speculation. Every finding must cite concrete evidence and a failure mode. + - Run the production guardrail gate before finalizing: check for cache stampedes, connection/resource leaks, missing boundary schemas, unhandled race conditions, naive retry loops, unbounded event callbacks/listeners, and IDOR/tenant-scope mistakes. + - Treat missing `finally` cleanup, absent schema validation at trust boundaries, unprotected shared-state mutation, non-jittered immediate retries, or identity from mutable client parameters as reject-level findings when reachable in the changed code. Freshness check (run BEFORE flagging third-party library or framework misuse): - For every third-party API, SDK call, framework primitive, or "best practice" the diff turns on, verify the current canonical usage. Prefer a context7 MCP query (e.g. `mcp__context7__query-docs` with the library id) when registered with the parent runtime; otherwise use `SearchWeb` to locate the official documentation and `FetchURL` to read the current page. diff --git a/src/pythinker_code/agents/default/judge.yaml b/src/pythinker_code/agents/default/judge.yaml index 0be35baf..22cb6e25 100644 --- a/src/pythinker_code/agents/default/judge.yaml +++ b/src/pythinker_code/agents/default/judge.yaml @@ -14,6 +14,7 @@ agent: - Fidelity: the draft summary matches the actual diff and changes, with no overclaiming. - Verification: the checks the parent ran are relevant to the change and actually ran, not assumed. - Safety and scope: no unsafe or destructive action, no secret or PII exposure, no scope creep beyond the request. + - Production guardrails: changed code that touches caches, resources, trust boundaries, shared state, outbound requests, long-lived listeners, or authorization context has explicit defenses for stampedes, cleanup, schemas, races, retry storms, leaks, and IDOR risks. - Findings quality: for reports, each finding is actionable, correctly severity-ranked, and anchored to evidence. Do not rubber-stamp, and do not pad: prefer a few concrete blockers over broad style notes. diff --git a/src/pythinker_code/agents/default/review.yaml b/src/pythinker_code/agents/default/review.yaml index c0d7ba27..b6569831 100644 --- a/src/pythinker_code/agents/default/review.yaml +++ b/src/pythinker_code/agents/default/review.yaml @@ -16,6 +16,8 @@ agent: - If `.pythinker/review-guidelines.md` exists, read it before scoring findings. - Read the diff or target files before scoring. - Use Grep/Glob to check sibling call sites, similar patterns, and existing tests. + - Apply the production guardrail gate: look specifically for cache stampedes, connection/resource leaks, missing boundary schemas, unhandled race conditions, naive retry loops, unbounded event callbacks/listeners, and IDOR/tenant-scope mistakes. + - Reject happy-path code as BLOCKER or MAJOR when the changed path mutates shared state, crosses a trust boundary, acquires resources, retries outbound calls, or registers long-lived callbacks without the matching defensive pattern. - Score each finding as BLOCKER, MAJOR, MINOR, or NIT. - Order findings by severity, BLOCKER first. - Do not request tests unless they cover a distinct behavior or risk introduced by the change. diff --git a/src/pythinker_code/agents/default/security_reviewer.yaml b/src/pythinker_code/agents/default/security_reviewer.yaml index 3d341012..c46315e2 100644 --- a/src/pythinker_code/agents/default/security_reviewer.yaml +++ b/src/pythinker_code/agents/default/security_reviewer.yaml @@ -9,6 +9,7 @@ agent: Security review discipline: - Build a threat context before judging: changed trust boundaries, inputs/outputs, authz/authn, filesystem/network access, secrets, serialization, command execution, and persistence. + - Apply the production guardrail gate to security-relevant changes: reject missing boundary schemas, IDOR/tenant-scope mistakes, unprotected shared-state mutations, unsafe retries for non-idempotent outbound calls, and resource leaks that can become denial-of-service vectors. - Report only reachable or plausibly reachable vulnerabilities backed by evidence. Prefer no finding over speculative risk. - For each finding, include exploit preconditions, impact, severity rationale, and the smallest safe mitigation. - Treat secrets/PII carefully: never print raw secret values; redact if needed. diff --git a/src/pythinker_code/agents/default/system.md b/src/pythinker_code/agents/default/system.md index d7a13ff0..938facf3 100644 --- a/src/pythinker_code/agents/default/system.md +++ b/src/pythinker_code/agents/default/system.md @@ -186,6 +186,28 @@ Code quality defaults (unless project or domain rules override): - Wrap error-prone I/O, API, network, and resource operations with appropriate error handling, timeouts/fallbacks, and cleanup. - Adapt to domain standards when relevant (for example stricter MISRA-style practices for critical C/C++ systems). +## Production Bug Guardrails + +When generating, changing, reviewing, or approving production-facing code, optimize for failure modes first: concurrency, resource cleanup, input boundaries, authorization context, data integrity, and retry behavior. Never assume single-threaded, trusted, or low-traffic execution when the code can run in a shared service. + +Mandatory defensive patterns: + +1. **Cache misses:** If adding cache-aside behavior, serialize identical misses with a local or distributed double-checked lock so concurrent misses do not stampede the backing store. +2. **Resource acquisition:** For database clients, transactions, streams, sockets, files, and connection pools, acquire immediately before a `try` block and guarantee release/close in `finally`. Transactions that fail must explicitly roll back before release. +3. **API and webhook boundaries:** Validate runtime inputs at the boundary with the project's schema/validation mechanism, strip or ignore unregistered fields, bound payload sizes/types where relevant, and never pass raw request bodies directly into persistence or business logic. +4. **State mutations and counters:** For increments, decrements, toggles, balances, inventory, likes, and unique relationships, use atomic conflict handling plus row-level serialization (`FOR UPDATE`) or optimistic version checks inside transactions. +5. **Outbound requests:** Use short explicit timeouts, exponential backoff with random jitter, and avoid retry storms. Non-idempotent outbound mutations need an idempotency key/header or an explicit reason they cannot safely be retried. +6. **Long-lived listeners:** Every subscription, event listener, websocket, interval, timer, and background callback needs symmetric cleanup (`unsubscribe`, `off`, `close`, `clearInterval`, or equivalent). Clean up empty maps/registries to avoid leaks. +7. **Authorization context:** Use verified cryptographic/session identity (`req.user`, validated token claims, server-side session) for user/account/tenant scope. Never trust mutable query/body/path parameters as the authority for identity when verified context exists. + +Self-correction pre-flight before calling code done: + +- **Concurrency:** If 1,000 requests hit this path simultaneously, what shared resource races or stampedes? +- **Resources:** If an exception is raised after acquisition, is every socket/connection/stream/listener guaranteed to close? +- **Security:** Is identity or tenant scope derived only from verified auth context, not mutable client parameters? +- **Data integrity:** What happens with oversized strings, wrong types, duplicate submits, or malicious payload shape? +- **Resilience:** If a dependency is slow or failing, do timeouts/retries prevent cascading load rather than amplify it? + DO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations. # General Guidelines for Research and Data Processing diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py index f0ab1026..5faf9cfd 100644 --- a/src/pythinker_code/auth/alibaba.py +++ b/src/pythinker_code/auth/alibaba.py @@ -4,6 +4,7 @@ from collections.abc import AsyncIterator from dataclasses import dataclass from typing import Any, cast +from urllib.parse import urlparse import aiohttp from pydantic import SecretStr @@ -18,6 +19,7 @@ ALIBABA_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1" ALIBABA_CHINA_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +ALIBABA_CODING_PLAN_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/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) @@ -126,12 +128,18 @@ def get_alibaba_api_key_from_env() -> str | None: return None -def _normalize_alibaba_base_url(value: str) -> str: +def _normalize_alibaba_base_url(value: str, is_coding_plan: bool = False) -> str: base_url = value.strip().rstrip("/") if not base_url: - return ALIBABA_BASE_URL + return ALIBABA_CODING_PLAN_BASE_URL if is_coding_plan else ALIBABA_BASE_URL if "://" not in base_url: base_url = f"https://{base_url}" + + if is_coding_plan: + if base_url.endswith("/v1"): + return base_url + return f"{base_url}/v1" + if base_url.endswith("/api/v1"): return f"{base_url.removesuffix('/api/v1')}/compatible-mode/v1" if base_url.endswith("/compatible-mode/v1"): @@ -228,6 +236,11 @@ def _parse_discovered_models(data: object) -> tuple[AlibabaModel, ...]: return tuple(results) +def _is_workspace_endpoint(base_url: str) -> bool: + hostname = urlparse(base_url).hostname or "" + return hostname.startswith("ws-") and hostname.endswith(".maas.aliyuncs.com") + + async def _discover_alibaba_models(api_key: str, base_url: str) -> tuple[AlibabaModel, ...]: async with ( new_client_session(timeout=ALIBABA_MODEL_DISCOVERY_TIMEOUT) as session, @@ -256,11 +269,32 @@ async def login_alibaba_api_key( yield OAuthEvent("error", "Alibaba API key is required.") return - primary_url = ( - _normalize_alibaba_base_url(base_url) - if base_url and base_url.strip() - else get_alibaba_base_url_from_env() + is_coding_plan = resolved_key.startswith("sk-sp-") + is_token_plan = resolved_key.startswith("sk-ws-") + + default_url = ALIBABA_CODING_PLAN_BASE_URL if is_coding_plan else ALIBABA_BASE_URL + china_url = ( + "https://coding.dashscope.aliyuncs.com/v1" if is_coding_plan else ALIBABA_CHINA_BASE_URL ) + + env_base_url = os.getenv("DASHSCOPE_BASE_URL") or os.getenv("ALIBABA_BASE_URL") + is_env_explicitly_set = bool(env_base_url) + + if is_token_plan and not (base_url and base_url.strip()) and not is_env_explicitly_set: + yield OAuthEvent( + "error", + "Alibaba Token Plan workspace keys require the dedicated Base URL shown in " + "the Token Plan console. Enter it during /login or set DASHSCOPE_BASE_URL.", + ) + return + + if base_url and base_url.strip(): + primary_url = _normalize_alibaba_base_url(base_url, is_coding_plan=is_coding_plan) + elif is_env_explicitly_set: + primary_url = _normalize_alibaba_base_url(env_base_url, is_coding_plan=is_coding_plan) + else: + primary_url = default_url + active_url = primary_url models = ALIBABA_MODELS @@ -270,19 +304,16 @@ async def login_alibaba_api_key( models = discovered except aiohttp.ClientResponseError as exc: if exc.status in {401, 403}: - if resolved_key.startswith("sk-ws-"): - # Workspace-scoped keys only work with their dedicated endpoint. + if is_token_plan: yield OAuthEvent( "error", - "Workspace-scoped API keys (sk-ws-) require a dedicated endpoint. " - "Set DASHSCOPE_BASE_URL to the API host shown at key creation time. " - "Example: DASHSCOPE_BASE_URL=" - "ws-xxxx.ap-southeast-1.maas.aliyuncs.com", + "Alibaba Token Plan API key was not accepted. Ensure the key is active " + "and assigned to a Token Plan seat. Set DASHSCOPE_BASE_URL only when " + "Alibaba provides a different endpoint for your plan.", ) return - # Non-workspace key: probe China endpoint as a region fallback. - china_url = ALIBABA_CHINA_BASE_URL - if china_url != primary_url: + + if is_coding_plan and primary_url == default_url: try: discovered = await _discover_alibaba_models(resolved_key, china_url) active_url = china_url @@ -290,43 +321,95 @@ async def login_alibaba_api_key( models = discovered yield OAuthEvent( "info", - "Detected China-region DashScope key; " - "configured for China (Beijing) endpoint.", + "Detected China-region Coding Plan key; configured for China endpoint.", ) except aiohttp.ClientResponseError as china_exc: if china_exc.status in {401, 403}: yield OAuthEvent( "error", - "Alibaba API key was not accepted. Ensure your key is valid and " - "comes from the Alibaba Cloud Model Studio console " - "(https://bailian.console.aliyun.com). " - "Set DASHSCOPE_BASE_URL to override the endpoint if needed.", + "Alibaba Coding Plan API key was not accepted. Ensure your key is " + "valid, active, and has the required model permissions in the " + "Coding Plan console.", ) return - # Non-auth error from China — can't verify; point at China anyway - # since the US endpoint definitively rejected the key. - active_url = china_url yield OAuthEvent( - "info", - "US endpoint rejected the key and China endpoint is unreachable — " - "configured for China (Beijing). Set DASHSCOPE_BASE_URL if issues persist.", + "error", + "The default international endpoint rejected the key and the China " + "endpoint could not be reached. Check network access or set " + "DASHSCOPE_BASE_URL to the correct endpoint and try again.", ) + return except (aiohttp.ClientError, TimeoutError, ValueError): - active_url = china_url yield OAuthEvent( - "info", - "US endpoint rejected the key and China endpoint is unreachable — " - "configured for China (Beijing). Set DASHSCOPE_BASE_URL if issues persist.", + "error", + "The default international endpoint rejected the key and the China " + "endpoint could not be reached. Check network access or set " + "DASHSCOPE_BASE_URL to the correct endpoint and try again.", ) + return else: - # Primary is already the China URL and it returned 401 — key is invalid. - yield OAuthEvent( - "error", - "Alibaba API key was not accepted. Ensure your key is valid and " - "comes from the Alibaba Cloud Model Studio console " - "(https://bailian.console.aliyun.com).", - ) - return + if is_coding_plan: + if primary_url == china_url: + yield OAuthEvent( + "error", + "Alibaba Coding Plan API key was not accepted. Ensure your key is " + "valid, active, and has the required model permissions in the " + "Coding Plan console.", + ) + else: + yield OAuthEvent( + "error", + "Alibaba Coding Plan API key was not accepted. Ensure your key is " + "valid, active, and has the required model permissions in the " + "Coding Plan console. If using the China region, set " + "DASHSCOPE_BASE_URL=https://coding.dashscope.aliyuncs.com/v1", + ) + return + else: + if primary_url == default_url: + try: + discovered = await _discover_alibaba_models(resolved_key, china_url) + active_url = china_url + if discovered: + models = discovered + yield OAuthEvent( + "info", + "Detected China-region DashScope key; " + "configured for China (Beijing) endpoint.", + ) + except aiohttp.ClientResponseError as china_exc: + if china_exc.status in {401, 403}: + yield OAuthEvent( + "error", + "Alibaba API key was not accepted. Ensure your key is valid " + "and " + "comes from the Alibaba Cloud Model Studio console " + "(https://bailian.console.aliyun.com). " + "Set DASHSCOPE_BASE_URL to override the endpoint if needed.", + ) + return + yield OAuthEvent( + "error", + "The default US endpoint rejected the key and the China endpoint " + "could not be reached. Check network access or set " + "DASHSCOPE_BASE_URL to the correct endpoint and try again.", + ) + return + except (aiohttp.ClientError, TimeoutError, ValueError): + yield OAuthEvent( + "error", + "The default US endpoint rejected the key and the China endpoint " + "could not be reached. Check network access or set " + "DASHSCOPE_BASE_URL to the correct endpoint and try again.", + ) + return + else: + yield OAuthEvent( + "error", + "Alibaba API key was not accepted. Ensure your key is valid and " + "that DASHSCOPE_BASE_URL points to the correct endpoint.", + ) + return else: yield OAuthEvent( "info", @@ -338,6 +421,9 @@ async def login_alibaba_api_key( "Alibaba model listing is unavailable; using the built-in model list.", ) + if _is_workspace_endpoint(active_url): + models = tuple(model for model in models if model.model_id != "kimi-k2.6") + _apply_alibaba_config(config, SecretStr(resolved_key), models=models, base_url=active_url) save_config(config) yield OAuthEvent("success", f"Alibaba configured with model {config.default_model}.") diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index b91fcc9c..b9c9f235 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -225,10 +225,15 @@ def create_llm( if provider.reasoning_key is not None else "reasoning_content" ) + stream = not ( + _is_alibaba_workspace_endpoint(provider.base_url) + and model.model.lower().replace("_", "-") == "deepseek-v3.2" + ) chat_provider = OpenAILegacy( model=model.model, base_url=provider.base_url, api_key=resolved_api_key, + stream=stream, reasoning_key=reasoning_key, default_headers=dict(provider.custom_headers) if provider.custom_headers else None, http_client=rl_http_client, @@ -356,10 +361,13 @@ def create_llm( is_dashscope_legacy = provider.type == "openai_legacy" and _is_dashscope_endpoint( provider.base_url or "" ) - # Kimi K2.x and GLM use {"thinking": {"type": ...}} on ALL providers - # (Moonshot direct, Z AI, and DashScope workspace). reasoning_effort is - # never sent for these models regardless of provider. - is_kimi_openai_legacy = provider.type == "openai_legacy" and _is_kimi_k2_model(model.model) + # Kimi K2.x uses the provider-specific thinking.type field on Moonshot-style + # endpoints, but Alibaba's DashScope-compatible routes use enable_thinking. + is_kimi_openai_legacy = ( + provider.type == "openai_legacy" + and _is_kimi_k2_model(model.model) + and not is_dashscope_legacy + ) is_glm_openai_legacy = provider.type == "openai_legacy" and _is_glm_model(model.model) if ( effective_effort is not None @@ -373,8 +381,7 @@ def create_llm( # null reasoning_effort field. chat_provider = chat_provider.with_thinking(effective_effort) - # Kimi K2.x and GLM use {"thinking": {"type": ...}} on every provider - # (Moonshot, Z AI, and DashScope workspace all accept this format). + # Kimi K2.x on Moonshot-style endpoints and GLM use thinking.type. 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: @@ -386,8 +393,8 @@ def create_llm( extra_body={"thinking": thinking_body} ) - # Qwen thinking models on DashScope use enable_thinking (not reasoning_effort). - # Other DashScope-proxied models (Kimi, GLM, DeepSeek) use their own formats above. + # DashScope-compatible models use enable_thinking unless handled by a + # provider-specific format above. if ( is_dashscope_legacy and not is_kimi_openai_legacy @@ -491,6 +498,10 @@ def _is_dashscope_endpoint(base_url: str) -> bool: return "aliyuncs.com" in base_url +def _is_alibaba_workspace_endpoint(base_url: str) -> bool: + return "://ws-" in base_url and ".maas.aliyuncs.com" in base_url + + 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 cd653d79..10541f44 100644 --- a/src/pythinker_code/ui/shell/oauth.py +++ b/src/pythinker_code/ui/shell/oauth.py @@ -293,16 +293,14 @@ async def login(app: Shell, args: str) -> None: if not api_key: console.print(f"[{_t.error}]No Alibaba API key entered.[/]") return - workspace_endpoint: str | None = None + base_url: str | None = None if api_key.startswith("sk-ws-"): - workspace_endpoint = await _prompt_text( - "Workspace endpoint host (e.g. ws-xxxx.ap-southeast-1.maas.aliyuncs.com)" - ) - if not workspace_endpoint: - console.print(f"[{_t.error}]No workspace endpoint entered.[/]") + base_url = await _prompt_text("Token Plan OpenAI-compatible endpoint") + if not base_url: + console.print(f"[{_t.error}]No Token Plan endpoint entered.[/]") return ok = await _render_oauth_events( - login_alibaba_api_key(soul.runtime.config, api_key, base_url=workspace_endpoint) + login_alibaba_api_key(soul.runtime.config, api_key, base_url=base_url) ) provider = ALIBABA_PLATFORM_ID elif mode == "anthropic": diff --git a/tasks/todo.md b/tasks/todo.md index 214918d0..03d38818 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,3 +1,40 @@ +# Alibaba Token Plan model compatibility fix + +## Plan + +- [x] Reproduce the Kimi-only request-shaping regression with a focused test. +- [x] Preserve Moonshot/Z AI request formats for non-Alibaba providers. +- [x] Confirm the generic Token Plan `/models` response does not validate `sk-ws-` credentials. +- [x] Require the dedicated workspace Base URL for `sk-ws-` login. +- [x] Re-test Kimi K2.6 and DeepSeek V3.2 request behavior for the dedicated endpoint. +- [x] Fix the shell wire coroutine warning. +- [x] Run focused Alibaba auth/LLM tests and `make check-pythinker-code`. + +## Acceptance criteria + +- Alibaba Token Plan Kimi K2.6 sends `extra_body.enable_thinking`, not + `extra_body.thinking.type` or `reasoning_effort`. +- `sk-ws-` login requires and saves the dedicated Base URL shown in the Token Plan console. +- Login cannot falsely succeed based only on the generic endpoint's public `/models` response. +- DeepSeek V3.2 either produces a valid response or is excluded with evidence that the workspace + endpoint does not support it. +- Existing generic, regional, Coding Plan, Moonshot, and GLM behavior remains covered. + +## Review + +- Token Plan keys now require an explicit dedicated endpoint and the shell passes that endpoint + directly to the login flow. +- Regional fallback only saves a provider after the fallback endpoint authenticates successfully; + custom endpoints are never silently replaced. +- Workspace Kimi entries are filtered when the advertised route is unusable, while DeepSeek V3.2 + uses the verified non-streaming request path and DashScope `enable_thinking`. +- Final focused agent-spec, auth, LLM, and shell tests: `122 passed`. +- `make check-pythinker-code` passed. +- Full Pythinker Code test target passed: `4427 passed, 6 skipped, 1 xfailed`; wire E2E + passed: `52 passed, 4 skipped`. + +--- + # Plan: Full rename `pythinker-cli` → `pythinker-code` **Status**: Planning — DO NOT execute yet. User to review and approve. diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index b8fbe705..9a7d61a0 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -329,9 +329,9 @@ async def fake_request(*args: object, **kwargs: object) -> object: @pytest.mark.asyncio -async def test_login_alibaba_china_probe_network_error_configures_china(monkeypatch, tmp_path): - """When US returns 401 and China is unreachable, we still configure for China.""" - from pythinker_code.auth.alibaba import ALIBABA_CHINA_BASE_URL, login_alibaba_api_key +async def test_login_alibaba_china_probe_network_error_fails_closed(monkeypatch, tmp_path): + """When US rejects a key and China is unreachable, do not save an unverified endpoint.""" + from pythinker_code.auth.alibaba import login_alibaba_api_key monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) @@ -350,13 +350,42 @@ async def fake_request(*args: object, **kwargs: object) -> object: events = [event async for event in login_alibaba_api_key(config, "sk-china-key")] - types = [e.type for e in events] - assert types == ["info", "success"], types - assert "China" in events[0].message - provider = next(iter(config.providers.values())) - assert provider.base_url == ALIBABA_CHINA_BASE_URL - assert "alibaba/qwen3.6-plus" in config.models - assert (tmp_path / "config.toml").exists() + assert [event.type for event in events] == ["error"] + assert "China endpoint could not be reached" in events[-1].message + assert config.providers == {} + assert config.models == {} + assert not (tmp_path / "config.toml").exists() + + +@pytest.mark.asyncio +async def test_login_alibaba_custom_endpoint_401_does_not_probe_china(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) + seen_urls: list[str] = [] + + async def fake_request(*args: object, **kwargs: object) -> object: + url = str(args[2]) + seen_urls.append(url) + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [ + event + async for event in login_alibaba_api_key( + config, + "sk-custom-key", + base_url="https://custom.example.com/compatible-mode/v1", + ) + ] + + assert [event.type for event in events] == ["error"] + assert seen_urls == ["https://custom.example.com/compatible-mode/v1/models"] + assert config.providers == {} @pytest.mark.asyncio @@ -389,8 +418,8 @@ async def fake_request(*args: object, **kwargs: object) -> object: @pytest.mark.asyncio -async def test_login_alibaba_workspace_key_gives_targeted_error(monkeypatch, tmp_path): - """sk-ws- workspace keys get a targeted error with DASHSCOPE_BASE_URL guidance.""" +async def test_login_alibaba_token_plan_key_gives_targeted_error(monkeypatch, tmp_path): + """Rejected sk-ws- keys get Token Plan-specific recovery guidance.""" from pythinker_code.auth.alibaba import login_alibaba_api_key monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) @@ -418,25 +447,27 @@ async def fake_request(*args: object, **kwargs: object) -> object: ] assert events[-1].type == "error" - assert "sk-ws-" in events[-1].message + assert "Token Plan" in events[-1].message assert "DASHSCOPE_BASE_URL" in events[-1].message - assert call_count == 1, "should not probe China for workspace keys" + assert call_count == 0, "should not probe any endpoint without the dedicated workspace URL" assert config.providers == {} @pytest.mark.asyncio async def test_login_alibaba_workspace_key_with_correct_base_url_succeeds(monkeypatch, tmp_path): - """sk-ws- key succeeds when DASHSCOPE_BASE_URL is set to the workspace endpoint.""" + """An explicit Token Plan endpoint takes precedence over the environment.""" from pythinker_code.auth.alibaba import login_alibaba_api_key monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) monkeypatch.setenv( "DASHSCOPE_BASE_URL", - "ws-kopy0du82ky7144q.ap-southeast-1.maas.aliyuncs.com", + "ws-wrong.ap-southeast-1.maas.aliyuncs.com", ) config = Config(is_from_default_location=True) + seen_urls: list[str] = [] async def fake_request(*args: object, **kwargs: object) -> object: + seen_urls.append(str(args[2])) return _FakeAiohttpResponse({"data": [{"id": "qwen3.6-plus"}]}) monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) @@ -444,16 +475,74 @@ async def fake_request(*args: object, **kwargs: object) -> object: events = [ event async for event in login_alibaba_api_key( - config, "sk-ws-H.HXDYIP.c9u4.abcdefghijklmnopqrstuvwxyz" + config, + "sk-ws-H.HXDYIP.c9u4.abcdefghijklmnopqrstuvwxyz", + base_url="ws-kopy0du82ky7144q.ap-southeast-1.maas.aliyuncs.com", ) ] assert events[-1].type == "success" provider = next(iter(config.providers.values())) assert "ws-kopy0du82ky7144q" in provider.base_url + assert all("ws-wrong" not in url for url in seen_urls) assert (tmp_path / "config.toml").exists() +@pytest.mark.asyncio +async def test_login_alibaba_workspace_hides_unroutable_kimi(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + return _FakeAiohttpResponse({"data": [{"id": "kimi-k2.6"}, {"id": "deepseek-v3.2"}]}) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [ + event + async for event in login_alibaba_api_key( + config, + "sk-ws-H.HXDYIP.c9u4.abcdefghijklmnopqrstuvwxyz", + base_url="ws-example.ap-southeast-1.maas.aliyuncs.com", + ) + ] + + assert events[-1].type == "success" + assert "alibaba/kimi-k2.6" not in config.models + assert "alibaba/deepseek-v3.2" in config.models + + +@pytest.mark.asyncio +async def test_login_alibaba_workspace_key_requires_dedicated_base_url(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + pytest.fail("workspace login must not probe a generic endpoint") + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [ + event + async for event in login_alibaba_api_key( + config, "sk-ws-H.HXDYIP.c9u4.abcdefghijklmnopqrstuvwxyz" + ) + ] + + assert events[-1].type == "error" + assert "dedicated Base URL" in events[-1].message + assert "DASHSCOPE_BASE_URL" in events[-1].message + assert config.providers == {} + + @pytest.mark.asyncio async def test_login_alibaba_uses_discovered_context_length(monkeypatch, tmp_path): from pythinker_code.auth.alibaba import login_alibaba_api_key @@ -585,3 +674,110 @@ async def test_logout_alibaba_rejects_non_default_config_location(): assert "default config file" in events[-1].message assert config.providers == {} assert config.models == {} + + +@pytest.mark.asyncio +async def test_login_alibaba_coding_plan_key_intl_succeeds(monkeypatch, tmp_path): + """A Coding Plan (sk-sp-) key succeeds on the intl endpoint.""" + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + url = str(args[2]) + assert "coding-intl.dashscope.aliyuncs.com/v1" in url + return _FakeAiohttpResponse({"data": [{"id": "kimi-k2.6"}]}) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [event async for event in login_alibaba_api_key(config, "sk-sp-test-key")] + + assert events[-1].type == "success" + provider = next(iter(config.providers.values())) + assert provider.base_url == "https://coding-intl.dashscope.aliyuncs.com/v1" + assert (tmp_path / "config.toml").exists() + + +@pytest.mark.asyncio +async def test_login_alibaba_coding_plan_key_auto_detected(monkeypatch, tmp_path): + """A Coding Plan (sk-sp-) key that fails on intl but succeeds on China is auto-configured.""" + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + url = str(args[2]) + if "coding-intl" in url: + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + return _FakeAiohttpResponse({"data": [{"id": "kimi-k2.6", "context_length": 262_144}]}) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [event async for event in login_alibaba_api_key(config, "sk-sp-test-key")] + + types = [e.type for e in events] + assert types == ["info", "success"], types + assert "China" in events[0].message + provider = next(iter(config.providers.values())) + assert provider.base_url == "https://coding.dashscope.aliyuncs.com/v1" + assert config.models["alibaba/kimi-k2.6"].max_context_size == 262_144 + assert (tmp_path / "config.toml").exists() + + +@pytest.mark.asyncio +async def test_login_alibaba_coding_plan_key_rejects_401(monkeypatch, tmp_path): + """A Coding Plan (sk-sp-) key that fails on both intl and China endpoints is rejected.""" + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + url = str(args[2]) + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [event async for event in login_alibaba_api_key(config, "sk-sp-bad-key")] + + assert events[-1].type == "error" + assert "Coding Plan" in events[-1].message + assert config.providers == {} + + +@pytest.mark.asyncio +async def test_login_alibaba_coding_plan_probe_network_error_fails_closed(monkeypatch, tmp_path): + from pythinker_code.auth.alibaba import login_alibaba_api_key + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.delenv("DASHSCOPE_BASE_URL", raising=False) + monkeypatch.delenv("ALIBABA_BASE_URL", raising=False) + config = Config(is_from_default_location=True) + + async def fake_request(*args: object, **kwargs: object) -> object: + url = str(args[2]) + if "coding-intl" in url: + raise aiohttp.ClientResponseError( + _request_info(url), (), status=401, message="Unauthorized" + ) + raise aiohttp.ClientConnectionError("China unreachable") + + monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) + + events = [event async for event in login_alibaba_api_key(config, "sk-sp-test-key")] + + assert [event.type for event in events] == ["error"] + assert "China endpoint could not be reached" in events[-1].message + assert config.providers == {} diff --git a/tests/core/test_agent_spec.py b/tests/core/test_agent_spec.py index e2991b13..1c2a4b79 100644 --- a/tests/core/test_agent_spec.py +++ b/tests/core/test_agent_spec.py @@ -420,6 +420,26 @@ def test_load_default_agent_spec(): assert sub_subagents == snapshot({}) +def test_default_subagents_include_production_guardrail_gate(): + subagent_specs = { + name: load_agent_spec(spec.path) + for name, spec in load_agent_spec(DEFAULT_AGENT_FILE).subagents.items() + } + + assert ( + "production guardrail gate" + in subagent_specs["review"].system_prompt_args["ROLE_ADDITIONAL"] + ) + assert ( + "cache stampedes" in subagent_specs["code-reviewer"].system_prompt_args["ROLE_ADDITIONAL"] + ) + assert ( + "IDOR/tenant-scope mistakes" + in subagent_specs["security-reviewer"].system_prompt_args["ROLE_ADDITIONAL"] + ) + assert "Production guardrails" in subagent_specs["judge"].system_prompt_args["ROLE_ADDITIONAL"] + + def test_load_agent_spec_basic(agent_file: Path): """Test loading a basic agent specification.""" spec = load_agent_spec(agent_file) diff --git a/tests/core/test_create_llm.py b/tests/core/test_create_llm.py index 6e5061d0..b9fdce74 100644 --- a/tests/core/test_create_llm.py +++ b/tests/core/test_create_llm.py @@ -718,6 +718,52 @@ def test_create_llm_openai_legacy_kimi_sends_enabled_thinking_body(): } +def test_create_llm_alibaba_kimi_uses_dashscope_thinking_switch(): + provider = LLMProvider( + type="openai_legacy", + base_url="https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="managed:alibaba", + model="kimi-k2.6", + max_context_size=262_144, + capabilities={"thinking"}, + ) + + llm = create_llm(provider, model, thinking=True) + + assert llm is not None + assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.chat_provider.thinking_effort is None + assert llm.chat_provider._generation_kwargs.get("extra_body") == { # pyright: ignore[reportPrivateUsage] + "enable_thinking": True + } + + +def test_create_llm_alibaba_workspace_deepseek_disables_streaming(): + provider = LLMProvider( + type="openai_legacy", + base_url=("https://ws-example.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"), + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="managed:alibaba", + model="deepseek-v3.2", + max_context_size=128_000, + capabilities={"thinking"}, + ) + + llm = create_llm(provider, model, thinking=True) + + assert llm is not None + assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.chat_provider.stream is False + assert llm.chat_provider._generation_kwargs.get("extra_body") == { # pyright: ignore[reportPrivateUsage] + "enable_thinking": True + } + + def test_create_llm_openai_legacy_glm_sends_provider_thinking_body(): provider = LLMProvider( type="openai_legacy", diff --git a/tests/core/test_default_agent.py b/tests/core/test_default_agent.py index d2c40675..22eb97ff 100644 --- a/tests/core/test_default_agent.py +++ b/tests/core/test_default_agent.py @@ -20,6 +20,17 @@ async def test_default_agent(runtime: Runtime): assert "Pythoughts-labs" in agent.system_prompt assert "Do not name or describe the underlying language model" in agent.system_prompt + # Production guardrails — keep defensive coding rules in the base prompt so root and + # subagent roles inherit the same failure-mode posture. + assert "## Production Bug Guardrails" in agent.system_prompt + assert "double-checked lock" in agent.system_prompt + assert "guarantee release/close in `finally`" in agent.system_prompt + assert "schema/validation mechanism" in agent.system_prompt + assert "row-level serialization (`FOR UPDATE`)" in agent.system_prompt + assert "exponential backoff with random jitter" in agent.system_prompt + assert "symmetric cleanup" in agent.system_prompt + assert "verified cryptographic/session identity" in agent.system_prompt + builtin_types = [ ( name, diff --git a/tests/ui_and_conv/test_openai_shell_login.py b/tests/ui_and_conv/test_openai_shell_login.py index a86071c3..73a0e377 100644 --- a/tests/ui_and_conv/test_openai_shell_login.py +++ b/tests/ui_and_conv/test_openai_shell_login.py @@ -220,6 +220,28 @@ async def test_shell_login_deepseek_routes_to_deepseek(monkeypatch): assert login.call_args.args[1] == "ds-test" +@pytest.mark.asyncio +async def test_shell_login_alibaba_workspace_key_prompts_for_dedicated_endpoint(monkeypatch): + login = Mock(side_effect=_success_event) + monkeypatch.setattr(shell_oauth, "login_alibaba_api_key", login, raising=False) + monkeypatch.setattr( + shell_oauth, + "_prompt_api_key", + lambda label: _async_value("sk-ws-test"), + ) + monkeypatch.setattr( + shell_oauth, + "_prompt_text", + lambda label: _async_value("ws-example.ap-southeast-1.maas.aliyuncs.com"), + raising=False, + ) + + with pytest.raises(Reload): + await cast(Any, shell_oauth.login)(_app(), "alibaba") + + assert login.call_args.kwargs["base_url"] == ("ws-example.ap-southeast-1.maas.aliyuncs.com") + + @pytest.mark.asyncio async def test_shell_logout_deepseek_routes_to_deepseek(monkeypatch): logout = Mock(side_effect=_success_event) From 74c716afa2f26ff42c9bbd374bccfcbf75ac81e4 Mon Sep 17 00:00:00 2001 From: mohamed-elkholy95 Date: Sat, 6 Jun 2026 12:44:19 -0400 Subject: [PATCH 11/11] docs(agents): support local agent instructions --- .gitignore | 1 + AGENTS.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 7ab3576d..1c5efc1f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ wheels/ .vscode .env .env.local +AGENTS.local /tests_local uv.toml .idea/* diff --git a/AGENTS.md b/AGENTS.md index af2245e7..a2f47632 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,13 @@ This file is the root guidance for AI agents working in this repository. It is i Pythinker sessions via `PYTHINKER_AGENTS_MD`; keep it durable, portable, and focused on rules that should apply across many tasks. +## Local-only instructions + +If `AGENTS.local` exists at the repository root, read it after this file for machine-specific or +private local instructions. `AGENTS.local` is intentionally gitignored; do not commit it or copy its +contents into tracked files. Local instructions may add workflow details, but they must not weaken +or override this repository's non-negotiable rules. + ## Mission Pythinker CLI is a Python CLI agent for software engineering workflows. It supports an