diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml index 8d953401..8d6a4428 100644 --- a/.github/workflows/docs-pages.yml +++ b/.github/workflows/docs-pages.yml @@ -14,14 +14,15 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" +concurrency: + group: pages + cancel-in-progress: false + jobs: - deploy: + build: # Only run on the original repository, not on forks if: github.repository == 'Pythoughts-labs/pythinker-code' runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pinned from v6.0.2 @@ -76,6 +77,16 @@ jobs: with: path: docs/.vitepress/dist + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: - name: Deploy to GitHub Pages id: deploy uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # pinned from v5.0.0 diff --git a/.gitignore b/.gitignore index 11a916a1..ff129039 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,6 @@ blackbox/ coverage.xml htmlcov/ *.scratchpad.lock + +.playwright-mcp/ +.playwright/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index cd08fdec..8743b5b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ GitHub Releases page; `0.8.0` is the new starting line. - **Deep-audit remediation: security, correctness, and multi-instance robustness.** Permission gate: awk programs that shell out via `print | "cmd"` / `getline` are now classified as mutating AND destructive (previously only `system(`/`>` and only mutating), and `xargs -L N` no longer hides its payload from classification. Glob resolves symlinks before its workspace-boundary check (an in-workspace symlink could previously list outside content); progress-note titles are ANSI-sanitized like every other transcript field. Grep content lines are parsed with unambiguous field separators, so paths like `utf-8-codec.py` are no longer mangled with `-n=false` and sensitive-file attribution is exact. Multi-line edits on CRLF files work again (LF-joined old strings are CRLF-translated when needed). `/import` preserves paths byte-for-byte (only a standalone leading/trailing `--force` is treated as the flag). Post-compaction file reminders include `--add-dir` files. Double-interrupt can no longer orphan the interruption-marker write (unanswered tool_calls). Background web replay falls back to full history (not empty) when the watermark stat fails, and a malformed Agent resume id returns a clean "Agent not found". OAuth: login fails loud when the token response lacks a `refresh_token`; a refresh response without `expires_in` carries the previous lifetime forward instead of refreshing every tick; the device-id file can no longer be read empty mid-creation. A failed `theme="auto"` background probe can be retried by re-selecting auto via `/theme`. Multi-instance: sessions now take a per-session writer lock (a second `pythinker -r `/web worker on the same session is refused instead of interleaving turns), the shared `pythinker.json` index uses a locked read-modify-write (no more lost work-dir registrations), JSONL appenders repair torn final lines after a crash, forks materialize atomically, project-memory mutations abort on read failure instead of wiping the file, the journal is capped at 100 recaps, inbox approve/reject claims candidates atomically, and recall re-arms when another instance writes new memory. Subagents: a failed summary continuation no longer discards a completed agent's work, hallucinated subagent types fail fast with the valid-type list (before any RunAgents child launches), background failures carry an `Agent ID:` + resume hint, and a crash inside the runner's own error handling is logged instead of silently lost. - **Breaking (CLI flags): `pythinker web` / `pythinker vis` host short flag is now `-H`.** `-h` is a help alias on both subcommands (matching the root CLI); previously `-h ` bound the host. Scripts using `-h 0.0.0.0` now print help and exit 0 without starting a server — switch to `-H ` or `--host `. Part of the security/correctness audit (which also confined Grep to the workspace, gated non-HTTPS provider URLs in the web config API to loopback, and stopped saving OpenAI keys on 401/403). - **Thinking effort moved to a single top-right label on the input border.** The input box border is now one static frame grey at every effort level instead of recoloring the whole bar cold→hot, and the effort is no longer duplicated in the footer line. It's shown once, as a small label flushed to the right of the input's top border — a level-colored dot (slate→blue→teal→amber→orange→red as `off→max`) plus the muted level word — so the dial stays glanceable without tinting the typing area or cluttering the footer. The label is hidden entirely for native-thinking models (`always_thinking`, no user dial) and non-thinking models, and the rule auto-shortens by the label width so the line never wraps. +- **Alibaba login: correct plan-key routing, full live model discovery, and `qwen3.7-plus` default.** Subscription "plan" keys — `sk-sp-`, `sk-tok-`, and `sk-ws-` — now route to the shared international Token Plan endpoint (`token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1`), fixing a bug where `sk-sp-` keys were sent to the Coding Plan host (`coding-intl`) and every chat then failed with `401 invalid_api_key`. Generic `sk-` keys remain pay-as-you-go Cloud (DashScope) keys. Model discovery no longer filters the live `/models` response down to a hardcoded allowlist — every chat model the endpoint returns now appears (known models keep curated capabilities/context; the rest get sensible defaults), while text-to-image models (Wan, Qwen-Image) are excluded since they can't serve as the agent's LLM. The catalog therefore reflects the key's tier: a Token Plan key surfaces the multi-vendor set (Qwen, DeepSeek, GLM, Kimi, MiniMax), a pay-as-you-go key surfaces that account's Qwen line. The first-login default model is now `qwen3.7-plus` (falling back to the first available model when an endpoint doesn't offer it). The usage panel detects endpoints with no quota API and shows a clear note pointing to the Model Studio console alongside Pythinker's local token tally instead of probing a 404 quota endpoint. The built-in catalog remains the offline fallback when discovery is unavailable. For the Token Plan, whose usage policy forbids automated balance polling and exposes no quota API, `/usage` shows Pythinker's local token tally plus a note pointing to the Model Studio console (My Subscriptions / Usage Analysis) for the Credits balance. - **Qwen models treated as native-thinking across both plans.** Qwen3.x/3.7 (e.g. `qwen3.7-max`, `qwen3.6-plus`, the Qwen3 Coder models) now carry the `always_thinking` capability on both the Alibaba Model Studio and OpenCode Go plans, matching GLM/MiniMax: reasoning is built in and always on, with no user effort dial and no top-border effort label. Reasoning still flows over the Anthropic `thinking` block that both Anthropic-compatible routes accept. - **TUI enhancements: adaptive theme, layout, and agent prompt overhaul.** Adaptive terminal-background probe + color-depth blending; reference-CLI layout and palette refinements; unified todo-list renderer; white running-task titles with consistent diff palette; elapsed/tokens/t-s metadata on the background status line; transcript-row bullet fix; renderer guards and markdown fence table unwrapping. All default agent prompts restructured with explicit Mission / Hard Constraints / Workflow / Output Contract sections. Background manager and subagent runner hardened with stale-record reconciliation and resume contract enforcement. Automatic turn recaps disabled by default. diff --git a/src/pythinker_code/auth/alibaba.py b/src/pythinker_code/auth/alibaba.py index 755246bd..05fa5869 100644 --- a/src/pythinker_code/auth/alibaba.py +++ b/src/pythinker_code/auth/alibaba.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re from collections.abc import AsyncIterator from dataclasses import dataclass from typing import Any, cast @@ -19,9 +20,13 @@ 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" +# Shared international (Team Edition) Token Plan endpoint — the default for plan keys. +ALIBABA_TOKEN_PLAN_BASE_URL = ( + "https://token-plan.ap-southeast-1.maas.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_DEFAULT_MODEL_ALIAS = f"{ALIBABA_PLATFORM_ID}/qwen3.7-plus" +ALIBABA_DEFAULT_CONTEXT = 131_072 ALIBABA_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10) @@ -128,18 +133,12 @@ def get_alibaba_api_key_from_env() -> str | None: return None -def _normalize_alibaba_base_url(value: str, is_coding_plan: bool = False) -> str: +def _normalize_alibaba_base_url(value: str) -> str: base_url = value.strip().rstrip("/") if not base_url: - return ALIBABA_CODING_PLAN_BASE_URL if is_coding_plan else ALIBABA_BASE_URL + return 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"): @@ -196,6 +195,47 @@ def _model_by_id() -> dict[str, AlibabaModel]: return {model.model_id: model for model in ALIBABA_MODELS} +# Non-chat model ids returned by /models that can't serve as the agent LLM (image/audio/ +# video generation, embeddings, rerankers, omni/realtime). Mirrors pi-alibaba-models. +_NON_CHAT_MODEL_RE = re.compile( + r"image|audio|video|tts|asr|embed|vector|rerank|wan|omni|livetranslate|realtime", + re.IGNORECASE, +) +_VISION_RE = re.compile(r"vl|vision", re.IGNORECASE) +_QWEN_PLUS_RE = re.compile(r"^qwen3\.\d+-plus\b", re.IGNORECASE) +_REASONING_RE = re.compile(r"qwq|max|thinking|deepseek|minimax|kimi|glm|3\.[5-9]", re.IGNORECASE) +_QWEN_BIG_CTX_RE = re.compile(r"^qwen3\.([7-9]|\d{2,})-(plus|max)\b", re.IGNORECASE) + + +def _is_non_chat_model(model_id: str) -> bool: + return bool(_NON_CHAT_MODEL_RE.search(model_id)) + + +def _infer_capabilities(model_id: str) -> frozenset[ModelCapability] | None: + """The /models API returns only ids, so infer capabilities from the id (pi heuristics).""" + mid = model_id.lower() + caps: set[ModelCapability] = set() + if _VISION_RE.search(mid) or _QWEN_PLUS_RE.search(mid) or "kimi" in mid: + caps.add("image_in") + if _REASONING_RE.search(mid): + # DeepSeek/Kimi expose a thinking dial; Qwen/GLM/MiniMax reason natively. + caps.add("thinking" if ("deepseek" in mid or "kimi" in mid) else "always_thinking") + return frozenset(caps) or None + + +def _infer_context_window(model_id: str) -> int: + mid = model_id.lower() + if "flash" in mid: + return 131_072 + if "kimi" in mid: + return 262_144 + if mid.startswith("qwen3.6-max"): + return 262_144 + if mid.startswith("qwen3.6-plus") or _QWEN_BIG_CTX_RE.search(mid): + return 1_048_576 + return ALIBABA_DEFAULT_CONTEXT + + def _parse_discovered_models(data: object) -> tuple[AlibabaModel, ...]: if not isinstance(data, dict): return () @@ -210,27 +250,35 @@ def _parse_discovered_models(data: object) -> tuple[AlibabaModel, ...]: continue item = cast(dict[str, Any], raw_item) model_id = item.get("id") - if not isinstance(model_id, str) or model_id not in catalog: + if not isinstance(model_id, str) or not model_id: + continue + if _is_non_chat_model(model_id): continue - base = catalog[model_id] + # Accept every chat model the endpoint returns; enrich known ones from the + # built-in catalog and infer capabilities/context from the id for the rest. + base = catalog.get(model_id) ctx = item.get("context_length") - max_ctx = base.max_context_size if isinstance(ctx, int) and ctx > 0: max_ctx = ctx + elif base is not None: + max_ctx = base.max_context_size + else: + max_ctx = _infer_context_window(model_id) display_name_raw = item.get("display_name") - display_name = ( - display_name_raw - if isinstance(display_name_raw, str) and display_name_raw - else base.display_name - ) + if isinstance(display_name_raw, str) and display_name_raw: + display_name = display_name_raw + elif base is not None: + display_name = base.display_name + else: + display_name = model_id + capabilities = base.capabilities if base is not None else _infer_capabilities(model_id) results.append( AlibabaModel( - model_id=base.model_id, - alias_suffix=base.alias_suffix, + model_id=model_id, + alias_suffix=base.alias_suffix if base is not None else model_id, display_name=display_name, - provider_key=base.provider_key, max_context_size=max_ctx, - capabilities=base.capabilities, + capabilities=capabilities, ) ) return tuple(results) @@ -269,29 +317,20 @@ async def login_alibaba_api_key( yield OAuthEvent("error", "Alibaba API key is required.") return - 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 - ) + # Subscription "plan" keys (Coding Plan / Token Plan) all authenticate against the + # shared Token Plan endpoint — sk-sp-, sk-tok-, and sk-ws- route there. Generic sk- + # keys are pay-as-you-go Cloud (DashScope) keys routed to the regional endpoint. + is_token_plan = resolved_key.startswith(("sk-sp-", "sk-tok-", "sk-ws-")) + # Default plan keys to the shared international Token Plan endpoint; a dedicated + # workspace URL can still be supplied via base_url / DASHSCOPE_BASE_URL. + default_url = ALIBABA_TOKEN_PLAN_BASE_URL if is_token_plan else ALIBABA_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) + primary_url = _normalize_alibaba_base_url(base_url) + elif env_base_url: + primary_url = _normalize_alibaba_base_url(env_base_url) else: primary_url = default_url @@ -312,104 +351,53 @@ async def login_alibaba_api_key( "Alibaba provides a different endpoint for your plan.", ) return - - if is_coding_plan and primary_url == default_url: + # Pay-as-you-go Cloud key: when we used the default US endpoint, probe the + # China endpoint to auto-detect a China-region key before giving up. + if primary_url == ALIBABA_BASE_URL: try: - discovered = await _discover_alibaba_models(resolved_key, china_url) - active_url = china_url + discovered = await _discover_alibaba_models( + resolved_key, ALIBABA_CHINA_BASE_URL + ) + active_url = ALIBABA_CHINA_BASE_URL if discovered: models = discovered yield OAuthEvent( "info", - "Detected China-region Coding Plan key; configured for China endpoint.", + "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 Coding Plan API key was not accepted. Ensure your key is " - "valid, active, and has the required model permissions in the " - "Coding Plan console.", + "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 international endpoint rejected the key and the China " - "endpoint could not be reached. Check network access or set " + "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 international endpoint rejected the key and the China " - "endpoint could not be reached. Check network access or set " + "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: - 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 + 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", diff --git a/src/pythinker_code/ui/shell/usage_adapters/alibaba.py b/src/pythinker_code/ui/shell/usage_adapters/alibaba.py index 03a43493..6c8c4df3 100644 --- a/src/pythinker_code/ui/shell/usage_adapters/alibaba.py +++ b/src/pythinker_code/ui/shell/usage_adapters/alibaba.py @@ -41,6 +41,21 @@ def _quota_url(base_url: str) -> str: return f"{parsed.scheme}://{host}{_QUOTA_PATH}" +def _endpoint_without_quota_api(base_url: str) -> bool: + """Endpoints where the configured API key cannot fetch a live quota/balance. + + The Token Plan (``token-plan…maas.aliyuncs.com``) and Coding Plan + (``coding…dashscope``) hosts return 404 on ``/api/v1/quotas``. The Token Plan + balance lives only in the console, and its usage policy forbids automated + balance polling — so we never probe these and instead show Pythinker's local + tally plus a console pointer. + """ + host = urlparse(base_url).netloc + if host.startswith("coding") and "dashscope" in host: + return True + return host.startswith("token-plan") and host.endswith(".maas.aliyuncs.com") + + def _parse_quota_response(data: object) -> list[UsageRow]: """Parse DashScope /api/v1/quotas response into UsageRow list. @@ -124,24 +139,34 @@ async def fetch(self, provider: LLMProvider, oauth_mgr: OAuthManager) -> UsageRe # --- DashScope quota API (best-effort; logs + notes on failure) --- quota_rows: list[UsageRow] = [] notes: list[str] = [] - try: - async with ( - new_client_session(timeout=_TIMEOUT) as session, - session.get( - _quota_url(base_url), - headers={"Authorization": f"Bearer {api_key}"}, - ) as resp, - ): - if resp.status == 200: - data = await resp.json(content_type=None) - quota_rows = _parse_quota_response(data) - elif resp.status in (401, 403): - notes.append( - "DashScope quota API: authorization failed — quota data unavailable." - ) - except (aiohttp.ClientError, TimeoutError) as e: - logger.debug("DashScope quota API request failed: {error}", error=e, exc_info=True) - notes.append("DashScope quota API unavailable right now — retry in a moment.") + if _endpoint_without_quota_api(base_url): + # No quota API on this host (and the Token Plan policy forbids automated + # balance polling), so don't probe it. The figures above are Pythinker's + # local tally, not the plan's Credits balance. + notes.append( + "No usage API for this plan — the token counts above are Pythinker's " + "local tally, not your Credits balance. View remaining Credits in the " + "Model Studio console (My Subscriptions / Usage Analysis)." + ) + else: + try: + async with ( + new_client_session(timeout=_TIMEOUT) as session, + session.get( + _quota_url(base_url), + headers={"Authorization": f"Bearer {api_key}"}, + ) as resp, + ): + if resp.status == 200: + data = await resp.json(content_type=None) + quota_rows = _parse_quota_response(data) + elif resp.status in (401, 403): + notes.append( + "DashScope quota API: authorization failed — quota data unavailable." + ) + except (aiohttp.ClientError, TimeoutError) as e: + logger.debug("DashScope quota API request failed: {error}", error=e, exc_info=True) + notes.append("DashScope quota API unavailable right now — retry in a moment.") # --- Rate-limit headers from last response (if DashScope ever sends them) --- rl_rows: list[UsageRow] = [] diff --git a/tests/auth/test_alibaba_auth.py b/tests/auth/test_alibaba_auth.py index 8bbf1aa0..a6576a09 100644 --- a/tests/auth/test_alibaba_auth.py +++ b/tests/auth/test_alibaba_auth.py @@ -153,7 +153,7 @@ def test_apply_alibaba_config_writes_provider_and_default(): assert config.models["alibaba/kimi-k2.6"].capabilities == frozenset({"thinking", "image_in"}) assert config.models["alibaba/glm-5.1"].capabilities == frozenset({"always_thinking"}) assert "alibaba/minimax-m2.5" not in config.models - assert config.default_model == "alibaba/qwen3.6-plus" + assert config.default_model == "alibaba/qwen3.7-plus" assert config.default_thinking is True assert config.default_thinking_effort == "high" @@ -256,7 +256,7 @@ async def fake_request(*args: object, **kwargs: object) -> object: assert [event.type for event in events] == ["info", "success"] assert "sk-test" not in "\n".join(event.json for event in events) - assert config.default_model == "alibaba/qwen3.6-plus" + assert config.default_model == "alibaba/qwen3.7-plus" assert "alibaba/qwen3.7-max" in config.models assert "alibaba/qwen3.6-plus" in config.models assert (tmp_path / "config.toml").exists() @@ -282,7 +282,7 @@ async def fake_request(*args: object, **kwargs: object) -> object: events = [event async for event in login_alibaba_api_key(config, "sk-test")] assert [event.type for event in events] == ["info", "success"] - assert config.default_model == "alibaba/qwen3.6-plus" + assert config.default_model == "alibaba/qwen3.7-plus" @pytest.mark.asyncio @@ -433,20 +433,19 @@ async def fake_request(*args: object, **kwargs: object) -> object: @pytest.mark.asyncio 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 + """A rejected sk-ws- key (probed on the default endpoint) gets Token Plan guidance.""" + from pythinker_code.auth.alibaba import ALIBABA_TOKEN_PLAN_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) - call_count = 0 + seen_urls: list[str] = [] async def fake_request(*args: object, **kwargs: object) -> object: - nonlocal call_count - call_count += 1 url = str(args[2]) + seen_urls.append(url) raise aiohttp.ClientResponseError( _request_info(url), (), status=401, message="Unauthorized" ) @@ -463,7 +462,8 @@ async def fake_request(*args: object, **kwargs: object) -> object: assert events[-1].type == "error" assert "Token Plan" in events[-1].message assert "DASHSCOPE_BASE_URL" in events[-1].message - assert call_count == 0, "should not probe any endpoint without the dedicated workspace URL" + # Defaults to the shared international Token Plan endpoint (no China probe). + assert seen_urls == [f"{ALIBABA_TOKEN_PLAN_BASE_URL}/models"] assert config.providers == {} @@ -531,16 +531,22 @@ async def fake_request(*args: object, **kwargs: object) -> object: @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 +async def test_login_alibaba_token_plan_defaults_to_international_endpoint(monkeypatch, tmp_path): + """A sk-ws- key with no base URL defaults to the shared international Token Plan endpoint.""" + from pythinker_code.auth.alibaba import ALIBABA_TOKEN_PLAN_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) + seen_urls: list[str] = [] + async def fake_request(*args: object, **kwargs: object) -> object: - pytest.fail("workspace login must not probe a generic endpoint") + seen_urls.append(str(args[2])) + return _FakeAiohttpResponse( + {"data": [{"id": "qwen3.7-plus"}, {"id": "MiniMax-M2.5"}, {"id": "deepseek-v4-pro"}]} + ) monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) @@ -551,10 +557,14 @@ async def fake_request(*args: object, **kwargs: object) -> object: ) ] - assert events[-1].type == "error" - assert "dedicated Base URL" in events[-1].message - assert "DASHSCOPE_BASE_URL" in events[-1].message - assert config.providers == {} + assert events[-1].type == "success" + assert seen_urls == [f"{ALIBABA_TOKEN_PLAN_BASE_URL}/models"] + provider = next(iter(config.providers.values())) + assert provider.base_url == ALIBABA_TOKEN_PLAN_BASE_URL + assert config.default_model == "alibaba/qwen3.7-plus" + # Accept-all discovery surfaces the multi-vendor Token Plan models. + assert "alibaba/MiniMax-M2.5" in config.models + assert "alibaba/deepseek-v4-pro" in config.models @pytest.mark.asyncio @@ -602,7 +612,8 @@ async def test_login_alibaba_requires_key(monkeypatch, tmp_path): ({}, set()), ({"data": "not a list"}, set()), ({"data": [{"context_length": 1000}]}, set()), - ({"data": [{"id": "unknown-model-xyz"}]}, set()), + ({"data": [{"id": ""}]}, set()), + ({"data": [{"id": "unknown-model-xyz"}]}, {"alibaba/unknown-model-xyz"}), ({"data": [{"id": "qwen3.7-max"}]}, {"alibaba/qwen3.7-max"}), ({"data": [{"id": "deepseek-v3.2"}]}, {"alibaba/deepseek-v3.2"}), ], @@ -614,6 +625,40 @@ def test_parse_discovered_alibaba_models_handles_malformed_payloads(payload, exp assert {m.alias for m in result} == expected_aliases +def test_parse_discovered_alibaba_models_accepts_unknown_and_excludes_image_models(): + from pythinker_code.auth.alibaba import ALIBABA_DEFAULT_CONTEXT, _parse_discovered_models + + payload = { + "data": [ + # Token Plan third-party chat models not in the built-in catalog. + {"id": "glm-5"}, + {"id": "MiniMax-M2.5", "context_length": 200_000}, + {"id": "kimi-k2.5"}, + # Image-generation models must be excluded (can't be the agent LLM). + {"id": "qwen-image-2.0"}, + {"id": "qwen-image-2.0-pro"}, + {"id": "wan2.7-image"}, + {"id": "wan2.7-image-pro"}, + # A known catalog model stays enriched. + {"id": "qwen3.7-plus"}, + ] + } + result = _parse_discovered_models(payload) + by_id = {m.model_id: m for m in result} + + assert set(by_id) == {"glm-5", "MiniMax-M2.5", "kimi-k2.5", "qwen3.7-plus"} + # Unknown models get capabilities/context inferred from their id. + assert by_id["glm-5"].max_context_size == ALIBABA_DEFAULT_CONTEXT + assert by_id["glm-5"].capabilities == frozenset({"always_thinking"}) + assert by_id["MiniMax-M2.5"].max_context_size == 200_000 + assert by_id["MiniMax-M2.5"].capabilities == frozenset({"always_thinking"}) + # Kimi: thinking dial + vision, 256K context inferred. + assert by_id["kimi-k2.5"].capabilities == frozenset({"thinking", "image_in"}) + assert by_id["kimi-k2.5"].max_context_size == 262_144 + # Known catalog model keeps curated capabilities. + assert by_id["qwen3.7-plus"].capabilities == frozenset({"always_thinking", "image_in"}) + + def test_parse_discovered_alibaba_models_overrides_context_length_only_for_positive_int(): from pythinker_code.auth.alibaba import _parse_discovered_models @@ -693,73 +738,48 @@ async def test_logout_alibaba_rejects_non_default_config_location(): @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 +@pytest.mark.parametrize("key", ["sk-sp-test-key", "sk-tok-test-key", "sk-ws-test-key"]) +async def test_login_alibaba_plan_key_routes_to_token_plan(monkeypatch, tmp_path, key): + """Subscription plan keys (sk-sp-/sk-tok-/sk-ws-) all use the Token Plan endpoint.""" + from pythinker_code.auth.alibaba import ALIBABA_TOKEN_PLAN_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) + seen_urls: list[str] = [] + 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"}]}) + seen_urls.append(str(args[2])) + return _FakeAiohttpResponse({"data": [{"id": "qwen3.7-plus"}]}) monkeypatch.setattr(aiohttp.ClientSession, "_request", fake_request) - events = [event async for event in login_alibaba_api_key(config, "sk-sp-test-key")] + events = [event async for event in login_alibaba_api_key(config, key)] assert events[-1].type == "success" + assert seen_urls == [f"{ALIBABA_TOKEN_PLAN_BASE_URL}/models"] provider = next(iter(config.providers.values())) - assert provider.base_url == "https://coding-intl.dashscope.aliyuncs.com/v1" + assert provider.base_url == ALIBABA_TOKEN_PLAN_BASE_URL 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 +async def test_login_alibaba_plan_key_rejected_does_not_probe_china(monkeypatch, tmp_path): + """A rejected plan key gets the Token Plan error without probing a China endpoint.""" + from pythinker_code.auth.alibaba import ALIBABA_TOKEN_PLAN_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 "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) + 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" ) @@ -769,31 +789,6 @@ async def fake_request(*args: object, **kwargs: object) -> object: 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 "Token Plan" in events[-1].message + assert seen_urls == [f"{ALIBABA_TOKEN_PLAN_BASE_URL}/models"] assert config.providers == {}