From d74abe40e63a69d7e43259b52f8e27844bf07231 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 14 Jul 2026 23:40:54 -0400 Subject: [PATCH 1/9] test(llm): characterize provider compatibility --- tests/core/test_create_llm.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/core/test_create_llm.py b/tests/core/test_create_llm.py index 4f4da6e3..3d49291c 100644 --- a/tests/core/test_create_llm.py +++ b/tests/core/test_create_llm.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pytest from inline_snapshot import snapshot from pydantic import SecretStr from pythinker_core.chat_provider.echo import EchoChatProvider @@ -790,6 +791,32 @@ def test_create_llm_openai_legacy_glm_sends_provider_thinking_body(): } +@pytest.mark.parametrize(("thinking", "enabled"), [(False, False), (True, True)]) +def test_create_llm_self_hosted_qwen_uses_chat_template_thinking_toggle( + thinking: bool, enabled: bool +) -> None: + provider = LLMProvider( + type="openai_legacy", + base_url="http://localhost:8080/v1", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="local", + model="Qwen3.6-35B-A3B", + max_context_size=262_144, + capabilities={"thinking"}, + ) + + llm = create_llm(provider, model, thinking=thinking) + + 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] + "chat_template_kwargs": {"enable_thinking": enabled} + } + + def test_create_llm_openai_legacy_glm_sends_disabled_provider_thinking_body(): provider = LLMProvider( type="openai_legacy", @@ -894,6 +921,13 @@ def test_resolve_tool_result_mode_native_vs_compat_proxy(): is None ) assert resolve_tool_result_mode(api_family="openai", base_url="https://api.openai.com") is None + assert ( + resolve_tool_result_mode(api_family="openai", base_url="https://api.openai.com/v1") is None + ) + assert ( + resolve_tool_result_mode(api_family="openai", base_url="https://proxy.example/v1") + == "extract_text" + ) # Anthropic-compatible proxies (z.ai/GLM, MiniMax, Kimi) → flatten. assert ( resolve_tool_result_mode(api_family="anthropic", base_url="https://api.z.ai/api/anthropic") From 26543fa0d8a0df3ef5374455314fd3df039da971 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 14 Jul 2026 23:56:23 -0400 Subject: [PATCH 2/9] refactor(llm): centralize provider compatibility --- .../contrib/chat_provider/common.py | 1 + src/pythinker_code/llm.py | 56 +-- src/pythinker_code/provider_compatibility.py | 373 ++++++++++++++++++ src/pythinker_code/soul/pythinkersoul.py | 6 +- src/pythinker_code/ui/shell/slash.py | 16 +- tests/core/test_model_thinking_levels.py | 22 +- tests/core/test_provider_compatibility.py | 360 +++++++++++++++++ .../test_model_profile_thinking.py | 115 ++++++ 8 files changed, 910 insertions(+), 39 deletions(-) create mode 100644 src/pythinker_code/provider_compatibility.py create mode 100644 tests/core/test_provider_compatibility.py create mode 100644 tests/ui_and_conv/test_model_profile_thinking.py diff --git a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/common.py b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/common.py index d19bf99c..cc9335dd 100644 --- a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/common.py +++ b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/common.py @@ -3,3 +3,4 @@ from typing import Literal type ToolMessageConversion = Literal["extract_text"] +type ReasoningReplayMode = Literal["exact", "tool_calls", "strict_synthetic"] diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index f149d08b..c2fcf915 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -3,15 +3,20 @@ import contextlib import json import os -import re from collections.abc import Collection -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, cast, get_args from pythinker_core.chat_provider import ChatProvider, ThinkingEffort from pythinker_code.constant import USER_AGENT +from pythinker_code.provider_compatibility import ( + ProviderCompatibility, + default_provider_compatibility, + openai_gpt_reasoning_levels, + resolve_provider_compatibility, +) from pythinker_code.thinking import ( DEFAULT_THINKING_EFFORT, available_thinking_levels, @@ -51,6 +56,7 @@ class LLM: chat_provider: ChatProvider max_context_size: int capabilities: set[ModelCapability] + compatibility: ProviderCompatibility = field(default_factory=default_provider_compatibility) model_config: LLMModel | None = None provider_config: LLMProvider | None = None thinking: bool | None = None @@ -309,6 +315,8 @@ def create_llm( ) return None + compatibility = resolve_provider_compatibility(model.provider, provider, model) + resolved_api_key = ( oauth.resolve_api_key(provider.api_key, provider.oauth) if oauth and provider.oauth @@ -583,6 +591,7 @@ def create_llm( chat_provider=chat_provider, max_context_size=model.max_context_size, capabilities=capabilities, + compatibility=compatibility, model_config=model, provider_config=provider, thinking=thinking_effort_enabled(effective_effort) @@ -648,37 +657,10 @@ def derive_model_capabilities(model: LLMModel) -> set[ModelCapability]: return capabilities -_GPT5_REASONING_RE = re.compile(r"gpt-5(?:\.(\d+))?", re.IGNORECASE) - - -def openai_gpt_reasoning_levels(model_id: str) -> tuple[ThinkingEffort, ...] | None: - """Reasoning-effort levels an OpenAI GPT-5-family model actually accepts. - - OpenAI's ``reasoning_effort`` set is model-dependent and has drifted across - the GPT-5 line, so the provider-neutral ladder over-offers levels a given - model rejects (e.g. ``minimal`` on gpt-5.4/5.5). Returns the supported - levels low->high including ``off`` (OpenAI ``none``), or ``None`` when - *model_id* is not a recognized GPT-5 reasoning model. - - Matrix (OpenAI docs): - - * ``5.0`` -> minimal, low, medium, high - * ``5.1`` / ``5.2`` / ``5.3`` -> low, medium, high (``minimal`` replaced by ``none``) - * ``5.1-codex-max``, ``5.4+`` -> low, medium, high, xhigh (``minimal`` dropped) - """ - match = _GPT5_REASONING_RE.search(model_id) - if match is None: - return None - minor = int(match.group(1)) if match.group(1) else 0 - if minor == 0: - return ("off", "minimal", "low", "medium", "high") - if minor >= 4 or "codex-max" in model_id.lower(): - return ("off", "low", "medium", "high", "xhigh") - return ("off", "low", "medium", "high") - - def available_model_thinking_levels( - model: LLMModel, capabilities: Collection[str] | None + model: LLMModel, + capabilities: Collection[str] | None, + compatibility: ProviderCompatibility | None = None, ) -> tuple[ThinkingEffort, ...]: """Selectable thinking levels for *model*, scoped to provider-specific support. @@ -689,10 +671,14 @@ def available_model_thinking_levels( per-model rule. """ base = available_thinking_levels(capabilities) - gpt_levels = openai_gpt_reasoning_levels(model.model) - if gpt_levels is None: + scoped_levels = ( + compatibility.supported_thinking_levels + if compatibility is not None + else openai_gpt_reasoning_levels(model.model) + ) + if scoped_levels is None: return base - allowed = set(gpt_levels) + allowed = set(scoped_levels) scoped: tuple[ThinkingEffort, ...] = tuple(level for level in base if level in allowed) return scoped or base diff --git a/src/pythinker_code/provider_compatibility.py b/src/pythinker_code/provider_compatibility.py new file mode 100644 index 00000000..e305ec26 --- /dev/null +++ b/src/pythinker_code/provider_compatibility.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import re +from collections.abc import Collection +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal +from urllib.parse import urlparse + +from pythinker_core.chat_provider import ThinkingEffort +from pythinker_core.contrib.chat_provider.common import ( + ReasoningReplayMode, + ToolMessageConversion, +) + +from pythinker_code.thinking import ( + DEFAULT_THINKING_EFFORT, + available_thinking_levels, + clamp_thinking_effort, +) + +if TYPE_CHECKING: + from pythinker_code.config import LLMModel, LLMProvider + + +type ApiFamily = Literal["pythinker", "openai", "anthropic", "gemini", "vertex", "test"] +type ThinkingFormat = Literal[ + "native", + "zai_tiered", + "zai_binary", + "kimi", + "dashscope", + "qwen_template", + "none", +] +type ZaiThinkingMode = Literal["tiered", "binary"] + + +@dataclass(frozen=True, slots=True) +class ZaiModelPolicy: + model_id: str + context_tokens: int + max_output_tokens: int + thinking_mode: ZaiThinkingMode + tool_stream: bool + + +@dataclass(slots=True) +class GenerationOverrides: + native_effort: ThinkingEffort | None + generation_kwargs: dict[str, object] + extra_body: dict[str, object] + + +@dataclass(frozen=True, slots=True) +class ProviderCompatibility: + profile_id: str + api_family: ApiFamily + output_tokens_kwarg: str + max_output_tokens: int | None + tool_message_conversion: ToolMessageConversion | None + deferred_tool_search: bool + reasoning_key: str | None + reasoning_replay_mode: ReasoningReplayMode + auto_reasoning_effort: bool + thinking_format: ThinkingFormat + supported_thinking_levels: tuple[ThinkingEffort, ...] | None + tool_stream: bool + + def effective_effort( + self, + requested: ThinkingEffort | None, + capabilities: Collection[str] | None, + ) -> ThinkingEffort | None: + if capabilities and "always_thinking" in capabilities: + if requested is not None and requested != "off": + return requested + return DEFAULT_THINKING_EFFORT + if not capabilities or "thinking" not in capabilities: + return "off" if requested is not None else None + if requested is None: + return None + if self.thinking_format == "zai_binary": + return "off" if requested in {"off", "minimal"} else "high" + if self.thinking_format == "zai_tiered" and requested == "max": + return "max" + levels = self.supported_thinking_levels or available_thinking_levels(capabilities) + return clamp_thinking_effort(requested, levels) + + def request_overrides( + self, + *, + model_id: str, + effort: ThinkingEffort | None, + ) -> GenerationOverrides: + del model_id + generation_kwargs: dict[str, object] = {} + extra_body: dict[str, object] = {} + native_effort: ThinkingEffort | None = None + + if self.max_output_tokens is not None: + generation_kwargs[self.output_tokens_kwarg] = self.max_output_tokens + + if self.thinking_format == "native": + native_effort = effort + elif self.thinking_format == "zai_tiered": + if effort in {"off", "minimal"}: + extra_body["thinking"] = {"type": "disabled"} + else: + extra_body["thinking"] = { + "type": "enabled", + "clear_thinking": False, + } + extra_body["reasoning_effort"] = "max" if effort in {"xhigh", "max"} else "high" + elif self.thinking_format == "zai_binary": + if effort in {"off", "minimal"}: + extra_body["thinking"] = {"type": "disabled"} + else: + extra_body["thinking"] = { + "type": "enabled", + "clear_thinking": False, + } + elif self.thinking_format == "kimi": + extra_body["thinking"] = {"type": "enabled" if _effort_enabled(effort) else "disabled"} + elif self.thinking_format == "dashscope": + extra_body["enable_thinking"] = _effort_enabled(effort) + elif self.thinking_format == "qwen_template": + extra_body["chat_template_kwargs"] = {"enable_thinking": _effort_enabled(effort)} + + return GenerationOverrides( + native_effort=native_effort, + generation_kwargs=generation_kwargs, + extra_body=extra_body, + ) + + +_ZAI_CODING_ENDPOINT = ("api.z.ai", "/api/coding/paas/v4") +_ZAI_API_ENDPOINT = ("api.z.ai", "/api/paas/v4") +_ZAI_PROVIDER_KEYS = frozenset({"managed:z-ai-coding", "managed:z-ai-api"}) +_ZAI_MODEL_POLICIES = ( + ZaiModelPolicy("glm-5.2", 1_000_000, 131_072, "tiered", True), + ZaiModelPolicy("glm-5.1", 204_800, 131_072, "binary", True), + ZaiModelPolicy("glm-5", 204_800, 131_072, "binary", True), + ZaiModelPolicy("glm-5-turbo", 204_800, 131_072, "binary", True), + ZaiModelPolicy("glm-4.7", 204_800, 131_072, "binary", True), + ZaiModelPolicy("glm-4.5-air", 131_072, 98_304, "binary", False), +) +_ZAI_MODEL_POLICIES_BY_ID = {policy.model_id: policy for policy in _ZAI_MODEL_POLICIES} +_GENUINE_ANTHROPIC_HOSTS = frozenset({"api.anthropic.com"}) +_GENUINE_OPENAI_HOSTS = frozenset({"api.openai.com"}) +_GPT5_REASONING_RE = re.compile(r"gpt-5(?:\.(\d+))?", re.IGNORECASE) + + +def get_zai_model_policy(model_id: str) -> ZaiModelPolicy | None: + return _ZAI_MODEL_POLICIES_BY_ID.get(model_id.lower()) + + +def default_provider_compatibility() -> ProviderCompatibility: + return ProviderCompatibility( + profile_id="conservative", + api_family="test", + output_tokens_kwarg="max_tokens", + max_output_tokens=None, + tool_message_conversion=None, + deferred_tool_search=False, + reasoning_key=None, + reasoning_replay_mode="tool_calls", + auto_reasoning_effort=True, + thinking_format="native", + supported_thinking_levels=None, + tool_stream=False, + ) + + +def resolve_provider_compatibility( + provider_key: str, + provider: LLMProvider, + model: LLMModel, +) -> ProviderCompatibility: + api_family = _api_family(provider.type) + endpoint = _normalize_endpoint(provider.base_url) + zai_route = _zai_route(provider_key, api_family, endpoint) + if zai_route is not None: + return _zai_profile(zai_route, provider, model) + + output_kwarg = _output_tokens_kwarg(provider.type) + reasoning_key = _reasoning_key(provider) + supported_levels = openai_gpt_reasoning_levels(model.model) + + if api_family == "anthropic": + native = endpoint[0] in _GENUINE_ANTHROPIC_HOSTS + deferred = native and "haiku" not in model.model.lower() + return ProviderCompatibility( + profile_id="anthropic-native" if native else "anthropic-compatible", + api_family=api_family, + output_tokens_kwarg=output_kwarg, + max_output_tokens=None, + tool_message_conversion=None if native else "extract_text", + deferred_tool_search=deferred, + reasoning_key=None, + reasoning_replay_mode="exact", + auto_reasoning_effort=True, + thinking_format="native", + supported_thinking_levels=supported_levels, + tool_stream=False, + ) + + if api_family == "openai": + native = endpoint[0] in _GENUINE_OPENAI_HOSTS + profile_id = "openai-native" if native else "openai-compatible" + thinking_format: ThinkingFormat = "native" + replay_mode: ReasoningReplayMode = "tool_calls" + if provider.type == "openai_legacy" and _is_dashscope_endpoint(endpoint[0]): + profile_id = "dashscope" + thinking_format = "dashscope" + elif provider.type == "openai_legacy" and _is_kimi_model(model.model): + profile_id = "kimi" + thinking_format = "kimi" + replay_mode = "strict_synthetic" + elif provider.type == "openai_legacy" and _is_qwen3_model(model.model): + profile_id = "qwen-template" + thinking_format = "qwen_template" + elif provider.type == "openai_legacy" and _is_strict_replay_model(model.model): + replay_mode = "strict_synthetic" + return ProviderCompatibility( + profile_id=profile_id, + api_family=api_family, + output_tokens_kwarg=output_kwarg, + max_output_tokens=None, + tool_message_conversion=None if native else "extract_text", + deferred_tool_search=False, + reasoning_key=reasoning_key, + reasoning_replay_mode=replay_mode, + auto_reasoning_effort=True, + thinking_format=thinking_format, + supported_thinking_levels=supported_levels, + tool_stream=False, + ) + + return ProviderCompatibility( + profile_id=api_family, + api_family=api_family, + output_tokens_kwarg=output_kwarg, + max_output_tokens=None, + tool_message_conversion=None, + deferred_tool_search=False, + reasoning_key=None, + reasoning_replay_mode="exact", + auto_reasoning_effort=True, + thinking_format="native", + supported_thinking_levels=None, + tool_stream=False, + ) + + +def openai_gpt_reasoning_levels(model_id: str) -> tuple[ThinkingEffort, ...] | None: + match = _GPT5_REASONING_RE.search(model_id) + if match is None: + return None + minor = int(match.group(1)) if match.group(1) else 0 + if minor == 0: + return ("off", "minimal", "low", "medium", "high") + if minor >= 4 or "codex-max" in model_id.lower(): + return ("off", "low", "medium", "high", "xhigh") + return ("off", "low", "medium", "high") + + +def _zai_profile( + route: Literal["z-ai-coding", "z-ai-api"], + provider: LLMProvider, + model: LLMModel, +) -> ProviderCompatibility: + policy = get_zai_model_policy(model.model) + if policy is None: + thinking_format: ThinkingFormat = "none" + supported_levels = None + max_output_tokens = None + tool_stream = False + else: + thinking_format = "zai_tiered" if policy.thinking_mode == "tiered" else "zai_binary" + supported_levels = ( + ("off", "minimal", "low", "medium", "high", "xhigh") + if policy.thinking_mode == "tiered" + else ("off", "high") + ) + max_output_tokens = policy.max_output_tokens + tool_stream = policy.tool_stream + return ProviderCompatibility( + profile_id=route, + api_family="openai", + output_tokens_kwarg="max_tokens", + max_output_tokens=max_output_tokens, + tool_message_conversion="extract_text", + deferred_tool_search=False, + reasoning_key=_reasoning_key(provider), + reasoning_replay_mode="exact", + auto_reasoning_effort=False, + thinking_format=thinking_format, + supported_thinking_levels=supported_levels, + tool_stream=tool_stream, + ) + + +def _api_family(provider_type: str) -> ApiFamily: + if provider_type == "pythinker": + return "pythinker" + if provider_type in {"openai_legacy", "openai_responses", "openai_codex"}: + return "openai" + if provider_type == "anthropic": + return "anthropic" + if provider_type in {"google_genai", "gemini"}: + return "gemini" + if provider_type == "vertexai": + return "vertex" + return "test" + + +def _output_tokens_kwarg(provider_type: str) -> str: + if provider_type in {"openai_responses", "openai_codex", "google_genai", "gemini", "vertexai"}: + return "max_output_tokens" + return "max_tokens" + + +def _reasoning_key(provider: LLMProvider) -> str | None: + if provider.type != "openai_legacy": + return None + return provider.reasoning_key if provider.reasoning_key is not None else "reasoning_content" + + +def _normalize_endpoint(base_url: str | None) -> tuple[str, str]: + if not base_url: + return ("", "/") + parsed = urlparse(base_url) + host = (parsed.hostname or "").lower() + path = f"/{parsed.path.lstrip('/')}".rstrip("/") or "/" + return (host, path) + + +def _zai_route( + provider_key: str, + api_family: ApiFamily, + endpoint: tuple[str, str], +) -> Literal["z-ai-coding", "z-ai-api"] | None: + if provider_key in _ZAI_PROVIDER_KEYS: + return "z-ai-coding" if provider_key == "managed:z-ai-coding" else "z-ai-api" + if api_family != "openai": + return None + if endpoint == _ZAI_CODING_ENDPOINT: + return "z-ai-coding" + if endpoint == _ZAI_API_ENDPOINT: + return "z-ai-api" + return None + + +def _is_dashscope_endpoint(host: str) -> bool: + return host == "aliyuncs.com" or host.endswith(".aliyuncs.com") + + +def _is_kimi_model(model_id: str) -> bool: + return "kimi-k2" in model_id.lower().replace("_", "-") + + +def _is_qwen3_model(model_id: str) -> bool: + normalized = model_id.lower().replace("_", "-") + return "qwen3" in normalized or "qwen-3" in normalized + + +def _is_strict_replay_model(model_id: str) -> bool: + normalized = model_id.lower() + return "deepseek" in normalized or _is_kimi_model(normalized) + + +def _effort_enabled(effort: ThinkingEffort | None) -> bool: + return effort is not None and effort != "off" diff --git a/src/pythinker_code/soul/pythinkersoul.py b/src/pythinker_code/soul/pythinkersoul.py index 8990ecb8..945c057b 100644 --- a/src/pythinker_code/soul/pythinkersoul.py +++ b/src/pythinker_code/soul/pythinkersoul.py @@ -1075,7 +1075,11 @@ def available_thinking_efforts(self) -> tuple[ThinkingEffort, ...]: return available_thinking_levels(self._runtime.llm.capabilities) from pythinker_code.llm import available_model_thinking_levels - return available_model_thinking_levels(model, self._runtime.llm.capabilities) + return available_model_thinking_levels( + model, + self._runtime.llm.capabilities, + self._runtime.llm.compatibility, + ) def set_thinking_effort_from_manual(self, effort: ThinkingEffort) -> ThinkingEffort | None: """Apply a user-selected thinking level to the live runtime. diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 28b9ebd4..97f6c948 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -305,12 +305,24 @@ async def model(app: Shell, args: str): # Step 2: Determine thinking effort capabilities = derive_model_capabilities(selected_model_cfg) - from pythinker_code.llm import available_model_thinking_levels + from pythinker_code.llm import ( + available_model_thinking_levels, + resolve_provider_compatibility, + ) from pythinker_code.thinking import clamp_thinking_effort from pythinker_code.ui.shell.selectors.thinking import ThinkingLevel, run_thinking_selector native_thinking = model_uses_native_thinking(capabilities) - available_efforts = available_model_thinking_levels(selected_model_cfg, capabilities) + compatibility = resolve_provider_compatibility( + selected_model_cfg.provider, + selected_provider, + selected_model_cfg, + ) + available_efforts = available_model_thinking_levels( + selected_model_cfg, + capabilities, + compatibility, + ) if native_thinking or available_efforts == ("off",): new_effort = "off" else: diff --git a/tests/core/test_model_thinking_levels.py b/tests/core/test_model_thinking_levels.py index fa49e481..462ea84a 100644 --- a/tests/core/test_model_thinking_levels.py +++ b/tests/core/test_model_thinking_levels.py @@ -9,11 +9,14 @@ from __future__ import annotations -from pythinker_code.config import LLMModel +from pydantic import SecretStr + +from pythinker_code.config import LLMModel, LLMProvider from pythinker_code.llm import ( available_model_thinking_levels, openai_gpt_reasoning_levels, ) +from pythinker_code.provider_compatibility import resolve_provider_compatibility from pythinker_code.thinking import clamp_thinking_effort @@ -82,6 +85,23 @@ def test_available_model_thinking_levels_non_gpt_keeps_full_ladder() -> None: ) +def test_available_model_thinking_levels_prefers_profile_override() -> None: + provider = LLMProvider( + type="openai_legacy", + base_url="https://api.z.ai/api/paas/v4", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="managed:z-ai-api", + model="glm-4.7", + max_context_size=204_800, + capabilities={"thinking"}, + ) + profile = resolve_provider_compatibility(model.provider, provider, model) + + assert available_model_thinking_levels(model, {"thinking"}, profile) == ("off", "high") + + def test_unsupported_effort_clamps_up_to_supported() -> None: # The create_llm send-path clamps a persisted unsupported effort to the # nearest supported level, so 'minimal' is never sent to gpt-5.5. diff --git a/tests/core/test_provider_compatibility.py b/tests/core/test_provider_compatibility.py new file mode 100644 index 00000000..ac0856a6 --- /dev/null +++ b/tests/core/test_provider_compatibility.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest +from pydantic import SecretStr + +from pythinker_code.config import LLMModel, LLMProvider +from pythinker_code.provider_compatibility import ( + GenerationOverrides, + get_zai_model_policy, + resolve_provider_compatibility, +) + + +def _provider(provider_type: str, base_url: str) -> LLMProvider: + return LLMProvider( + type=provider_type, # type: ignore[arg-type] + base_url=base_url, + api_key=SecretStr("test-key"), + ) + + +def _model(provider_key: str, model_id: str, *, thinking: bool = True) -> LLMModel: + return LLMModel( + provider=provider_key, + model=model_id, + max_context_size=200_000, + capabilities={"thinking"} if thinking else None, + ) + + +@pytest.mark.parametrize( + ( + "provider_key", + "provider_type", + "base_url", + "model_id", + "profile_id", + "api_family", + "thinking_format", + "tool_conversion", + "deferred_tool_search", + ), + [ + ( + "anthropic", + "anthropic", + "https://api.anthropic.com/v1", + "claude-opus-4-8", + "anthropic-native", + "anthropic", + "native", + None, + True, + ), + ( + "proxy", + "anthropic", + "https://proxy.example/anthropic", + "claude-opus-4-8", + "anthropic-compatible", + "anthropic", + "native", + "extract_text", + False, + ), + ( + "openai", + "openai_legacy", + "https://api.openai.com/v1", + "gpt-5.2", + "openai-native", + "openai", + "native", + None, + False, + ), + ( + "proxy", + "openai_legacy", + "https://proxy.example/v1", + "some-model", + "openai-compatible", + "openai", + "native", + "extract_text", + False, + ), + ( + "managed:alibaba", + "openai_legacy", + "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + "qwen3-max", + "dashscope", + "openai", + "dashscope", + "extract_text", + False, + ), + ( + "moonshot", + "openai_legacy", + "https://api.moonshot.ai/v1", + "kimi-k2.6", + "kimi", + "openai", + "kimi", + "extract_text", + False, + ), + ( + "local", + "openai_legacy", + "http://localhost:8080/v1", + "Qwen3.6-35B-A3B", + "qwen-template", + "openai", + "qwen_template", + "extract_text", + False, + ), + ], +) +def test_resolver_preserves_existing_provider_matrix( + provider_key: str, + provider_type: str, + base_url: str, + model_id: str, + profile_id: str, + api_family: str, + thinking_format: str, + tool_conversion: str | None, + deferred_tool_search: bool, +) -> None: + profile = resolve_provider_compatibility( + provider_key, + _provider(provider_type, base_url), + _model(provider_key, model_id), + ) + + assert profile.profile_id == profile_id + assert profile.api_family == api_family + assert profile.thinking_format == thinking_format + assert profile.tool_message_conversion == tool_conversion + assert profile.deferred_tool_search is deferred_tool_search + + +@pytest.mark.parametrize( + ("provider_key", "base_url", "profile_id"), + [ + ( + "managed:z-ai-coding", + "https://unrelated.example/v1", + "z-ai-coding", + ), + ( + "managed:z-ai-api", + "https://api.z.ai/api/coding/paas/v4", + "z-ai-api", + ), + ( + "custom", + "https://API.Z.AI/api/coding/paas/v4/", + "z-ai-coding", + ), + ( + "custom", + "https://api.z.ai/api/paas/v4/", + "z-ai-api", + ), + ], +) +def test_zai_identity_precedence_and_normalized_endpoint_fallback( + provider_key: str, base_url: str, profile_id: str +) -> None: + profile = resolve_provider_compatibility( + provider_key, + _provider("openai_legacy", base_url), + _model(provider_key, "glm-5.2"), + ) + + assert profile.profile_id == profile_id + assert profile.api_family == "openai" + assert profile.thinking_format == "zai_tiered" + assert profile.reasoning_replay_mode == "exact" + assert profile.auto_reasoning_effort is False + assert profile.tool_stream is True + + +def test_local_glm_name_does_not_activate_zai_policy() -> None: + profile = resolve_provider_compatibility( + "local", + _provider("openai_legacy", "http://localhost:8080/v1"), + _model("local", "glm-5.2"), + ) + + assert profile.profile_id == "openai-compatible" + assert not profile.thinking_format.startswith("zai_") + assert profile.tool_stream is False + assert profile.max_output_tokens is None + + +def test_zai_model_policy_matrix_is_literal_and_immutable() -> None: + expected = { + "glm-5.2": (1_000_000, 131_072, "tiered", True), + "glm-5.1": (204_800, 131_072, "binary", True), + "glm-5": (204_800, 131_072, "binary", True), + "glm-5-turbo": (204_800, 131_072, "binary", True), + "glm-4.7": (204_800, 131_072, "binary", True), + "glm-4.5-air": (131_072, 98_304, "binary", False), + } + + for model_id, values in expected.items(): + policy = get_zai_model_policy(model_id) + assert policy is not None + assert ( + policy.context_tokens, + policy.max_output_tokens, + policy.thinking_mode, + policy.tool_stream, + ) == values + with pytest.raises(FrozenInstanceError): + policy.max_output_tokens = 1 # pyright: ignore[reportAttributeAccessIssue] + + assert get_zai_model_policy("glm-future") is None + assert get_zai_model_policy("glm-5.2[1m]") is None + + +def test_profiles_are_frozen_and_request_override_dicts_are_fresh() -> None: + profile = resolve_provider_compatibility( + "managed:z-ai-coding", + _provider("openai_legacy", "https://api.z.ai/api/coding/paas/v4"), + _model("managed:z-ai-coding", "glm-5.2"), + ) + + with pytest.raises(FrozenInstanceError): + profile.profile_id = "changed" # pyright: ignore[reportAttributeAccessIssue] + + first = profile.request_overrides(model_id="glm-5.2", effort="high") + second = profile.request_overrides(model_id="glm-5.2", effort="high") + assert first == GenerationOverrides( + native_effort=None, + generation_kwargs={"max_tokens": 131_072}, + extra_body={ + "thinking": {"type": "enabled", "clear_thinking": False}, + "reasoning_effort": "high", + }, + ) + first.extra_body["mutated"] = True + assert "mutated" not in second.extra_body + + +@pytest.mark.parametrize( + ("effort", "expected_thinking", "expected_reasoning_effort"), + [ + ("off", {"type": "disabled"}, None), + ("minimal", {"type": "disabled"}, None), + ("low", {"type": "enabled", "clear_thinking": False}, "high"), + ("medium", {"type": "enabled", "clear_thinking": False}, "high"), + ("high", {"type": "enabled", "clear_thinking": False}, "high"), + ("xhigh", {"type": "enabled", "clear_thinking": False}, "max"), + ("max", {"type": "enabled", "clear_thinking": False}, "max"), + ], +) +def test_glm52_request_overrides( + effort: str, expected_thinking: dict[str, object], expected_reasoning_effort: str | None +) -> None: + profile = resolve_provider_compatibility( + "managed:z-ai-api", + _provider("openai_legacy", "https://api.z.ai/api/paas/v4"), + _model("managed:z-ai-api", "glm-5.2"), + ) + + overrides = profile.request_overrides(model_id="glm-5.2", effort=effort) # type: ignore[arg-type] + + assert overrides.native_effort is None + assert overrides.generation_kwargs == {"max_tokens": 131_072} + assert overrides.extra_body["thinking"] == expected_thinking + assert overrides.extra_body.get("reasoning_effort") == expected_reasoning_effort + + +@pytest.mark.parametrize( + ("effort", "expected_effort", "expected_thinking"), + [ + ("off", "off", {"type": "disabled"}), + ("minimal", "off", {"type": "disabled"}), + ("low", "high", {"type": "enabled", "clear_thinking": False}), + ("xhigh", "high", {"type": "enabled", "clear_thinking": False}), + ], +) +def test_binary_zai_effort_maps_before_request_assembly( + effort: str, expected_effort: str, expected_thinking: dict[str, object] +) -> None: + profile = resolve_provider_compatibility( + "managed:z-ai-api", + _provider("openai_legacy", "https://api.z.ai/api/paas/v4"), + _model("managed:z-ai-api", "glm-5.1"), + ) + + effective = profile.effective_effort(effort, {"thinking"}) # type: ignore[arg-type] + overrides = profile.request_overrides(model_id="glm-5.1", effort=effective) + + assert effective == expected_effort + assert overrides.native_effort is None + assert overrides.generation_kwargs == {"max_tokens": 131_072} + assert overrides.extra_body == {"thinking": expected_thinking} + + +def test_zai_thinking_levels_are_model_specific() -> None: + tiered = resolve_provider_compatibility( + "managed:z-ai-coding", + _provider("openai_legacy", "https://api.z.ai/api/coding/paas/v4"), + _model("managed:z-ai-coding", "glm-5.2"), + ) + binary = resolve_provider_compatibility( + "managed:z-ai-coding", + _provider("openai_legacy", "https://api.z.ai/api/coding/paas/v4"), + _model("managed:z-ai-coding", "glm-4.7"), + ) + + assert tiered.supported_thinking_levels == ( + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + ) + assert binary.supported_thinking_levels == ("off", "high") + + +def test_unknown_zai_model_is_conservative() -> None: + profile = resolve_provider_compatibility( + "managed:z-ai-api", + _provider("openai_legacy", "https://api.z.ai/api/paas/v4"), + _model("managed:z-ai-api", "glm-future", thinking=False), + ) + + assert profile.profile_id == "z-ai-api" + assert profile.max_output_tokens is None + assert profile.thinking_format == "none" + assert profile.supported_thinking_levels is None + assert profile.tool_stream is False + assert profile.effective_effort(None, None) is None + assert profile.request_overrides(model_id="glm-future", effort=None) == GenerationOverrides( + native_effort=None, + generation_kwargs={}, + extra_body={}, + ) + + +def test_generic_profile_preserves_unconfigured_effort() -> None: + profile = resolve_provider_compatibility( + "proxy", + _provider("openai_legacy", "https://proxy.example/v1"), + _model("proxy", "plain-model", thinking=False), + ) + + assert profile.effective_effort(None, None) is None diff --git a/tests/ui_and_conv/test_model_profile_thinking.py b/tests/ui_and_conv/test_model_profile_thinking.py new file mode 100644 index 00000000..5df9835c --- /dev/null +++ b/tests/ui_and_conv/test_model_profile_thinking.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from pydantic import SecretStr +from pythinker_core.chat_provider import ChatProvider + +from pythinker_code.config import Config, LLMModel, LLMProvider +from pythinker_code.llm import LLM +from pythinker_code.provider_compatibility import ( + ProviderCompatibility, + resolve_provider_compatibility, +) +from pythinker_code.soul.pythinkersoul import PythinkerSoul + + +def _zai_config(model_id: str = "glm-5.1") -> Config: + provider_key = "managed:z-ai-coding" + return Config( + is_from_default_location=True, + default_model="z-ai-coding/model", + providers={ + provider_key: LLMProvider( + type="openai_legacy", + base_url="https://api.z.ai/api/coding/paas/v4", + api_key=SecretStr("test-key"), + ) + }, + models={ + "z-ai-coding/model": LLMModel( + provider=provider_key, + model=model_id, + max_context_size=204_800, + capabilities={"thinking"}, + ) + }, + ) + + +def test_soul_available_thinking_efforts_reads_runtime_profile() -> None: + config = _zai_config() + model = config.models[config.default_model] + provider = config.providers[model.provider] + profile = resolve_provider_compatibility(model.provider, provider, model) + llm = LLM( + chat_provider=cast(ChatProvider, SimpleNamespace(model_name=model.model)), + max_context_size=model.max_context_size, + capabilities={"thinking"}, + model_config=model, + provider_config=provider, + compatibility=profile, + ) + soul = object.__new__(PythinkerSoul) + soul._runtime = SimpleNamespace(llm=llm) # pyright: ignore[reportAttributeAccessIssue] + + assert soul.available_thinking_efforts() == ("off", "high") + + +async def test_model_selector_resolves_selected_models_profile( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from pythinker_code import llm as llm_module + from pythinker_code.ui.shell import model_picker + from pythinker_code.ui.shell import slash as shell_slash + from pythinker_code.ui.shell.selectors import thinking as thinking_selector + + config = _zai_config("glm-5.2") + model = config.models[config.default_model] + provider = config.providers[model.provider] + current_llm = SimpleNamespace(model_config=model) + soul = SimpleNamespace( + runtime=SimpleNamespace(config=config, llm=current_llm), + thinking_effort="off", + thinking=False, + ) + + class _ModelPicker: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + async def run(self) -> str: + return config.default_model + + resolutions: list[tuple[str, LLMProvider, LLMModel]] = [] + original_resolver = llm_module.resolve_provider_compatibility + + def recording_resolver( + provider_key: str, + selected_provider: LLMProvider, + selected_model: LLMModel, + ) -> ProviderCompatibility: + resolutions.append((provider_key, selected_provider, selected_model)) + return original_resolver(provider_key, selected_provider, selected_model) + + captured_levels: list[str] = [] + + async def capture_selector(*, current_level: str, available_levels: list[str]) -> None: + del current_level + captured_levels.extend(available_levels) + + async def no_refresh(_config: Config) -> None: + return None + + monkeypatch.setattr(shell_slash, "ensure_pythinker_soul", lambda _app: soul) + monkeypatch.setattr(shell_slash, "refresh_managed_models", no_refresh) + monkeypatch.setattr(model_picker, "ModelPickerApp", _ModelPicker) + monkeypatch.setattr(llm_module, "resolve_provider_compatibility", recording_resolver) + monkeypatch.setattr(thinking_selector, "run_thinking_selector", capture_selector) + + await cast(Any, shell_slash.model)(cast(Any, SimpleNamespace()), "") + + assert resolutions == [(model.provider, provider, model)] + assert captured_levels == ["off", "minimal", "low", "medium", "high", "xhigh"] From 97980ad2e1b94eccd3482a776ee9c6c8c5ee6b79 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 15 Jul 2026 00:07:57 -0400 Subject: [PATCH 3/9] refactor(core): make reasoning replay explicit --- .../contrib/chat_provider/openai_legacy.py | 47 ++++-- .../api_snapshot_tests/test_openai_legacy.py | 157 ++++++++++++++++++ 2 files changed, 188 insertions(+), 16 deletions(-) diff --git a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py index 179683f2..d0f63f15 100644 --- a/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py +++ b/packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py @@ -29,7 +29,10 @@ thinking_effort_to_reasoning_effort, tool_to_openai, ) -from pythinker_core.contrib.chat_provider.common import ToolMessageConversion +from pythinker_core.contrib.chat_provider.common import ( + ReasoningReplayMode, + ToolMessageConversion, +) from pythinker_core.message import ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart from pythinker_core.tooling import Tool @@ -76,6 +79,9 @@ def __init__( base_url: str | None = None, stream: bool = True, reasoning_key: str | None = None, + reasoning_replay_mode: ReasoningReplayMode | None = None, + auto_reasoning_effort: bool = True, + tool_stream: bool = False, tool_message_conversion: ToolMessageConversion | None = None, **client_kwargs: Any, ): @@ -98,6 +104,11 @@ def __init__( """The underlying `AsyncOpenAI` client.""" self._reasoning_effort: ReasoningEffort | Omit = omit self._reasoning_key = reasoning_key + self._reasoning_replay_mode: ReasoningReplayMode = reasoning_replay_mode or ( + "strict_synthetic" if _is_strict_interleaved_model(model) else "tool_calls" + ) + self._auto_reasoning_effort = auto_reasoning_effort + self._tool_stream = tool_stream self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion self._generation_kwargs: OpenAILegacy.GenerationKwargs = {} @@ -124,14 +135,22 @@ async def generate( messages.extend(self._convert_message(message) for message in history) generation_kwargs: dict[str, Any] = {} - generation_kwargs.update(self._generation_kwargs) + generation_kwargs.update(copy.deepcopy(self._generation_kwargs)) + if self._tool_stream and tools: + extra_body = dict(generation_kwargs.get("extra_body") or {}) + extra_body["tool_stream"] = True + generation_kwargs["extra_body"] = extra_body reasoning_effort = self._reasoning_effort # Auto-enable reasoning_effort when the history contains ThinkPart but reasoning # was not explicitly configured. This prevents server validation errors from APIs # (e.g. One API) that require reasoning_effort when messages contain reasoning_content. # See: https://github.com/Pythoughts-labs/pythinker-code/issues/1616 - if isinstance(reasoning_effort, Omit) and self._reasoning_key: + if ( + self._auto_reasoning_effort + and isinstance(reasoning_effort, Omit) + and self._reasoning_key + ): has_think_part = any( isinstance(part, ThinkPart) for message in history for part in message.content ) @@ -213,19 +232,15 @@ def _convert_message(self, message: Message) -> ChatCompletionMessageParam: else: message.content = content dumped_message = message.model_dump(exclude_none=True) - if self._reasoning_key: - # Kimi-style interleaved-thinking providers require consistent - # reasoning replay metadata on assistant history. At a minimum - # tool-call turns need the field, and known strict models are - # safest when all assistant turns include it, even if empty. - has_tool_calls = message.role == "assistant" and bool(message.tool_calls) - strict_interleaved = message.role == "assistant" and _is_strict_interleaved_model( - self.model - ) - if reasoning_content or has_tool_calls or strict_interleaved: - if strict_interleaved and not reasoning_content: - reasoning_content = message.extract_text() or "[reasoning unavailable]" - dumped_message[self._reasoning_key] = reasoning_content + if self._reasoning_key and reasoning_content: + dumped_message[self._reasoning_key] = reasoning_content + elif self._reasoning_key and message.role == "assistant": + if self._reasoning_replay_mode == "tool_calls" and message.tool_calls: + dumped_message[self._reasoning_key] = "" + elif self._reasoning_replay_mode == "strict_synthetic": + dumped_message[self._reasoning_key] = ( + message.extract_text() or "[reasoning unavailable]" + ) return cast(ChatCompletionMessageParam, dumped_message) diff --git a/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py b/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py index c5868625..dffeb998 100644 --- a/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py +++ b/packages/pythinker-core/tests/api_snapshot_tests/test_openai_legacy.py @@ -2,13 +2,16 @@ import json +import pytest import respx from common import COMMON_CASES, Case, make_chat_completion_response, run_test_cases from httpx import Response from inline_snapshot import snapshot +from pythinker_core.contrib.chat_provider.common import ReasoningReplayMode from pythinker_core.contrib.chat_provider.openai_legacy import OpenAILegacy from pythinker_core.message import Message, TextPart, ThinkPart, ToolCall +from pythinker_core.tooling import Tool TEST_CASES: dict[str, Case] = {**COMMON_CASES} @@ -467,6 +470,160 @@ async def test_openai_legacy_reasoning_content_not_forced_on_plain_assistant(): assert "reasoning_content" not in body["messages"][1] +@pytest.mark.parametrize( + ("replay_mode", "expected_reasoning"), + [ + ("exact", None), + ("tool_calls", ""), + ("strict_synthetic", "[reasoning unavailable]"), + ], +) +async def test_openai_legacy_explicit_reasoning_replay_modes_override_model_inference( + replay_mode: ReasoningReplayMode, + expected_reasoning: str | None, +) -> None: + with respx.mock(base_url="https://api.openai.com") as mock: + mock.post("/v1/chat/completions").mock( + return_value=Response(200, json=make_chat_completion_response()) + ) + provider = OpenAILegacy( + model="kimi-k2.6", + api_key="test-key", + stream=False, + reasoning_key="reasoning_content", + reasoning_replay_mode=replay_mode, + ) + history = [ + Message(role="user", content="List files"), + Message( + role="assistant", + content=[], + tool_calls=[ + ToolCall( + id="call_1", + function=ToolCall.FunctionBody(name="ls", arguments="{}"), + ) + ], + ), + ] + + stream = await provider.generate("", [], history) + async for _ in stream: + pass + + body = json.loads(mock.calls.last.request.content.decode()) + assistant = body["messages"][1] + if expected_reasoning is None: + assert "reasoning_content" not in assistant + else: + assert assistant["reasoning_content"] == expected_reasoning + + +async def test_openai_legacy_omitted_replay_mode_preserves_generic_tool_call_behavior() -> None: + with respx.mock(base_url="https://api.openai.com") as mock: + mock.post("/v1/chat/completions").mock( + return_value=Response(200, json=make_chat_completion_response()) + ) + provider = OpenAILegacy( + model="plain-model", + api_key="test-key", + stream=False, + reasoning_key="reasoning_content", + ) + history = [ + Message( + role="assistant", + content=[], + tool_calls=[ + ToolCall( + id="call_1", + function=ToolCall.FunctionBody(name="ls", arguments="{}"), + ) + ], + ) + ] + + stream = await provider.generate("", [], history) + async for _ in stream: + pass + + body = json.loads(mock.calls.last.request.content.decode()) + assert body["messages"][0]["reasoning_content"] == "" + + +async def test_openai_legacy_exact_replay_preserves_multiple_think_parts_without_auto_effort(): + with respx.mock(base_url="https://api.openai.com") as mock: + mock.post("/v1/chat/completions").mock( + return_value=Response(200, json=make_chat_completion_response()) + ) + provider = OpenAILegacy( + model="glm-5.2", + api_key="test-key", + stream=False, + reasoning_key="reasoning_content", + reasoning_replay_mode="exact", + auto_reasoning_effort=False, + ) + history = [ + Message( + role="assistant", + content=[ + ThinkPart(think="first\n"), + ThinkPart(think="second"), + TextPart(text="visible"), + ], + ) + ] + + stream = await provider.generate("", [], history) + async for _ in stream: + pass + + body = json.loads(mock.calls.last.request.content.decode()) + assert body["messages"][0]["reasoning_content"] == "first\nsecond" + assert "reasoning_effort" not in body + + +@pytest.mark.parametrize("with_tools", [False, True]) +async def test_openai_legacy_tool_stream_merges_with_existing_extra_body( + with_tools: bool, +) -> None: + with respx.mock(base_url="https://api.openai.com") as mock: + mock.post("/v1/chat/completions").mock( + return_value=Response(200, json=make_chat_completion_response()) + ) + provider = OpenAILegacy( + model="glm-5.2", + api_key="test-key", + stream=False, + tool_stream=True, + ).with_generation_kwargs( + extra_body={"thinking": {"type": "enabled", "clear_thinking": False}} + ) + tools = ( + [ + Tool( + name="read", + description="Read a file", + parameters={"type": "object", "properties": {}}, + ) + ] + if with_tools + else [] + ) + + stream = await provider.generate("", tools, [Message(role="user", content="hello")]) + async for _ in stream: + pass + + body = json.loads(mock.calls.last.request.content.decode()) + assert body["thinking"] == {"type": "enabled", "clear_thinking": False} + assert body.get("tool_stream") is (True if with_tools else None) + assert provider._generation_kwargs == { # pyright: ignore[reportPrivateUsage] + "extra_body": {"thinking": {"type": "enabled", "clear_thinking": False}} + } + + async def test_openai_legacy_reasoning_content_forced_on_known_interleaved_plain_assistant(): """Kimi/DeepSeek-style interleaved-thinking providers need consistent assistant replay metadata across turns, not only on turns where visible reasoning text exists.""" From 3544f656fc07e5e4084b40eee340d00e9b554d86 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 15 Jul 2026 00:39:18 -0400 Subject: [PATCH 4/9] feat(auth): activate dual Z.AI routes --- src/pythinker_code/auth/__init__.py | 6 +- src/pythinker_code/auth/platforms.py | 52 +- src/pythinker_code/auth/z_ai.py | 438 ++++++----- src/pythinker_code/llm.py | 217 +----- src/pythinker_code/provider_compatibility.py | 38 +- src/pythinker_code/ui/shell/oauth.py | 48 +- tests/auth/test_platforms.py | 74 ++ tests/auth/test_z_ai_auth.py | 770 +++++++++---------- tests/core/test_compaction_overflow.py | 18 +- tests/core/test_create_llm.py | 94 ++- tests/core/test_provider_compatibility.py | 5 + tests/core/test_tool_search_gating.py | 26 +- tests/ui_and_conv/test_openai_shell_login.py | 81 ++ 13 files changed, 1030 insertions(+), 837 deletions(-) diff --git a/src/pythinker_code/auth/__init__.py b/src/pythinker_code/auth/__init__.py index 718ba8a3..cdc5b77c 100644 --- a/src/pythinker_code/auth/__init__.py +++ b/src/pythinker_code/auth/__init__.py @@ -13,7 +13,8 @@ OPENROUTER_PLATFORM_ID = "openrouter" LM_STUDIO_PLATFORM_ID = "lm-studio" OLLAMA_PLATFORM_ID = "ollama" -ZAI_PLATFORM_ID = "z-ai" +ZAI_CODING_PLATFORM_ID = "z-ai-coding" +ZAI_API_PLATFORM_ID = "z-ai-api" __all__ = [ "ALIBABA_PLATFORM_ID", @@ -29,5 +30,6 @@ "OPENCODE_GO_PLATFORM_ID", "OPENROUTER_PLATFORM_ID", "PYTHINKER_CODE_PLATFORM_ID", - "ZAI_PLATFORM_ID", + "ZAI_API_PLATFORM_ID", + "ZAI_CODING_PLATFORM_ID", ] diff --git a/src/pythinker_code/auth/platforms.py b/src/pythinker_code/auth/platforms.py index 4c9e5714..ad22780b 100644 --- a/src/pythinker_code/auth/platforms.py +++ b/src/pythinker_code/auth/platforms.py @@ -250,8 +250,9 @@ async def refresh_managed_models(config: Config) -> bool: refresh_opencode_go_models, ) from pythinker_code.auth.z_ai import ( - ZAI_PROVIDER_KEY, - ZaiModel, + ZAI_ROUTES, + ZaiCatalogResult, + ZaiRoute, apply_z_ai_models, refresh_z_ai_models, ) @@ -264,16 +265,17 @@ async def refresh_managed_models(config: Config) -> bool: changed = False updates: list[tuple[str, str, list[ModelInfo]]] = [] + z_ai_provider_keys = {route.provider_key for route in ZAI_ROUTES} oauth_manager = None for provider_key, provider in managed_providers.items(): # OpenCode Go and MiniMax own provider-specific model discovery. The # generic `managed:` path can't express OpenCode Go's # two-provider split, and MiniMax's provider key intentionally includes # the wire-shape suffix (`managed:minimax-anthropic`). - if provider_key in OPENCODE_GO_PROVIDER_KEYS or provider_key in ( - MINIMAX_ANTHROPIC_PROVIDER_KEY, - ZAI_PROVIDER_KEY, - KIMI_PROVIDER_KEY, + if ( + provider_key in OPENCODE_GO_PROVIDER_KEYS + or provider_key in {MINIMAX_ANTHROPIC_PROVIDER_KEY, KIMI_PROVIDER_KEY} + or provider_key in z_ai_provider_keys ): continue platform_id = parse_managed_provider_key(provider_key) @@ -430,13 +432,29 @@ async def refresh_managed_models(config: Config) -> bool: if minimax_models is not None and apply_minimax_models(config, minimax_models): changed = True - z_ai_models: tuple[ZaiModel, ...] | None = None - try: - z_ai_models = await refresh_z_ai_models(config) - except (aiohttp.ClientError, TimeoutError, ValueError) as exc: - logger.warning("Failed to refresh Z AI models: {error}", error=exc) - if z_ai_models is not None and apply_z_ai_models(config, z_ai_models): - changed = True + z_ai_results: dict[ZaiRoute, ZaiCatalogResult] = {} + for route in ZAI_ROUTES: + try: + result = await refresh_z_ai_models(config, route) + except Exception as exc: + logger.warning( + "Unexpected Z.AI catalog refresh failure for {route}: {error_type}", + route=route.platform_id, + error_type=type(exc).__name__, + ) + continue + z_ai_results[route] = result + if result.status == "live": + assert result.models is not None + if apply_z_ai_models(config, route, result.models): + changed = True + elif result.status != "unconfigured": + logger.warning( + "Z.AI catalog refresh did not update {route}: status={status}, failure={failure}", + route=route.platform_id, + status=result.status, + failure=result.failure, + ) kimi_models: tuple[KimiModel, ...] | None = None try: @@ -456,8 +474,12 @@ async def refresh_managed_models(config: Config) -> bool: save_changed = True if minimax_models is not None and apply_minimax_models(config_for_save, minimax_models): save_changed = True - if z_ai_models is not None and apply_z_ai_models(config_for_save, z_ai_models): - save_changed = True + for route, result in z_ai_results.items(): + if result.status != "live": + continue + assert result.models is not None + if apply_z_ai_models(config_for_save, route, result.models): + save_changed = True if kimi_models is not None and apply_kimi_models(config_for_save, kimi_models): save_changed = True if save_changed: diff --git a/src/pythinker_code/auth/z_ai.py b/src/pythinker_code/auth/z_ai.py index f8a15985..3c9136f2 100644 --- a/src/pythinker_code/auth/z_ai.py +++ b/src/pythinker_code/auth/z_ai.py @@ -3,100 +3,110 @@ import os from collections.abc import AsyncIterator, Mapping from dataclasses import dataclass -from typing import Any, cast +from typing import Any, Literal, cast import aiohttp from pydantic import SecretStr -from pythinker_code.auth import ZAI_PLATFORM_ID +from pythinker_code.auth import ZAI_API_PLATFORM_ID, ZAI_CODING_PLATFORM_ID from pythinker_code.auth.oauth import OAuthEvent from pythinker_code.auth.platforms import managed_model_key, managed_provider_key from pythinker_code.config import Config, LLMModel, LLMProvider, save_config +from pythinker_code.provider_compatibility import ( + get_zai_model_policies, + get_zai_model_policy, +) from pythinker_code.thinking import apply_login_thinking_defaults from pythinker_code.utils.aiohttp import new_client_session -ZAI_BASE_URL = "https://api.z.ai/api/anthropic" -ZAI_MODELS_URL = "https://api.z.ai/api/anthropic/v1/models" -ZAI_PROVIDER_KEY = managed_provider_key(ZAI_PLATFORM_ID) -ZAI_DEFAULT_MODEL_ALIAS = managed_model_key(ZAI_PLATFORM_ID, "glm-5.2") ZAI_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10) +type ZaiCatalogStatus = Literal["live", "degraded", "unauthorized", "unconfigured"] +type ZaiCatalogFailure = Literal[ + "empty", + "http", + "malformed", + "timeout", + "transport", + "unauthorized", + "unconfigured", +] + @dataclass(frozen=True, slots=True) -class ZaiModel: - model_id: str - alias_suffix: str +class ZaiRoute: + platform_id: str display_name: str - provider_key: str = ZAI_PROVIDER_KEY - max_context_size: int = 131_072 + base_url: str + api_key_env: str @property - def alias(self) -> str: - return f"{ZAI_PLATFORM_ID}/{self.alias_suffix}" - - -# GLM-5.2 is served on z.ai's Anthropic-compatible endpoint under the plain id -# "glm-5.2", which carries the full 1M-token context window. Verified empirically -# 2026-06-15 against api.z.ai/api/anthropic: a request with 1,002,378 input -# tokens succeeded while ~1.05M returned stop_reason="model_context_window_exceeded". -# The documented "glm-5.2[1m]" suffix is NOT a valid model code here (returns -# HTTP 400 "Unknown Model") — the plain id already grants 1M, so we use it and -# set the real window. z.ai's /models listings expose no context field and omit -# glm-5.2 entirely, so both the id and the size are curated. -_GLM_5_2 = ZaiModel("glm-5.2", "glm-5.2", "GLM-5.2", max_context_size=1_000_000) - -ZAI_MODELS: tuple[ZaiModel, ...] = ( - _GLM_5_2, - ZaiModel("glm-5.1", "glm-5.1", "GLM-5.1", max_context_size=204_800), - ZaiModel("glm-5", "glm-5", "GLM-5"), - ZaiModel("glm-5-turbo", "glm-5-turbo", "GLM-5-Turbo"), - ZaiModel("glm-4.7", "glm-4.7", "GLM-4.7"), - ZaiModel("glm-4.5-air", "glm-4.5-air", "GLM-4.5-Air", max_context_size=98_304), -) - -# Curated models that must always be offered even when z.ai's /models endpoint -# does not list them. GLM-5.2 is usable for chat but is absent from both the -# Anthropic and OpenAI-compatible /models listings (verified 2026-06-15), so -# without pinning it never reaches the model menu and a successful login or -# periodic refresh would drop it. Discovered entries win for everything else. -_PINNED_MODELS: tuple[ZaiModel, ...] = (_GLM_5_2,) + def provider_key(self) -> str: + return managed_provider_key(self.platform_id) - -def _with_pinned_models(models: tuple[ZaiModel, ...]) -> tuple[ZaiModel, ...]: - """Prepend curated pinned models the live catalog omitted, deduped by alias. - - If z.ai later starts returning a pinned model (e.g. it adds "glm-5.2" to its - /models listing), the discovered entry already occupies that alias, so the - pin is dropped — the API-provided definition wins and the model appears once, - never twice. The pin only fills the gap while the endpoint omits it. - """ - present = {model.alias for model in models} - missing = tuple(model for model in _PINNED_MODELS if model.alias not in present) - return missing + models + @property + def models_url(self) -> str: + return f"{self.base_url}/models" -def get_z_ai_api_key_from_env() -> str | None: - value = os.getenv("ZAI_API_KEY") - if value and value.strip(): - return value.strip() - return None +ZAI_CODING_ROUTE = ZaiRoute( + platform_id=ZAI_CODING_PLATFORM_ID, + display_name="Z.AI Coding Plan", + base_url="https://api.z.ai/api/coding/paas/v4", + api_key_env="ZAI_CODING_API_KEY", +) +ZAI_API_ROUTE = ZaiRoute( + platform_id=ZAI_API_PLATFORM_ID, + display_name="Z.AI API", + base_url="https://api.z.ai/api/paas/v4", + api_key_env="ZAI_API_KEY", +) +ZAI_ROUTES = (ZAI_CODING_ROUTE, ZAI_API_ROUTE) -def _is_supported_z_ai_model(model_id: str) -> bool: - return model_id.lower().startswith("glm-") +@dataclass(frozen=True, slots=True) +class ZaiModel: + model_id: str + display_name: str + max_context_size: int -def _model_by_id() -> dict[str, ZaiModel]: - return {model.model_id: model for model in ZAI_MODELS} +@dataclass(frozen=True, slots=True) +class ZaiCatalogResult: + status: ZaiCatalogStatus + models: tuple[ZaiModel, ...] | None + failure: ZaiCatalogFailure | None = None + + def __post_init__(self) -> None: + if self.status == "live": + if not self.models or self.failure is not None: + raise ValueError("A live Z.AI catalog requires non-empty models and no failure") + elif self.models is not None: + raise ValueError("A non-live Z.AI catalog must not carry models") + + +def _display_name(model_id: str) -> str: + return "-".join( + part.upper() if part.lower() == "glm" else part.capitalize() for part in model_id.split("-") + ) -def _derive_alias_suffix(model_id: str) -> str: - return model_id.lower().strip() +ZAI_MODELS: tuple[ZaiModel, ...] = tuple( + ZaiModel( + model_id=policy.model_id, + display_name=_display_name(policy.model_id), + max_context_size=policy.context_tokens, + ) + for policy in get_zai_model_policies() +) +_PINNED_MODEL_IDS = frozenset({"glm-5.2"}) -def _derive_display_name(model_id: str) -> str: - parts = model_id.split("-") - return "-".join(p.upper() if p.lower() == "glm" else p.capitalize() for p in parts) +def get_z_ai_api_key_from_env(route: ZaiRoute) -> str | None: + value = os.getenv(route.api_key_env) + if value and value.strip(): + return value.strip() + return None def _to_positive_int(value: Any) -> int | None: @@ -111,8 +121,7 @@ def _to_positive_int(value: Any) -> int | None: def _context_size_from_item(item: Mapping[str, Any], fallback: int) -> int: for key in ("context_length", "max_context_length", "context_window"): - parsed = _to_positive_int(item.get(key)) - if parsed is not None: + if (parsed := _to_positive_int(item.get(key))) is not None: return parsed return fallback @@ -126,102 +135,148 @@ def _display_name_from_item(item: Mapping[str, Any], fallback: str) -> str: def _parse_discovered_models(data: object) -> tuple[ZaiModel, ...] | None: - """Return parsed models, or None if the payload is structurally invalid.""" if not isinstance(data, dict): return None raw_items = cast(dict[str, Any], data).get("data") if not isinstance(raw_items, list): return None - known = _model_by_id() - seen: set[str] = set() result: list[ZaiModel] = [] + seen: set[str] = set() for raw_item in cast(list[Any], raw_items): if not isinstance(raw_item, Mapping): continue item = cast(Mapping[str, Any], raw_item) - model_id = item.get("id") - if not isinstance(model_id, str) or not model_id.strip(): + raw_model_id = item.get("id") + if not isinstance(raw_model_id, str): continue - model_id = model_id.strip() - if model_id in seen or not _is_supported_z_ai_model(model_id): + model_id = raw_model_id.strip().lower() + if not model_id.startswith("glm-") or model_id in seen: continue seen.add(model_id) - - current = known.get(model_id) - alias_suffix = current.alias_suffix if current else _derive_alias_suffix(model_id) - display_name = _display_name_from_item( - item, - current.display_name if current else _derive_display_name(model_id), - ) - max_context_size = _context_size_from_item( - item, - current.max_context_size if current else 131_072, - ) + policy = get_zai_model_policy(model_id) + fallback_context = policy.context_tokens if policy is not None else 131_072 + fallback_display = _display_name(model_id) result.append( ZaiModel( model_id=model_id, - alias_suffix=alias_suffix, - display_name=display_name, - provider_key=current.provider_key if current else ZAI_PROVIDER_KEY, - max_context_size=max_context_size, + display_name=_display_name_from_item(item, fallback_display), + max_context_size=_context_size_from_item(item, fallback_context), ) ) return tuple(result) -async def _discover_z_ai_models(api_key: str) -> tuple[ZaiModel, ...] | None: +def _with_pinned_models(models: tuple[ZaiModel, ...]) -> tuple[ZaiModel, ...]: + present = {model.model_id for model in models} + pins = tuple( + model + for model in ZAI_MODELS + if model.model_id in _PINNED_MODEL_IDS and model.model_id not in present + ) + return pins + models + + +async def _request_z_ai_models(route: ZaiRoute, api_key: str) -> object: async with ( new_client_session(timeout=ZAI_MODEL_DISCOVERY_TIMEOUT) as session, session.get( - ZAI_MODELS_URL, - headers={"x-api-key": api_key}, - raise_for_status=True, + route.models_url, + headers={"Authorization": f"Bearer {api_key}"}, + raise_for_status=False, ) as response, ): - payload = await response.json(content_type=None) - return _parse_discovered_models(payload) + if response.status >= 400: + raise aiohttp.ClientResponseError( + response.request_info, + response.history, + status=response.status, + message="Z.AI model catalog request failed", + headers=response.headers, + ) + return await response.json(content_type=None) + + +async def _discover_z_ai_catalog(route: ZaiRoute, api_key: str) -> ZaiCatalogResult: + try: + payload = await _request_z_ai_models(route, api_key) + except aiohttp.ClientResponseError as exc: + if exc.status in {401, 403}: + return ZaiCatalogResult( + status="unauthorized", + models=None, + failure="unauthorized", + ) + return ZaiCatalogResult(status="degraded", models=None, failure="http") + except TimeoutError: + return ZaiCatalogResult(status="degraded", models=None, failure="timeout") + except aiohttp.ClientError: + return ZaiCatalogResult(status="degraded", models=None, failure="transport") + except (TypeError, ValueError): + return ZaiCatalogResult(status="degraded", models=None, failure="malformed") + + models = _parse_discovered_models(payload) + if models is None: + return ZaiCatalogResult(status="degraded", models=None, failure="malformed") + if not models: + return ZaiCatalogResult(status="degraded", models=None, failure="empty") + return ZaiCatalogResult(status="live", models=_with_pinned_models(models)) + + +def _model_alias(route: ZaiRoute, model_id: str) -> str: + return managed_model_key(route.platform_id, model_id) + + +def _to_model_config(route: ZaiRoute, model: ZaiModel) -> LLMModel: + policy = get_zai_model_policy(model.model_id) + return LLMModel( + provider=route.provider_key, + model=model.model_id, + max_context_size=model.max_context_size, + capabilities={"thinking"} if policy is not None else None, + display_name=model.display_name, + ) def _apply_z_ai_config( config: Config, + route: ZaiRoute, api_key: SecretStr, models: tuple[ZaiModel, ...] = ZAI_MODELS, ) -> None: - config.providers[ZAI_PROVIDER_KEY] = LLMProvider( - type="anthropic", - base_url=ZAI_BASE_URL, + config.providers[route.provider_key] = LLMProvider( + type="openai_legacy", + base_url=route.base_url, api_key=api_key, ) + for alias, model in list(config.models.items()): + if model.provider == route.provider_key: + del config.models[alias] models = _with_pinned_models(models) - - provider_keys = {ZAI_PROVIDER_KEY} - for key, model in list(config.models.items()): - if model.provider in provider_keys: - del config.models[key] - for model in models: - config.models[model.alias] = LLMModel( - provider=model.provider_key, - model=model.model_id, - max_context_size=model.max_context_size, - display_name=model.display_name, - ) + config.models[_model_alias(route, model.model_id)] = _to_model_config(route, model) - fallback = next( - (m.alias for m in models), - next(iter(config.models), ""), - ) - if ZAI_DEFAULT_MODEL_ALIAS in config.models: - config.default_model = ZAI_DEFAULT_MODEL_ALIAS + preferred = _model_alias(route, "glm-5.2") + if preferred in config.models: + config.default_model = preferred else: - config.default_model = fallback - apply_login_thinking_defaults(config, thinking=False, effort="off") + route_alias = next( + ( + alias + for alias, model in config.models.items() + if model.provider == route.provider_key + ), + "", + ) + config.default_model = route_alias or next(iter(config.models), "") + apply_login_thinking_defaults(config, thinking=True, effort="high") -async def login_z_ai_api_key( - config: Config, api_key: str | None = None +async def _login_z_ai_route( + config: Config, + route: ZaiRoute, + api_key: str | None, ) -> AsyncIterator[OAuthEvent]: if not config.is_from_default_location: yield OAuthEvent( @@ -230,36 +285,56 @@ async def login_z_ai_api_key( ) return - resolved_key = (api_key or get_z_ai_api_key_from_env() or "").strip() + resolved_key = (api_key or get_z_ai_api_key_from_env(route) or "").strip() if not resolved_key: - yield OAuthEvent("error", "Z AI API key is required.") + yield OAuthEvent("error", f"{route.display_name} API key is required.") return - models = ZAI_MODELS - try: - discovered = await _discover_z_ai_models(resolved_key) - if discovered is not None and discovered: - models = discovered - except aiohttp.ClientResponseError as exc: - if exc.status in {401, 403}: - yield OAuthEvent("error", "Invalid Z AI API key; the key was not saved.") - return + catalog = await _discover_z_ai_catalog(route, resolved_key) + if catalog.status == "unauthorized": yield OAuthEvent( - "info", - "Z AI model listing is unavailable; using the built-in model list.", + "error", + f"Invalid {route.display_name} API key; the key was not saved.", ) - except (aiohttp.ClientError, TimeoutError, ValueError): + return + if catalog.status == "live": + assert catalog.models is not None + models = catalog.models + elif catalog.status == "degraded": yield OAuthEvent( "info", - "Z AI model listing is unavailable; using the built-in model list.", + f"{route.display_name} model listing is unavailable; using the built-in catalog.", ) + models = ZAI_MODELS + else: + yield OAuthEvent("error", f"{route.display_name} could not be configured.") + return - _apply_z_ai_config(config, SecretStr(resolved_key), models=models) + _apply_z_ai_config(config, route, SecretStr(resolved_key), models) save_config(config) - yield OAuthEvent("success", f"Z AI configured with model {config.default_model}.") + yield OAuthEvent("success", f"{route.display_name} configured with {config.default_model}.") + + +async def login_z_ai_coding_api_key( + config: Config, + api_key: str | None = None, +) -> AsyncIterator[OAuthEvent]: + async for event in _login_z_ai_route(config, ZAI_CODING_ROUTE, api_key): + yield event -async def logout_z_ai(config: Config) -> AsyncIterator[OAuthEvent]: +async def login_z_ai_api_key( + config: Config, + api_key: str | None = None, +) -> AsyncIterator[OAuthEvent]: + async for event in _login_z_ai_route(config, ZAI_API_ROUTE, api_key): + yield event + + +async def _logout_z_ai_route( + config: Config, + route: ZaiRoute, +) -> AsyncIterator[OAuthEvent]: if not config.is_from_default_location: yield OAuthEvent( "error", @@ -267,62 +342,47 @@ async def logout_z_ai(config: Config) -> AsyncIterator[OAuthEvent]: ) return - provider_keys = {ZAI_PROVIDER_KEY} - config.providers.pop(ZAI_PROVIDER_KEY, None) - for key, model in list(config.models.items()): - if model.provider in provider_keys: - del config.models[key] - + config.providers.pop(route.provider_key, None) + for alias, model in list(config.models.items()): + if model.provider == route.provider_key: + del config.models[alias] if config.default_model not in config.models: config.default_model = next(iter(config.models), "") save_config(config) - yield OAuthEvent("success", "Logged out of Z AI successfully.") + yield OAuthEvent("success", f"Logged out of {route.display_name} successfully.") + + +async def logout_z_ai_coding(config: Config) -> AsyncIterator[OAuthEvent]: + async for event in _logout_z_ai_route(config, ZAI_CODING_ROUTE): + yield event + +async def logout_z_ai_api(config: Config) -> AsyncIterator[OAuthEvent]: + async for event in _logout_z_ai_route(config, ZAI_API_ROUTE): + yield event -def apply_z_ai_models(config: Config, models: tuple[ZaiModel, ...]) -> bool: - """Upsert the live Z AI catalog and prune models no longer returned. - Preserves user preferences unless the selected Z AI model disappeared. - """ +def apply_z_ai_models( + config: Config, + route: ZaiRoute, + models: tuple[ZaiModel, ...], +) -> bool: models = _with_pinned_models(models) + aliases = [_model_alias(route, model.model_id) for model in models] changed = False - aliases: list[str] = [] - for model in models: - alias = model.alias - aliases.append(alias) - existing = config.models.get(alias) - if existing is None: - config.models[alias] = LLMModel( - provider=model.provider_key, - model=model.model_id, - max_context_size=model.max_context_size, - display_name=model.display_name, - ) - changed = True - continue - if existing.provider != model.provider_key: - existing.provider = model.provider_key - changed = True - if existing.model != model.model_id: - existing.model = model.model_id - changed = True - if existing.max_context_size != model.max_context_size: - existing.max_context_size = model.max_context_size - changed = True - if existing.display_name != model.display_name: - existing.display_name = model.display_name + for alias, model in zip(aliases, models, strict=True): + updated = _to_model_config(route, model) + if config.models.get(alias) != updated: + config.models[alias] = updated changed = True alias_set = set(aliases) removed_default = False - for alias, model_cfg in list(config.models.items()): - if model_cfg.provider != ZAI_PROVIDER_KEY: - continue - if alias in alias_set: + for alias, model in list(config.models.items()): + if model.provider != route.provider_key or alias in alias_set: continue del config.models[alias] - if config.default_model == alias: - removed_default = True + removed_default = removed_default or config.default_model == alias changed = True if removed_default: @@ -334,16 +394,20 @@ def apply_z_ai_models(config: Config, models: tuple[ZaiModel, ...]) -> bool: return changed -def _z_ai_api_key(config: Config) -> str | None: - provider = config.providers.get(ZAI_PROVIDER_KEY) +def _z_ai_api_key(config: Config, route: ZaiRoute) -> str | None: + provider = config.providers.get(route.provider_key) if provider is None: return None value = provider.api_key.get_secret_value().strip() return value or None -async def refresh_z_ai_models(config: Config) -> tuple[ZaiModel, ...] | None: - api_key = _z_ai_api_key(config) +async def refresh_z_ai_models(config: Config, route: ZaiRoute) -> ZaiCatalogResult: + api_key = _z_ai_api_key(config, route) if api_key is None: - return None - return await _discover_z_ai_models(api_key) + return ZaiCatalogResult( + status="unconfigured", + models=None, + failure="unconfigured", + ) + return await _discover_z_ai_catalog(route, api_key) diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index c2fcf915..3cde220d 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -16,12 +16,11 @@ default_provider_compatibility, openai_gpt_reasoning_levels, resolve_provider_compatibility, + resolve_tool_message_conversion, ) from pythinker_code.thinking import ( - DEFAULT_THINKING_EFFORT, available_thinking_levels, bool_to_thinking_effort, - clamp_thinking_effort, normalize_thinking_effort, thinking_effort_enabled, ) @@ -67,18 +66,6 @@ def model_name(self) -> str: return self.chat_provider.model_name -# Providers whose generation kwargs use `max_output_tokens` instead of the -# more common `max_tokens` (OpenAI Chat-Completions-shaped and Anthropic -# providers, plus the first-party `pythinker` provider, all use `max_tokens`). -_MAX_OUTPUT_TOKENS_KWARG_OVERRIDES: dict[str, str] = { - "openai_responses": "max_output_tokens", - "openai_codex": "max_output_tokens", - "google_genai": "max_output_tokens", - "gemini": "max_output_tokens", - "vertexai": "max_output_tokens", -} - - def capped_chat_provider(llm: LLM, max_output_tokens: int) -> ChatProvider: """Return a copy of ``llm.chat_provider`` with its output length capped. @@ -86,41 +73,11 @@ def capped_chat_provider(llm: LLM, max_output_tokens: int) -> ChatProvider: usual output budget (e.g. a context-compaction summary) can use this instead of hand-picking a provider-specific kwarg name. """ - provider_config = getattr(llm, "provider_config", None) - provider_type = getattr(provider_config, "type", None) - kwarg = _MAX_OUTPUT_TOKENS_KWARG_OVERRIDES.get(provider_type or "", "max_tokens") + compatibility = getattr(llm, "compatibility", None) + kwarg = compatibility.output_tokens_kwarg if compatibility is not None else "max_tokens" return cast(Any, llm.chat_provider).with_generation_kwargs(**{kwarg: max_output_tokens}) -# Hosts that serve the genuine Anthropic API and therefore accept the -# `tool_reference` / `defer_loading` beta content blocks that deferred tool -# search depends on. The Claude API-key path and Anthropic OAuth both route -# through `api.anthropic.com` (see `auth/anthropic_direct.py:ANTHROPIC_BASE_URL`). -_GENUINE_ANTHROPIC_HOSTS = frozenset({"api.anthropic.com"}) - -# Hosts that serve the genuine OpenAI API (as opposed to the many -# OpenAI-compatible proxies that reuse the chat-completions wire format). -_GENUINE_OPENAI_HOSTS = frozenset({"api.openai.com"}) - - -def _normalize_host(base_url: str | None) -> str: - """Lowercased hostname of `base_url`, or "" when absent/unparseable. - - Single source of truth for the genuine-vs-proxy host checks so callers do not - re-implement URL parsing (and so trailing slashes, paths, and case never matter). - """ - if not base_url: - return "" - from urllib.parse import urlparse - - return (urlparse(base_url).hostname or "").lower() - - -# Model-name substrings that do NOT support `tool_reference`. Haiku is the only -# known unsupported pattern in the deferred tool-search workflow. -_TOOL_REFERENCE_UNSUPPORTED_MODEL_PATTERNS = ("haiku",) - - def supports_deferred_tool_search(llm: LLM | None) -> bool: """Whether the active model can use the ToolSearch / deferred-tools workflow. @@ -129,9 +86,9 @@ def supports_deferred_tool_search(llm: LLM | None) -> bool: `ToolSearch` only makes sense when the provider supports Anthropic's `tool_reference` / `defer_loading` beta, the mechanism Pythinker uses to hold large MCP tool sets out of context and discover them on demand. Crucially, MANY - providers in this CLI declare `type="anthropic"` yet point at their OWN - Anthropic-COMPATIBLE proxy that does NOT forward that beta: z.ai/GLM - (`api.z.ai/api/anthropic`), Kimi, MiniMax, and opencode_go. On those — and on + providers in this CLI declare `type="anthropic"` yet point at their own + Anthropic-compatible proxy that does not forward that beta, including Kimi, + MiniMax, and custom bridges. On those — and on every non-Anthropic provider — offering `ToolSearch` is pure noise: it just re-lists tools the model can already see, and weaker tool-callers (observed with GLM-5.2) loop on it, "searching" for tools forever instead of calling @@ -150,18 +107,9 @@ def supports_deferred_tool_search(llm: LLM | None) -> bool: if env is not None: return env.strip().lower() not in {"", "0", "false", "no", "off"} - if llm is None or llm.provider_config is None: + if llm is None: return False - provider = llm.provider_config - if provider.type != "anthropic": - return False - # type="anthropic" is necessary but NOT sufficient — the compat proxies above - # share it. Only the genuine Anthropic host forwards the beta. - host = _normalize_host(provider.base_url) - if host not in _GENUINE_ANTHROPIC_HOSTS: - return False - model = llm.model_name.lower() - return not any(pat in model for pat in _TOOL_REFERENCE_UNSUPPORTED_MODEL_PATTERNS) + return llm.compatibility.deferred_tool_search def resolve_tool_result_mode( @@ -172,8 +120,8 @@ def resolve_tool_result_mode( The split that matters is NATIVE endpoint vs COMPATIBILITY PROXY, not which model: genuine `api.anthropic.com` / `api.openai.com` consume structured multi-part `tool_result` content faithfully, but the many proxies that merely speak the same - wire format often do not. z.ai/GLM (`api.z.ai/api/anthropic`) honors only the FIRST - content block of an array-form `tool_result`, so the leading `` summary block + wire format often do not. Some compatibility bridges honor only the first content + block of an array-form `tool_result`, so the leading `` summary block reaches the model while the actual tool OUTPUT block is silently dropped — every Shell/ReadFile result reads as "success" with no payload (confirmed against GLM-5.2). @@ -186,11 +134,7 @@ def resolve_tool_result_mode( Returns `None` to mean "native multi-part" (the provider default) and `"extract_text"` to mean "flatten to one string". New families/modes plug in here, not in agent/tool code. """ - host = _normalize_host(base_url) - native_hosts = _GENUINE_ANTHROPIC_HOSTS if api_family == "anthropic" else _GENUINE_OPENAI_HOSTS - if not host or host in native_hosts: - return None - return "extract_text" + return resolve_tool_message_conversion(api_family=api_family, base_url=base_url) def model_display_name(model_name: str | None, model: LLMModel | None = None) -> str: @@ -360,11 +304,6 @@ def create_llm( case "openai_legacy": from pythinker_core.contrib.chat_provider.openai_legacy import OpenAILegacy - reasoning_key = ( - provider.reasoning_key - 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" @@ -374,12 +313,13 @@ def create_llm( base_url=provider.base_url, api_key=resolved_api_key, stream=stream, - reasoning_key=reasoning_key, + reasoning_key=compatibility.reasoning_key, + reasoning_replay_mode=compatibility.reasoning_replay_mode, + auto_reasoning_effort=compatibility.auto_reasoning_effort, + tool_stream=compatibility.tool_stream, default_headers=dict(provider.custom_headers) if provider.custom_headers else None, http_client=rl_http_client, - tool_message_conversion=resolve_tool_result_mode( - api_family="openai", base_url=provider.base_url - ), + tool_message_conversion=compatibility.tool_message_conversion, ) case "openai_responses": from pythinker_core.contrib.chat_provider.openai_responses import OpenAIResponses @@ -390,6 +330,7 @@ def create_llm( api_key=resolved_api_key, default_headers=dict(provider.custom_headers) if provider.custom_headers else None, http_client=rl_http_client, + tool_message_conversion=compatibility.tool_message_conversion, ) case "openai_codex": from pythinker_core.contrib.chat_provider.openai_responses import OpenAIResponses @@ -408,6 +349,7 @@ def create_llm( system_prompt_as_instructions=True, default_headers=default_headers, http_client=rl_http_client, + tool_message_conversion=compatibility.tool_message_conversion, ) case "anthropic": from pythinker_core.contrib.chat_provider.anthropic import Anthropic @@ -420,9 +362,7 @@ def create_llm( metadata={"user_id": session_id} if session_id else None, default_headers=dict(provider.custom_headers) if provider.custom_headers else None, http_client=rl_http_client, - tool_message_conversion=resolve_tool_result_mode( - api_family="anthropic", base_url=provider.base_url - ), + tool_message_conversion=compatibility.tool_message_conversion, ) case "google_genai" | "gemini": from pythinker_core.contrib.chat_provider.google_genai import GoogleGenAI @@ -485,96 +425,21 @@ def create_llm( raise ValueError(f"Invalid thinking effort: {thinking_effort!r}") supports_thinking = "thinking" in capabilities - if "always_thinking" in capabilities: - # Always-thinking models cannot be disabled. Preserve an explicit - # non-off effort; otherwise keep the legacy high-effort default. - effective_effort = ( - requested_effort - if requested_effort is not None and requested_effort != "off" - else DEFAULT_THINKING_EFFORT - ) - elif supports_thinking: - # Clamp to the model's actually-supported levels so a persisted effort - # the model rejects (e.g. ``minimal`` on gpt-5.4/5.5) is never sent. - effective_effort = ( - clamp_thinking_effort( - requested_effort, available_model_thinking_levels(model, capabilities) - ) - if requested_effort is not None - else None - ) - else: - # Clamp to the model's supported levels: non-reasoning models have - # only the off level, so explicit non-off requests become off instead of - # being recorded as active but ignored by the provider. - effective_effort = "off" if requested_effort is not None else None - - thinking_on = thinking_effort_enabled(effective_effort) - # 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 "" + effective_effort = compatibility.effective_effort(requested_effort, capabilities) + overrides = compatibility.request_overrides( + model_id=model.model, + effort=effective_effort, ) - # Moonshot K2.x models use 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) - # Qwen3.x exposes a binary `enable_thinking` template toggle, not tiered - # reasoning effort; DashScope's own hosted endpoint already sends - # `enable_thinking` below, so only apply this for other openai_legacy - # routes (e.g. local llama.cpp/vLLM/LM Studio servers). - is_qwen3_openai_legacy = ( - provider.type == "openai_legacy" - and _is_qwen3_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_dashscope_legacy - and not is_qwen3_openai_legacy - ): - # Only explicitly send thinking controls for models that advertise - # reasoning. Some OpenAI-compatible non-reasoning models reject even a - # null reasoning_effort field. - chat_provider = chat_provider.with_thinking(effective_effort) - - # Moonshot K2.x and GLM use thinking.type on Moonshot-style endpoints. - if (is_kimi_openai_legacy or is_glm_openai_legacy) and effective_effort is not None: - thinking_body: dict[str, object] = {"type": "enabled" if thinking_on else "disabled"} - if is_glm_openai_legacy and thinking_on: - # Z.ai documents Preserved Thinking for coding/agent scenarios as - # `clear_thinking: false`; OpenAILegacy already replays ThinkPart as - # `reasoning_content`, which is the required history field. - thinking_body["clear_thinking"] = False - chat_provider = cast(Any, chat_provider).with_generation_kwargs( - extra_body={"thinking": thinking_body} - ) + if overrides.native_effort is not None and supports_thinking: + chat_provider = chat_provider.with_thinking(overrides.native_effort) - # DashScope-compatible models use enable_thinking unless handled by a - # provider-specific format 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} - ) + generation_kwargs = dict(overrides.generation_kwargs) + if overrides.extra_body: + generation_kwargs["extra_body"] = overrides.extra_body + if generation_kwargs: + chat_provider = cast(Any, chat_provider).with_generation_kwargs(**generation_kwargs) - # Self-hosted Qwen3.x servers (llama.cpp/vLLM/LM Studio) take the same - # `enable_thinking` toggle via the template-kwargs extra_body shape. - if is_qwen3_openai_legacy and effective_effort is not None: - chat_provider = cast(Any, chat_provider).with_generation_kwargs( - extra_body={"chat_template_kwargs": {"enable_thinking": thinking_on}} - ) + thinking_on = thinking_effort_enabled(effective_effort) # Apply Pythinker AI-specific ``thinking.keep`` (preserved thinking) only when # the model is actually in thinking mode; otherwise the API would see a @@ -687,26 +552,6 @@ def _is_kimi_k2_model(model_name: str) -> bool: return "kimi-k2" in model_name.lower().replace("_", "-") -def _is_glm_model(model_name: str) -> bool: - return model_name.lower().replace("_", "-").startswith("glm-") - - -def _is_qwen3_model(model_name: str) -> bool: - """Qwen3.x (dense and MoE) models only support the chat template's - binary `enable_thinking` toggle, not tiered reasoning effort levels. - """ - normalized = model_name.lower().replace("_", "-") - return "qwen3" in normalized or "qwen-3" in normalized - - -def _is_dashscope_endpoint(base_url: str) -> bool: - """True for any Alibaba DashScope endpoint (standard, intl, workspace).""" - from urllib.parse import urlparse - - host = urlparse(base_url).hostname or "" - return host == "aliyuncs.com" or host.endswith(".aliyuncs.com") - - def _is_alibaba_workspace_endpoint(base_url: str) -> bool: from urllib.parse import urlparse diff --git a/src/pythinker_code/provider_compatibility.py b/src/pythinker_code/provider_compatibility.py index e305ec26..08243692 100644 --- a/src/pythinker_code/provider_compatibility.py +++ b/src/pythinker_code/provider_compatibility.py @@ -119,11 +119,11 @@ def request_overrides( "type": "enabled", "clear_thinking": False, } - elif self.thinking_format == "kimi": + elif self.thinking_format == "kimi" and effort is not None: extra_body["thinking"] = {"type": "enabled" if _effort_enabled(effort) else "disabled"} - elif self.thinking_format == "dashscope": + elif self.thinking_format == "dashscope" and effort is not None: extra_body["enable_thinking"] = _effort_enabled(effort) - elif self.thinking_format == "qwen_template": + elif self.thinking_format == "qwen_template" and effort is not None: extra_body["chat_template_kwargs"] = {"enable_thinking": _effort_enabled(effort)} return GenerationOverrides( @@ -154,6 +154,22 @@ def get_zai_model_policy(model_id: str) -> ZaiModelPolicy | None: return _ZAI_MODEL_POLICIES_BY_ID.get(model_id.lower()) +def get_zai_model_policies() -> tuple[ZaiModelPolicy, ...]: + return _ZAI_MODEL_POLICIES + + +def resolve_tool_message_conversion( + *, + api_family: Literal["anthropic", "openai"], + base_url: str | None, +) -> ToolMessageConversion | None: + host, _path = _normalize_endpoint(base_url) + native_hosts = _GENUINE_ANTHROPIC_HOSTS if api_family == "anthropic" else _GENUINE_OPENAI_HOSTS + if not host or host in native_hosts: + return None + return "extract_text" + + def default_provider_compatibility() -> ProviderCompatibility: return ProviderCompatibility( profile_id="conservative", @@ -194,7 +210,10 @@ def resolve_provider_compatibility( api_family=api_family, output_tokens_kwarg=output_kwarg, max_output_tokens=None, - tool_message_conversion=None if native else "extract_text", + tool_message_conversion=resolve_tool_message_conversion( + api_family="anthropic", + base_url=provider.base_url, + ), deferred_tool_search=deferred, reasoning_key=None, reasoning_replay_mode="exact", @@ -219,6 +238,8 @@ def resolve_provider_compatibility( elif provider.type == "openai_legacy" and _is_qwen3_model(model.model): profile_id = "qwen-template" thinking_format = "qwen_template" + elif provider.type == "openai_legacy" and _is_glm_model(model.model): + thinking_format = "none" elif provider.type == "openai_legacy" and _is_strict_replay_model(model.model): replay_mode = "strict_synthetic" return ProviderCompatibility( @@ -226,7 +247,10 @@ def resolve_provider_compatibility( api_family=api_family, output_tokens_kwarg=output_kwarg, max_output_tokens=None, - tool_message_conversion=None if native else "extract_text", + tool_message_conversion=resolve_tool_message_conversion( + api_family="openai", + base_url=provider.base_url, + ), deferred_tool_search=False, reasoning_key=reasoning_key, reasoning_replay_mode=replay_mode, @@ -364,6 +388,10 @@ def _is_qwen3_model(model_id: str) -> bool: return "qwen3" in normalized or "qwen-3" in normalized +def _is_glm_model(model_id: str) -> bool: + return model_id.lower().replace("_", "-").startswith("glm-") + + def _is_strict_replay_model(model_id: str) -> bool: normalized = model_id.lower() return "deepseek" in normalized or _is_kimi_model(normalized) diff --git a/src/pythinker_code/ui/shell/oauth.py b/src/pythinker_code/ui/shell/oauth.py index f3ba4748..a5233207 100644 --- a/src/pythinker_code/ui/shell/oauth.py +++ b/src/pythinker_code/ui/shell/oauth.py @@ -20,7 +20,8 @@ OPENAI_CHATGPT_PLATFORM_ID, OPENCODE_GO_PLATFORM_ID, OPENROUTER_PLATFORM_ID, - ZAI_PLATFORM_ID, + ZAI_API_PLATFORM_ID, + ZAI_CODING_PLATFORM_ID, ) from pythinker_code.auth.alibaba import ( ALIBABA_PROVIDER_KEY, @@ -82,9 +83,12 @@ ) from pythinker_code.auth.platforms import managed_provider_key from pythinker_code.auth.z_ai import ( - ZAI_PROVIDER_KEY, + ZAI_API_ROUTE, + ZAI_CODING_ROUTE, login_z_ai_api_key, - logout_z_ai, + login_z_ai_coding_api_key, + logout_z_ai_api, + logout_z_ai_coding, ) from pythinker_code.cli import Reload from pythinker_code.ui.shell.console import console @@ -159,7 +163,8 @@ async def _prompt_text(label: str) -> str | None: OAuthProviderEntry(id="opencode-go", name="OpenCode Go", auth_type="api_key"), OAuthProviderEntry(id="minimax", name="MiniMax", auth_type="api_key"), OAuthProviderEntry(id="deepseek", name="DeepSeek", auth_type="api_key"), - OAuthProviderEntry(id="z-ai", name="Z AI", auth_type="api_key"), + OAuthProviderEntry(id="z-ai-coding", name="Z.AI Coding Plan", auth_type="api_key"), + OAuthProviderEntry(id="z-ai-api", name="Z.AI API", auth_type="api_key"), OAuthProviderEntry(id="moonshot", name="Moonshot", auth_type="api_key"), OAuthProviderEntry(id="kimi", name="Kimi Coding Plan", auth_type="api_key"), OAuthProviderEntry(id="alibaba", name="Alibaba (DashScope)", auth_type="api_key"), @@ -185,7 +190,8 @@ async def _prompt_text(label: str) -> str | None: "opencode-go": (OPENCODE_GO_OPENAI_PROVIDER_KEY, OPENCODE_GO_ANTHROPIC_PROVIDER_KEY), "minimax": (MINIMAX_ANTHROPIC_PROVIDER_KEY,), "deepseek": (DEEPSEEK_PROVIDER_KEY,), - "z-ai": (ZAI_PROVIDER_KEY,), + "z-ai-coding": (ZAI_CODING_ROUTE.provider_key,), + "z-ai-api": (ZAI_API_ROUTE.provider_key,), "moonshot": (MOONSHOT_PROVIDER_KEY,), "kimi": (KIMI_PROVIDER_KEY,), "alibaba": (ALIBABA_PROVIDER_KEY,), @@ -202,7 +208,8 @@ async def _prompt_text(label: str) -> str | None: OAuthProviderEntry(id="opencode-go", name="OpenCode Go", auth_type="api_key"), OAuthProviderEntry(id="minimax", name="MiniMax", auth_type="api_key"), OAuthProviderEntry(id="deepseek", name="DeepSeek", auth_type="api_key"), - OAuthProviderEntry(id="z-ai", name="Z AI", auth_type="api_key"), + OAuthProviderEntry(id="z-ai-coding", name="Z.AI Coding Plan", auth_type="api_key"), + OAuthProviderEntry(id="z-ai-api", name="Z.AI API", auth_type="api_key"), OAuthProviderEntry(id="moonshot", name="Moonshot", auth_type="api_key"), OAuthProviderEntry(id="kimi", name="Kimi Coding Plan", auth_type="api_key"), OAuthProviderEntry(id="alibaba", name="Alibaba (DashScope)", auth_type="api_key"), @@ -283,13 +290,20 @@ async def login(app: Shell, args: str) -> None: return ok = await _render_oauth_events(login_deepseek_api_key(soul.runtime.config, api_key)) provider = DEEPSEEK_PLATFORM_ID - elif mode == "z-ai": - api_key = await _prompt_api_key("Z AI") + elif mode == "z-ai-coding": + api_key = await _prompt_api_key("Z.AI Coding Plan") if not api_key: - console.print(f"[{_t.error}]No Z AI API key entered.[/]") + console.print(f"[{_t.error}]No Z.AI Coding Plan API key entered.[/]") + return + ok = await _render_oauth_events(login_z_ai_coding_api_key(soul.runtime.config, api_key)) + provider = ZAI_CODING_PLATFORM_ID + elif mode == "z-ai-api": + api_key = await _prompt_api_key("Z.AI API") + if not api_key: + console.print(f"[{_t.error}]No Z.AI API key entered.[/]") return ok = await _render_oauth_events(login_z_ai_api_key(soul.runtime.config, api_key)) - provider = ZAI_PLATFORM_ID + provider = ZAI_API_PLATFORM_ID elif mode == "moonshot": api_key = await _prompt_api_key("Moonshot") if not api_key: @@ -342,8 +356,8 @@ async def login(app: Shell, args: str) -> None: else: console.print( f"[{_t.error}]Usage: /login " - "[browser|headless|api-key|opencode-go|minimax|deepseek|z-ai|moonshot|kimi|alibaba|" - "anthropic|openrouter|lm-studio|ollama][/]" + "[browser|headless|api-key|opencode-go|minimax|deepseek|z-ai-coding|z-ai-api|" + "moonshot|kimi|alibaba|anthropic|openrouter|lm-studio|ollama][/]" ) return if not ok: @@ -397,8 +411,10 @@ async def logout(app: Shell, args: str) -> None: ok = await _render_oauth_events(logout_anthropic(config)) elif mode == "deepseek": ok = await _render_oauth_events(logout_deepseek(config)) - elif mode == "z-ai": - ok = await _render_oauth_events(logout_z_ai(config)) + elif mode == "z-ai-coding": + ok = await _render_oauth_events(logout_z_ai_coding(config)) + elif mode == "z-ai-api": + ok = await _render_oauth_events(logout_z_ai_api(config)) elif mode == "moonshot": ok = await _render_oauth_events(logout_moonshot(config)) elif mode == "kimi": @@ -422,8 +438,8 @@ async def logout(app: Shell, args: str) -> None: else: console.print( f"[{_t.error}]Usage: /logout " - "[openai|opencode-go|minimax|deepseek|z-ai|moonshot|kimi|alibaba|anthropic|openrouter|" - "lm-studio|ollama|github-feedback][/]" + "[openai|opencode-go|minimax|deepseek|z-ai-coding|z-ai-api|moonshot|kimi|" + "alibaba|anthropic|openrouter|lm-studio|ollama|github-feedback][/]" ) return if not ok: diff --git a/tests/auth/test_platforms.py b/tests/auth/test_platforms.py index d7175b40..0adb0f19 100644 --- a/tests/auth/test_platforms.py +++ b/tests/auth/test_platforms.py @@ -922,3 +922,77 @@ def _config_with_generic_provider() -> Config: assert "minimax/m2.7-highspeed" in saved[0].models assert saved[0].models["minimax/m2.7-highspeed"].provider == "managed:minimax-anthropic" assert "minimax/m2.7-highspeed" in config.models + + +@pytest.mark.parametrize( + "api_result", + [ + pytest.param(("degraded", "transport"), id="degraded"), + pytest.param(("unauthorized", "unauthorized"), id="unauthorized"), + pytest.param(("unconfigured", "unconfigured"), id="unconfigured"), + ], +) +async def test_refresh_zai_routes_apply_live_catalog_independently( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, + api_result: tuple[str, str], +) -> None: + from pythinker_code.auth import kimi, minimax, opencode_go, z_ai + from pythinker_code.auth.z_ai import ( + ZAI_API_ROUTE, + ZAI_CODING_ROUTE, + ZaiCatalogResult, + ZaiModel, + ZaiRoute, + _apply_z_ai_config, + ) + from pythinker_code.config import load_config, save_config + + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + config = Config(is_from_default_location=True) + _apply_z_ai_config(config, ZAI_CODING_ROUTE, SecretStr("coding")) + _apply_z_ai_config(config, ZAI_API_ROUTE, SecretStr("api")) + save_config(config) + api_before = { + key: value.model_copy(deep=True) + for key, value in config.models.items() + if value.provider == ZAI_API_ROUTE.provider_key + } + + async def fake_refresh_zai(_config: Config, route: ZaiRoute) -> ZaiCatalogResult: + if route == ZAI_CODING_ROUTE: + return ZaiCatalogResult( + status="live", + models=(ZaiModel("glm-5.1", "GLM-5.1 Live", 333_000),), + ) + status, failure = api_result + return ZaiCatalogResult( + status=cast(Any, status), + models=None, + failure=cast(Any, failure), + ) + + monkeypatch.setattr(z_ai, "refresh_z_ai_models", fake_refresh_zai) + monkeypatch.setattr(opencode_go, "refresh_opencode_go_models", AsyncMock(return_value=None)) + monkeypatch.setattr(minimax, "refresh_minimax_models", AsyncMock(return_value=None)) + monkeypatch.setattr(kimi, "refresh_kimi_models", AsyncMock(return_value=None)) + + changed = await refresh_managed_models(config) + + assert changed is True + assert config.models["z-ai-coding/glm-5.1"].max_context_size == 333_000 + api_after = { + key: value + for key, value in config.models.items() + if value.provider == ZAI_API_ROUTE.provider_key + } + assert api_after == api_before + + reloaded = load_config() + assert reloaded.models["z-ai-coding/glm-5.1"].max_context_size == 333_000 + reloaded_api = { + key: value + for key, value in reloaded.models.items() + if value.provider == ZAI_API_ROUTE.provider_key + } + assert reloaded_api == api_before diff --git a/tests/auth/test_z_ai_auth.py b/tests/auth/test_z_ai_auth.py index e852e706..9625ccb2 100644 --- a/tests/auth/test_z_ai_auth.py +++ b/tests/auth/test_z_ai_auth.py @@ -1,501 +1,483 @@ from __future__ import annotations +from contextlib import AbstractAsyncContextManager +from types import TracebackType +from typing import Any +from unittest.mock import AsyncMock + import aiohttp import pytest from multidict import CIMultiDict, CIMultiDictProxy from pydantic import SecretStr from yarl import URL -from pythinker_code.config import Config +from pythinker_code.auth.z_ai import ( + ZAI_API_ROUTE, + ZAI_CODING_ROUTE, + ZAI_MODELS, + ZAI_ROUTES, + ZaiCatalogResult, + ZaiModel, + ZaiRoute, + _apply_z_ai_config, + _discover_z_ai_catalog, + _parse_discovered_models, + _request_z_ai_models, + apply_z_ai_models, + get_z_ai_api_key_from_env, + login_z_ai_api_key, + login_z_ai_coding_api_key, + logout_z_ai_api, + logout_z_ai_coding, + refresh_z_ai_models, +) +from pythinker_code.config import Config, load_config -def _request_info(url: str) -> aiohttp.RequestInfo: +def _request_info(route: ZaiRoute) -> aiohttp.RequestInfo: return aiohttp.RequestInfo( - url=URL(url), + url=URL(route.models_url), method="GET", headers=CIMultiDictProxy(CIMultiDict()), - real_url=URL(url), + real_url=URL(route.models_url), ) -def test_z_ai_model_catalog_contains_six_models(): - from pythinker_code.auth.z_ai import ZAI_MODELS - - aliases = {model.alias for model in ZAI_MODELS} - assert aliases == { - "z-ai/glm-5.2", - "z-ai/glm-5.1", - "z-ai/glm-5", - "z-ai/glm-5-turbo", - "z-ai/glm-4.7", - "z-ai/glm-4.5-air", - } - - api_ids = {m.alias: m.model_id for m in ZAI_MODELS} - assert api_ids == { - # The "[1m]" suffix is rejected by z.ai's Anthropic endpoint; the plain - # id is what actually serves GLM-5.2 there. - "z-ai/glm-5.2": "glm-5.2", - "z-ai/glm-5.1": "glm-5.1", - "z-ai/glm-5": "glm-5", - "z-ai/glm-5-turbo": "glm-5-turbo", - "z-ai/glm-4.7": "glm-4.7", - "z-ai/glm-4.5-air": "glm-4.5-air", - } - - assert all(m.provider_key == "managed:z-ai" for m in ZAI_MODELS) - - -def test_z_ai_glm52_is_default_with_plain_id_and_1m_context(): - from pythinker_code.auth.z_ai import ZAI_DEFAULT_MODEL_ALIAS, ZAI_MODELS - - assert ZAI_DEFAULT_MODEL_ALIAS == "z-ai/glm-5.2" - glm52 = next(m for m in ZAI_MODELS if m.alias == "z-ai/glm-5.2") - # Plain id (the "[1m]" suffix is rejected by the endpoint) but the plain id - # already carries the full 1M window (verified empirically). - assert glm52.model_id == "glm-5.2" - assert glm52.max_context_size == 1_000_000 - - -@pytest.mark.asyncio -async def test_login_z_ai_pins_glm52_when_discovery_omits_it(monkeypatch, tmp_path): - """z.ai's /models listing does not include glm-5.2, but the model is usable. - Pinning must keep it in the catalog and as the default after a successful - discovery that returned other models.""" - from pythinker_code.auth.z_ai import ZaiModel, login_z_ai_api_key +def _response_error(route: ZaiRoute, status: int) -> aiohttp.ClientResponseError: + return aiohttp.ClientResponseError( + _request_info(route), + (), + status=status, + message="provider-private detail", + ) - monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) - config = Config(is_from_default_location=True) - async def fake_discover(api_key: str): - return ( - ZaiModel("glm-5.1", "glm-5.1", "GLM-5.1", max_context_size=204_800), - ZaiModel("glm-4.6", "glm-4.6", "GLM-4.6"), +def test_route_descriptors_are_explicit_and_independent() -> None: + assert ZAI_ROUTES == (ZAI_CODING_ROUTE, ZAI_API_ROUTE) + assert ( + ZaiRoute( + platform_id="z-ai-coding", + display_name="Z.AI Coding Plan", + base_url="https://api.z.ai/api/coding/paas/v4", + api_key_env="ZAI_CODING_API_KEY", ) - - monkeypatch.setattr("pythinker_code.auth.z_ai._discover_z_ai_models", fake_discover) - - events = [event async for event in login_z_ai_api_key(config, "zai-test")] - assert events[-1].type == "success" - assert config.models["z-ai/glm-5.2"].model == "glm-5.2" - assert config.default_model == "z-ai/glm-5.2" - - -def test_apply_z_ai_models_no_duplicate_when_api_lists_glm52(): - """If z.ai later returns glm-5.2 from /models, the discovered entry wins and - the pin is dropped: glm-5.2 appears exactly once with the API definition.""" - from pythinker_code.auth.z_ai import ( - ZAI_PROVIDER_KEY, - ZaiModel, - _apply_z_ai_config, - apply_z_ai_models, + == ZAI_CODING_ROUTE ) - - config = Config(is_from_default_location=True) - _apply_z_ai_config(config, SecretStr("zai-test")) - - apply_z_ai_models( - config, - (ZaiModel("glm-5.2", "glm-5.2", "GLM-5.2", max_context_size=400_000),), + assert ( + ZaiRoute( + platform_id="z-ai-api", + display_name="Z.AI API", + base_url="https://api.z.ai/api/paas/v4", + api_key_env="ZAI_API_KEY", + ) + == ZAI_API_ROUTE ) + assert ZAI_CODING_ROUTE.provider_key == "managed:z-ai-coding" + assert ZAI_API_ROUTE.provider_key == "managed:z-ai-api" + assert ZAI_CODING_ROUTE.models_url.endswith("/models") + assert ZAI_API_ROUTE.models_url.endswith("/models") + + +def test_curated_catalog_comes_from_single_policy_table() -> None: + expected = { + "glm-5.2": 1_000_000, + "glm-5.1": 204_800, + "glm-5": 204_800, + "glm-5-turbo": 204_800, + "glm-4.7": 204_800, + "glm-4.5-air": 131_072, + } + assert {model.model_id: model.max_context_size for model in ZAI_MODELS} == expected + + for route in ZAI_ROUTES: + config = Config(is_from_default_location=True) + _apply_z_ai_config(config, route, SecretStr("route-key")) + assert { + alias.removeprefix(f"{route.platform_id}/"): model.max_context_size + for alias, model in config.models.items() + if model.provider == route.provider_key + } == expected + assert all( + model.capabilities == {"thinking"} + for model in config.models.values() + if model.provider == route.provider_key + ) - glm52 = [a for a, m in config.models.items() if a == "z-ai/glm-5.2"] - assert glm52 == ["z-ai/glm-5.2"] # exactly one, no duplicate - # The API-provided context window wins over the curated pin. - assert config.models["z-ai/glm-5.2"].max_context_size == 400_000 - assert config.models["z-ai/glm-5.2"].provider == ZAI_PROVIDER_KEY - - -def test_z_ai_glm51_has_200k_context(): - from pythinker_code.auth.z_ai import ZAI_MODELS - glm51 = next(m for m in ZAI_MODELS if m.model_id == "glm-5.1") - assert glm51.max_context_size == 204_800 +@pytest.mark.parametrize("route", ZAI_ROUTES) +def test_apply_route_uses_openai_transport(route: ZaiRoute) -> None: + config = Config(is_from_default_location=True) + _apply_z_ai_config(config, route, SecretStr("route-key")) -def test_z_ai_glm45air_has_96k_context(): - from pythinker_code.auth.z_ai import ZAI_MODELS + provider = config.providers[route.provider_key] + assert provider.type == "openai_legacy" + assert provider.base_url == route.base_url + assert provider.api_key.get_secret_value() == "route-key" + assert config.default_model == f"{route.platform_id}/glm-5.2" + assert config.models[config.default_model].capabilities == {"thinking"} + assert config.default_thinking is True + assert config.default_thinking_effort == "high" - air = next(m for m in ZAI_MODELS if m.model_id == "glm-4.5-air") - assert air.max_context_size == 98_304 +def test_apply_route_preserves_explicit_thinking_choice() -> None: + config = Config( + is_from_default_location=True, + default_thinking=False, + default_thinking_effort="off", + ) -def test_z_ai_env_key_uses_zai_api_key(monkeypatch): - from pythinker_code.auth.z_ai import get_z_ai_api_key_from_env + _apply_z_ai_config(config, ZAI_API_ROUTE, SecretStr("route-key")) - monkeypatch.delenv("ZAI_API_KEY", raising=False) - assert get_z_ai_api_key_from_env() is None + assert config.default_thinking is False + assert config.default_thinking_effort == "off" - monkeypatch.setenv("ZAI_API_KEY", " zai-key ") - assert get_z_ai_api_key_from_env() == "zai-key" - monkeypatch.setenv("ZAI_API_KEY", "") - assert get_z_ai_api_key_from_env() is None +@pytest.mark.parametrize( + ("route", "set_env", "other_env", "expected"), + [ + (ZAI_CODING_ROUTE, "ZAI_CODING_API_KEY", "ZAI_API_KEY", "coding-key"), + (ZAI_API_ROUTE, "ZAI_API_KEY", "ZAI_CODING_API_KEY", "api-key"), + ], +) +def test_route_environment_keys_never_fall_back( + monkeypatch: pytest.MonkeyPatch, + route: ZaiRoute, + set_env: str, + other_env: str, + expected: str, +) -> None: + monkeypatch.setenv(set_env, f" {expected} ") + monkeypatch.setenv(other_env, "wrong-route-key") + assert get_z_ai_api_key_from_env(route) == expected + monkeypatch.delenv(set_env) + assert get_z_ai_api_key_from_env(route) is None @pytest.mark.parametrize( - "payload, expected_aliases", + ("payload", "expected"), [ - # Structurally invalid → None (no prune should happen) (None, None), ({}, None), - ({"data": "not a list"}, None), - # Valid structure, no matching models → empty tuple - ({"data": [{"context_length": 1000}]}, set()), - ({"data": [{"id": "unknown-model-xyz"}]}, set()), - # Valid structure with known models - ({"data": [{"id": "glm-5.1"}]}, {"z-ai/glm-5.1"}), - ({"data": [{"id": "glm-5-turbo"}]}, {"z-ai/glm-5-turbo"}), - ( - {"data": [{"id": "glm-4.7"}, {"id": "glm-4.5-air"}]}, - {"z-ai/glm-4.7", "z-ai/glm-4.5-air"}, - ), + ({"data": "invalid"}, None), + ({"data": []}, ()), + ({"data": [{"id": "not-glm"}]}, ()), ], ) -def test_parse_discovered_z_ai_models_handles_payloads(payload, expected_aliases): - from pythinker_code.auth.z_ai import _parse_discovered_models - - result = _parse_discovered_models(payload) - if expected_aliases is None: - assert result is None - else: - assert result is not None - assert {m.alias for m in result} == expected_aliases - - -def test_parse_discovered_z_ai_models_uses_context_length_when_positive(): - from pythinker_code.auth.z_ai import _parse_discovered_models - - payload = { - "data": [ - {"id": "glm-5.1", "context_length": 400_000}, - {"id": "glm-4.5-air", "context_length": -5}, - {"id": "glm-4.7", "context_length": "bogus"}, - ] - } - result = _parse_discovered_models(payload) +def test_parse_discovered_models_distinguishes_malformed_and_empty( + payload: object, + expected: tuple[ZaiModel, ...] | None, +) -> None: + assert _parse_discovered_models(payload) == expected + + +def test_parse_discovered_models_pins_known_values_and_keeps_unknown_conservative() -> None: + result = _parse_discovered_models( + { + "data": [ + {"id": "glm-5.1", "context_length": 400_000}, + {"id": "glm-5.1", "context_length": 1}, + {"id": "glm-future", "context_length": 512_000}, + ] + } + ) assert result is not None - by_id = {m.model_id: m for m in result} + by_id = {model.model_id: model for model in result} assert by_id["glm-5.1"].max_context_size == 400_000 - assert by_id["glm-4.5-air"].max_context_size == 98_304 # fallback to hardcoded - assert by_id["glm-4.7"].max_context_size == 131_072 # fallback to hardcoded + assert by_id["glm-future"] == ZaiModel( + model_id="glm-future", + display_name="GLM-Future", + max_context_size=512_000, + ) -def test_parse_discovered_z_ai_models_accepts_unknown_glm_future_models(): - from pythinker_code.auth.z_ai import _parse_discovered_models +class _FakeResponse: + status = 200 - payload = {"data": [{"id": "glm-6.0", "context_length": 512_000}]} - result = _parse_discovered_models(payload) - assert result is not None - assert len(result) == 1 - assert result[0].model_id == "glm-6.0" - assert result[0].alias_suffix == "glm-6.0" - assert result[0].max_context_size == 512_000 + async def json(self, *, content_type: None = None) -> object: + assert content_type is None + return {"data": [{"id": "glm-5.2"}]} -def test_parse_discovered_z_ai_models_deduplicates(): - from pythinker_code.auth.z_ai import _parse_discovered_models +class _FakeRequestContext(AbstractAsyncContextManager[_FakeResponse]): + async def __aenter__(self) -> _FakeResponse: + return _FakeResponse() - payload = {"data": [{"id": "glm-5.1"}, {"id": "glm-5.1"}]} - result = _parse_discovered_models(payload) - assert result is not None - assert len(result) == 1 + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None -def test_apply_z_ai_config_writes_provider_and_default(): - from pythinker_code.auth.z_ai import ( - ZAI_BASE_URL, - ZAI_DEFAULT_MODEL_ALIAS, - ZAI_PROVIDER_KEY, - _apply_z_ai_config, - ) +class _FakeSession: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, str]]] = [] - config = Config(is_from_default_location=True) - _apply_z_ai_config(config, SecretStr("zai-test")) - - assert set(config.providers) == {ZAI_PROVIDER_KEY} - provider = config.providers[ZAI_PROVIDER_KEY] - assert provider.type == "anthropic" - assert provider.base_url == ZAI_BASE_URL - assert provider.api_key.get_secret_value() == "zai-test" - assert config.models["z-ai/glm-5.1"].provider == ZAI_PROVIDER_KEY - assert config.models["z-ai/glm-5.1"].model == "glm-5.1" - assert config.models["z-ai/glm-5.1"].max_context_size == 204_800 - assert config.default_model == ZAI_DEFAULT_MODEL_ALIAS - - -def test_apply_z_ai_config_replaces_existing_z_ai_models(): - from pythinker_code.auth.z_ai import ( - ZAI_PROVIDER_KEY, - ZaiModel, - _apply_z_ai_config, - ) + def get( + self, + url: str, + *, + headers: dict[str, str], + raise_for_status: bool, + ) -> _FakeRequestContext: + assert raise_for_status is False + self.calls.append((url, headers)) + return _FakeRequestContext() - config = Config(is_from_default_location=True) - _apply_z_ai_config(config, SecretStr("zai-test")) - new_models = (ZaiModel("glm-5.1", "glm-5.1", "GLM-5.1 New", max_context_size=300_000),) - _apply_z_ai_config(config, SecretStr("zai-test-2"), models=new_models) +class _FakeSessionContext(AbstractAsyncContextManager[_FakeSession]): + def __init__(self, session: _FakeSession) -> None: + self.session = session - z_ai_aliases = {a for a, m in config.models.items() if m.provider == ZAI_PROVIDER_KEY} - # The replaced catalog plus the always-pinned GLM-5.2 default. - assert z_ai_aliases == {"z-ai/glm-5.2", "z-ai/glm-5.1"} - assert config.models["z-ai/glm-5.1"].max_context_size == 300_000 + async def __aenter__(self) -> _FakeSession: + return self.session + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + return None -@pytest.mark.asyncio -async def test_login_z_ai_saves_static_models_when_discovery_fails(monkeypatch, tmp_path): - from pythinker_code.auth.z_ai import login_z_ai_api_key - monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) - config = Config(is_from_default_location=True) +@pytest.mark.parametrize("route", ZAI_ROUTES) +async def test_model_request_uses_route_models_url_and_bearer_header( + monkeypatch: pytest.MonkeyPatch, + route: ZaiRoute, +) -> None: + from pythinker_code.auth import z_ai - async def fake_discover(api_key: str): - assert api_key == "zai-test" - raise aiohttp.ClientConnectionError("models unavailable") + session = _FakeSession() + monkeypatch.setattr( + z_ai, + "new_client_session", + lambda **_kwargs: _FakeSessionContext(session), + ) - monkeypatch.setattr("pythinker_code.auth.z_ai._discover_z_ai_models", fake_discover) + payload = await _request_z_ai_models(route, "route-key") - events = [event async for event in login_z_ai_api_key(config, "zai-test")] + assert payload == {"data": [{"id": "glm-5.2"}]} + assert session.calls == [(route.models_url, {"Authorization": "Bearer route-key"})] - assert [e.type for e in events] == ["info", "success"] - assert config.default_model == "z-ai/glm-5.2" - assert "z-ai/glm-5-turbo" in config.models - assert (tmp_path / "config.toml").exists() +@pytest.mark.parametrize( + ("outcome", "status", "failure"), + [ + (_response_error(ZAI_API_ROUTE, 401), "unauthorized", "unauthorized"), + (_response_error(ZAI_API_ROUTE, 403), "unauthorized", "unauthorized"), + (_response_error(ZAI_API_ROUTE, 503), "degraded", "http"), + (TimeoutError(), "degraded", "timeout"), + (aiohttp.ClientConnectionError(), "degraded", "transport"), + ({"unexpected": []}, "degraded", "malformed"), + ({"data": []}, "degraded", "empty"), + ], +) +async def test_catalog_failure_categories_are_explicit( + monkeypatch: pytest.MonkeyPatch, + outcome: object, + status: str, + failure: str, +) -> None: + from pythinker_code.auth import z_ai -@pytest.mark.asyncio -async def test_login_z_ai_falls_back_on_non_auth_response_error(monkeypatch, tmp_path): - from pythinker_code.auth.z_ai import login_z_ai_api_key + async def fake_request(_route: ZaiRoute, _api_key: str) -> object: + if isinstance(outcome, BaseException): + raise outcome + return outcome - monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) - config = Config(is_from_default_location=True) + monkeypatch.setattr(z_ai, "_request_z_ai_models", fake_request) - async def fake_discover(api_key: str): - raise aiohttp.ClientResponseError( - _request_info("https://api.z.ai/api/anthropic/v1/models"), - (), - status=503, - message="Service Unavailable", - ) + result = await _discover_z_ai_catalog(ZAI_API_ROUTE, "route-key") - monkeypatch.setattr("pythinker_code.auth.z_ai._discover_z_ai_models", fake_discover) + assert result.status == status + assert result.failure == failure + assert result.models is None - events = [event async for event in login_z_ai_api_key(config, "zai-test")] - assert [e.type for e in events] == ["info", "success"] - assert config.default_model == "z-ai/glm-5.2" +@pytest.mark.parametrize("route", ZAI_ROUTES) +async def test_refresh_unconfigured_route_does_not_call_network( + monkeypatch: pytest.MonkeyPatch, + route: ZaiRoute, +) -> None: + from pythinker_code.auth import z_ai + request = AsyncMock() + monkeypatch.setattr(z_ai, "_request_z_ai_models", request) -@pytest.mark.asyncio -async def test_login_z_ai_rejects_401(monkeypatch, tmp_path): - from pythinker_code.auth.z_ai import login_z_ai_api_key + result = await refresh_z_ai_models(Config(is_from_default_location=True), route) - monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) - config = Config(is_from_default_location=True) + assert result == ZaiCatalogResult( + status="unconfigured", + models=None, + failure="unconfigured", + ) + request.assert_not_awaited() - async def fake_discover(api_key: str): - raise aiohttp.ClientResponseError( - _request_info("https://api.z.ai/api/anthropic/v1/models"), - (), - status=401, - message="Unauthorized", - ) - monkeypatch.setattr("pythinker_code.auth.z_ai._discover_z_ai_models", fake_discover) +@pytest.mark.parametrize( + ("route", "login"), + [ + (ZAI_CODING_ROUTE, login_z_ai_coding_api_key), + (ZAI_API_ROUTE, login_z_ai_api_key), + ], +) +async def test_login_degraded_catalog_is_explicit_and_route_scoped( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, + route: ZaiRoute, + login: Any, +) -> None: + from pythinker_code.auth import z_ai - events = [event async for event in login_z_ai_api_key(config, "bad-key")] + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.setattr( + z_ai, + "_discover_z_ai_catalog", + AsyncMock( + return_value=ZaiCatalogResult( + status="degraded", + models=None, + failure="transport", + ) + ), + ) + config = Config(is_from_default_location=True) - assert events[-1].type == "error" - assert "Invalid Z AI API key" in events[-1].message - assert config.providers == {} - assert config.models == {} + events = [event async for event in login(config, "route-key")] + + assert [event.type for event in events] == ["info", "success"] + assert route.provider_key in config.providers + assert f"{route.platform_id}/glm-5.2" in config.models + other_route = ZAI_API_ROUTE if route is ZAI_CODING_ROUTE else ZAI_CODING_ROUTE + assert other_route.provider_key not in config.providers -@pytest.mark.asyncio -async def test_login_z_ai_uses_discovered_context_length(monkeypatch, tmp_path): - from pythinker_code.auth.z_ai import ZaiModel, login_z_ai_api_key +@pytest.mark.parametrize("status", ["unauthorized"]) +async def test_login_auth_failure_saves_nothing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, + status: str, +) -> None: + from pythinker_code.auth import z_ai monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) + monkeypatch.setattr( + z_ai, + "_discover_z_ai_catalog", + AsyncMock( + return_value=ZaiCatalogResult( + status=status, # type: ignore[arg-type] + models=None, + failure="unauthorized", + ) + ), + ) config = Config(is_from_default_location=True) - async def fake_discover(api_key: str): - return (ZaiModel("glm-5.1", "glm-5.1", "GLM-5.1", max_context_size=512_000),) - - monkeypatch.setattr("pythinker_code.auth.z_ai._discover_z_ai_models", fake_discover) - - events = [event async for event in login_z_ai_api_key(config, "zai-test")] + events = [event async for event in login_z_ai_api_key(config, "bad-key")] - assert events[-1].type == "success" - assert config.models["z-ai/glm-5.1"].max_context_size == 512_000 + assert events[-1].type == "error" + assert config.providers == {} + assert config.models == {} + assert not (tmp_path / "config.toml").exists() -@pytest.mark.asyncio -async def test_login_z_ai_requires_key(tmp_path): - from pythinker_code.auth.z_ai import login_z_ai_api_key +@pytest.mark.parametrize( + ("login", "route"), + [ + (login_z_ai_coding_api_key, ZAI_CODING_ROUTE), + (login_z_ai_api_key, ZAI_API_ROUTE), + ], +) +async def test_login_missing_key_does_not_call_network( + monkeypatch: pytest.MonkeyPatch, + login: Any, + route: ZaiRoute, +) -> None: + from pythinker_code.auth import z_ai - config = Config(is_from_default_location=True) + monkeypatch.delenv(route.api_key_env, raising=False) + discover = AsyncMock() + monkeypatch.setattr(z_ai, "_discover_z_ai_catalog", discover) - events = [event async for event in login_z_ai_api_key(config, "")] + events = [event async for event in login(Config(is_from_default_location=True), "")] assert events[-1].type == "error" - assert events[-1].message == "Z AI API key is required." + discover.assert_not_awaited() -@pytest.mark.asyncio -async def test_logout_z_ai_removes_only_z_ai(monkeypatch, tmp_path): - from pythinker_code.auth.z_ai import ( - ZAI_PROVIDER_KEY, - _apply_z_ai_config, - logout_z_ai, - ) - from pythinker_code.config import LLMModel, LLMProvider - +async def test_both_zai_routes_coexist_and_logout_is_owner_scoped( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) config = Config(is_from_default_location=True) - config.providers["managed:openai"] = LLMProvider( - type="openai_responses", - base_url="https://api.openai.com/v1", - api_key=SecretStr("sk-test"), - ) - config.models["openai/gpt-5.2"] = LLMModel( - provider="managed:openai", - model="gpt-5.2", - max_context_size=400_000, - ) - _apply_z_ai_config(config, SecretStr("zai-test")) + _apply_z_ai_config(config, ZAI_CODING_ROUTE, SecretStr("coding")) + _apply_z_ai_config(config, ZAI_API_ROUTE, SecretStr("api")) + config.default_model = "z-ai-coding/glm-5.2" - events = [event async for event in logout_z_ai(config)] + events = [event async for event in logout_z_ai_coding(config)] assert events[-1].type == "success" - assert ZAI_PROVIDER_KEY not in config.providers - assert "z-ai/glm-5.1" not in config.models - assert "managed:openai" in config.providers - assert "openai/gpt-5.2" in config.models - assert config.default_model == "openai/gpt-5.2" - - -@pytest.mark.asyncio -async def test_logout_z_ai_rejects_non_default_config_location(): - from pythinker_code.auth.z_ai import logout_z_ai - - config = Config(is_from_default_location=False) - - events = [event async for event in logout_z_ai(config)] - - assert events[-1].type == "error" - assert "default config file" in events[-1].message - assert config.providers == {} - - -def test_apply_z_ai_models_prunes_stale_models_and_preserves_user_default(): - from pythinker_code.auth.z_ai import ( - ZAI_PROVIDER_KEY, - ZaiModel, - _apply_z_ai_config, - apply_z_ai_models, + assert ZAI_CODING_ROUTE.provider_key not in config.providers + assert not any( + model.provider == ZAI_CODING_ROUTE.provider_key for model in config.models.values() ) - + assert ZAI_API_ROUTE.provider_key in config.providers + assert "z-ai-api/glm-5.2" in config.models + assert config.default_model == "z-ai-api/glm-5.2" + reloaded = load_config() + assert reloaded.default_model == config.default_model + assert set(reloaded.providers) == set(config.providers) + assert set(reloaded.models) == set(config.models) + + +async def test_logout_only_route_repairs_default_to_empty( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Any, +) -> None: + monkeypatch.setenv("PYTHINKER_SHARE_DIR", str(tmp_path)) config = Config(is_from_default_location=True) - _apply_z_ai_config(config, SecretStr("zai-test")) - config.default_model = "z-ai/glm-5.1" + _apply_z_ai_config(config, ZAI_API_ROUTE, SecretStr("api")) - discovered = ( - ZaiModel("glm-5.1", "glm-5.1", "GLM-5.1", max_context_size=400_000), - ZaiModel("glm-6.0", "glm-6.0", "GLM-6.0", max_context_size=512_000), - ) + events = [event async for event in logout_z_ai_api(config)] - changed = apply_z_ai_models(config, discovered) + assert events[-1].type == "success" + assert config.default_model == "" + assert config.providers == {} + assert config.models == {} + assert load_config().default_model == "" - assert changed is True - z_ai_aliases = {a for a, m in config.models.items() if m.provider == ZAI_PROVIDER_KEY} - # GLM-5.2 is always pinned even when discovery omits it. - assert z_ai_aliases == {"z-ai/glm-5.2", "z-ai/glm-5.1", "z-ai/glm-6.0"} - assert config.models["z-ai/glm-5.1"].max_context_size == 400_000 - assert config.default_model == "z-ai/glm-5.1" - - -def test_apply_z_ai_models_reassigns_default_when_it_disappears(): - from pythinker_code.auth.z_ai import ( - ZaiModel, - _apply_z_ai_config, - apply_z_ai_models, - ) +def test_apply_models_is_route_scoped_and_unknown_models_get_no_capabilities() -> None: config = Config(is_from_default_location=True) - _apply_z_ai_config( - config, - SecretStr("zai-test"), - models=(ZaiModel("glm-5.1", "glm-5.1", "GLM-5.1"),), - ) - config.default_model = "z-ai/glm-5.1" + _apply_z_ai_config(config, ZAI_CODING_ROUTE, SecretStr("coding")) + _apply_z_ai_config(config, ZAI_API_ROUTE, SecretStr("api")) + before_api = { + key: value.model_copy(deep=True) + for key, value in config.models.items() + if value.provider == ZAI_API_ROUTE.provider_key + } changed = apply_z_ai_models( config, - (ZaiModel("glm-5-turbo", "glm-5-turbo", "GLM-5-Turbo"),), + ZAI_CODING_ROUTE, + ( + ZaiModel("glm-5.1", "GLM-5.1 Live", 400_000), + ZaiModel("glm-future", "GLM-Future", 512_000), + ), ) assert changed is True - assert "z-ai/glm-5.1" not in config.models - assert "z-ai/glm-5-turbo" in config.models - # The disappeared default falls back to the first alias, which is the always - # pinned GLM-5.2. - assert config.default_model == "z-ai/glm-5.2" - - -def test_apply_z_ai_models_returns_false_for_noop(): - from pythinker_code.auth.z_ai import ( - ZAI_MODELS, - _apply_z_ai_config, - apply_z_ai_models, - ) - - config = Config(is_from_default_location=True) - _apply_z_ai_config(config, SecretStr("zai-test")) - - assert apply_z_ai_models(config, ZAI_MODELS) is False - - -def test_apply_z_ai_config_defaults_thinking_off(): - """Z.ai is wired to its Anthropic-compatible endpoint, which (verified - empirically 2026-06-03 against glm-5.1) defaults thinking OFF and honors - `thinking: {"type": "disabled"}`. The login default of effort="off" - therefore matches the endpoint's native behavior. If the provider type or - base_url ever moves to the OpenAI-compatible endpoint (which defaults - thinking ON for GLM-5.x), these defaults must be re-evaluated. - """ - from pythinker_code.auth.z_ai import _apply_z_ai_config - - config = Config(is_from_default_location=True) - assert config.default_thinking_effort is None - _apply_z_ai_config(config, SecretStr("zai-test")) - - assert config.providers["managed:z-ai"].type == "anthropic" - assert config.default_thinking is False # unchanged from Config default - assert config.default_thinking_effort == "off" - - -def test_apply_z_ai_config_preserves_legacy_thinking_true(): - """Users with legacy default_thinking=True must not be silently downgraded.""" - from pythinker_code.auth.z_ai import _apply_z_ai_config - - config = Config(is_from_default_location=True) - config.default_thinking = True - # effort unset — the legacy path - assert config.default_thinking_effort is None - _apply_z_ai_config(config, SecretStr("zai-test")) - - assert config.default_thinking is True - assert config.default_thinking_effort is None - - -def test_apply_z_ai_config_preserves_existing_effort_choice(): - from pythinker_code.auth.z_ai import _apply_z_ai_config - - config = Config(is_from_default_location=True) - config.default_thinking = True - config.default_thinking_effort = "medium" - _apply_z_ai_config(config, SecretStr("zai-test")) - - assert config.default_thinking is True - assert config.default_thinking_effort == "medium" + assert config.models["z-ai-coding/glm-5.1"].max_context_size == 400_000 + assert config.models["z-ai-coding/glm-future"].capabilities is None + after_api = { + key: value + for key, value in config.models.items() + if value.provider == ZAI_API_ROUTE.provider_key + } + assert after_api == before_api diff --git a/tests/core/test_compaction_overflow.py b/tests/core/test_compaction_overflow.py index 5aafed33..2b286104 100644 --- a/tests/core/test_compaction_overflow.py +++ b/tests/core/test_compaction_overflow.py @@ -11,14 +11,17 @@ from __future__ import annotations from types import SimpleNamespace -from typing import cast +from typing import Any, cast import pytest import pythinker_core +from pydantic import SecretStr from pythinker_core.chat_provider import APIStatusError from pythinker_core.message import Message +from pythinker_code.config import LLMModel, LLMProvider from pythinker_code.llm import LLM, capped_chat_provider +from pythinker_code.provider_compatibility import resolve_provider_compatibility from pythinker_code.soul.compaction import SimpleCompaction from pythinker_code.wire.types import TextPart @@ -51,11 +54,22 @@ def _fake_llm() -> LLM: def _fake_llm_with_provider_type(provider_type: str) -> LLM: + provider = LLMProvider( + type=cast(Any, provider_type), + base_url="https://api.example/v1", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="test", + model="test-model", + max_context_size=100_000, + ) return cast( LLM, SimpleNamespace( chat_provider=_FakeChatProvider(), - provider_config=SimpleNamespace(type=provider_type), + provider_config=provider, + compatibility=resolve_provider_compatibility("test", provider, model), ), ) diff --git a/tests/core/test_create_llm.py b/tests/core/test_create_llm.py index 3d49291c..5540113a 100644 --- a/tests/core/test_create_llm.py +++ b/tests/core/test_create_llm.py @@ -767,27 +767,47 @@ def test_create_llm_alibaba_workspace_deepseek_disables_streaming(): } -def test_create_llm_openai_legacy_glm_sends_provider_thinking_body(): +@pytest.mark.parametrize( + ("provider_key", "base_url"), + [ + ("managed:z-ai-coding", "https://api.z.ai/api/coding/paas/v4"), + ("managed:z-ai-api", "https://api.z.ai/api/paas/v4"), + ], +) +def test_create_llm_zai_glm52_activates_explicit_profile_policy( + provider_key: str, + base_url: str, +) -> None: provider = LLMProvider( type="openai_legacy", - base_url="https://api.example.com/v1", + base_url=base_url, api_key=SecretStr("test-key"), ) model = LLMModel( - provider="glm-provider", - model="glm-5.1", - max_context_size=262_144, + provider=provider_key, + model="glm-5.2", + max_context_size=1_000_000, capabilities={"thinking"}, ) - llm = create_llm(provider, model, thinking_effort="high") + llm = create_llm(provider, model, thinking_effort="xhigh") + assert llm is not None assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.compatibility.profile_id == provider_key.removeprefix("managed:") assert llm.chat_provider.thinking_effort is None assert llm.thinking is True - assert llm.thinking_effort == "high" - assert llm.chat_provider._generation_kwargs.get("extra_body") == { # pyright: ignore[reportPrivateUsage] - "thinking": {"type": "enabled", "clear_thinking": False} + assert llm.thinking_effort == "xhigh" + assert llm.chat_provider._reasoning_replay_mode == "exact" # pyright: ignore[reportPrivateUsage] + assert llm.chat_provider._auto_reasoning_effort is False # pyright: ignore[reportPrivateUsage] + assert llm.chat_provider._tool_stream is True # pyright: ignore[reportPrivateUsage] + assert llm.chat_provider._tool_message_conversion == "extract_text" # pyright: ignore[reportPrivateUsage] + assert llm.chat_provider._generation_kwargs == { # pyright: ignore[reportPrivateUsage] + "max_tokens": 131_072, + "extra_body": { + "thinking": {"type": "enabled", "clear_thinking": False}, + "reasoning_effort": "max", + }, } @@ -817,30 +837,54 @@ def test_create_llm_self_hosted_qwen_uses_chat_template_thinking_toggle( } -def test_create_llm_openai_legacy_glm_sends_disabled_provider_thinking_body(): +def test_create_llm_zai_binary_model_maps_minimal_to_disabled() -> None: provider = LLMProvider( type="openai_legacy", - base_url="https://api.example.com/v1", + base_url="https://api.z.ai/api/paas/v4", api_key=SecretStr("test-key"), ) model = LLMModel( - provider="glm-provider", + provider="managed:z-ai-api", model="glm-5.1", - max_context_size=262_144, + max_context_size=204_800, capabilities={"thinking"}, ) - llm = create_llm(provider, model, thinking_effort="off") + llm = create_llm(provider, model, thinking_effort="minimal") + assert llm is not None assert isinstance(llm.chat_provider, OpenAILegacy) assert llm.chat_provider.thinking_effort is None assert llm.thinking is False assert llm.thinking_effort == "off" - assert llm.chat_provider._generation_kwargs.get("extra_body") == { # pyright: ignore[reportPrivateUsage] - "thinking": {"type": "disabled"} + assert llm.chat_provider._generation_kwargs == { # pyright: ignore[reportPrivateUsage] + "max_tokens": 131_072, + "extra_body": {"thinking": {"type": "disabled"}}, } +def test_create_llm_local_glm_name_does_not_activate_zai_request_policy() -> None: + provider = LLMProvider( + type="openai_legacy", + base_url="http://localhost:8080/v1", + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider="local", + model="glm-5.2", + max_context_size=1_000_000, + capabilities={"thinking"}, + ) + + llm = create_llm(provider, model, thinking_effort="high") + + assert llm is not None + assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.compatibility.profile_id == "openai-compatible" + assert "extra_body" not in llm.chat_provider._generation_kwargs # pyright: ignore[reportPrivateUsage] + assert "max_tokens" not in llm.chat_provider._generation_kwargs # pyright: ignore[reportPrivateUsage] + + def test_clone_llm_with_model_alias_preserves_kimi_thinking_disabled(): provider = LLMProvider( type="openai_legacy", @@ -928,9 +972,12 @@ def test_resolve_tool_result_mode_native_vs_compat_proxy(): resolve_tool_result_mode(api_family="openai", base_url="https://proxy.example/v1") == "extract_text" ) - # Anthropic-compatible proxies (z.ai/GLM, MiniMax, Kimi) → flatten. + # Anthropic-compatible proxies (MiniMax, Kimi, custom bridges) → flatten. assert ( - resolve_tool_result_mode(api_family="anthropic", base_url="https://api.z.ai/api/anthropic") + resolve_tool_result_mode( + api_family="anthropic", + base_url="https://proxy.example/anthropic", + ) == "extract_text" ) # OpenAI-compatible proxies (DeepSeek, xAI/Grok, …) → flatten. @@ -959,18 +1006,15 @@ def test_resolve_tool_result_mode_host_normalization(): ) -def test_create_llm_zai_anthropic_proxy_flattens_tool_results(): - # z.ai's Anthropic-compatible proxy only honors the first content block of an - # array-form tool_result, so multi-block results (system summary + output) must - # be flattened to a single text block or the model never sees the tool output. +def test_create_llm_anthropic_compat_proxy_flattens_tool_results(): provider = LLMProvider( type="anthropic", - base_url="https://api.z.ai/api/anthropic", + base_url="https://proxy.example/anthropic", api_key=SecretStr("test-key"), ) model = LLMModel( - provider="managed:z-ai", - model="glm-5.2", + provider="anthropic-proxy", + model="claude-compatible", max_context_size=200_000, capabilities=None, ) diff --git a/tests/core/test_provider_compatibility.py b/tests/core/test_provider_compatibility.py index ac0856a6..f27ac6e0 100644 --- a/tests/core/test_provider_compatibility.py +++ b/tests/core/test_provider_compatibility.py @@ -358,3 +358,8 @@ def test_generic_profile_preserves_unconfigured_effort() -> None: ) assert profile.effective_effort(None, None) is None + assert profile.request_overrides(model_id="plain-model", effort=None) == GenerationOverrides( + native_effort=None, + generation_kwargs={}, + extra_body={}, + ) diff --git a/tests/core/test_tool_search_gating.py b/tests/core/test_tool_search_gating.py index 4f5031dd..69c0d9d5 100644 --- a/tests/core/test_tool_search_gating.py +++ b/tests/core/test_tool_search_gating.py @@ -2,8 +2,8 @@ Regression coverage for the GLM-5.2 ToolSearch loop: `ToolSearch` must only be offered to models that genuinely support Anthropic's `tool_reference` / -`defer_loading` beta. The compat proxies that declare `type="anthropic"` but -point at their own endpoint (z.ai/GLM, Kimi, MiniMax, opencode) must NOT see it. +`defer_loading` beta. Compat proxies that declare `type="anthropic"` but point +at their own endpoint (Kimi, MiniMax, custom bridges) must NOT see it. See `pythinker_code.llm.supports_deferred_tool_search`. """ @@ -15,8 +15,12 @@ import pytest from pydantic import SecretStr -from pythinker_code.config import LLMProvider +from pythinker_code.config import LLMModel, LLMProvider from pythinker_code.llm import LLM, supports_deferred_tool_search +from pythinker_code.provider_compatibility import ( + default_provider_compatibility, + resolve_provider_compatibility, +) def _llm(provider_type: str | None, base_url: str, model: str) -> LLM: @@ -25,10 +29,22 @@ def _llm(provider_type: str | None, base_url: str, model: str) -> LLM: if provider_type is None else LLMProvider(type=cast("str", provider_type), base_url=base_url, api_key=SecretStr("x")) # type: ignore[arg-type] ) + model_config = LLMModel( + provider="test", + model=model, + max_context_size=200_000, + ) + compatibility = ( + resolve_provider_compatibility("test", provider, model_config) + if provider is not None + else default_provider_compatibility() + ) return LLM( chat_provider=cast("object", SimpleNamespace(model_name=model)), # type: ignore[arg-type] max_context_size=200_000, capabilities=set(), + compatibility=compatibility, + model_config=model_config, provider_config=provider, ) @@ -42,7 +58,7 @@ def test_genuine_anthropic_supports_tool_search() -> None: @pytest.mark.parametrize( ("base_url", "label"), [ - ("https://api.z.ai/api/anthropic", "z.ai/GLM"), + ("https://proxy.example/anthropic", "custom proxy"), ("https://api.moonshot.ai/anthropic", "kimi"), ("https://api.minimax.io/anthropic", "minimax"), ], @@ -73,7 +89,7 @@ def test_env_force_enable_overrides_host(monkeypatch: pytest.MonkeyPatch) -> Non # Explicit opt-in: user asserts their proxy forwards the beta. monkeypatch.setenv("ENABLE_TOOL_SEARCH", "true") assert supports_deferred_tool_search( - _llm("anthropic", "https://api.z.ai/api/anthropic", "glm-5.2") + _llm("anthropic", "https://proxy.example/anthropic", "claude-compatible") ) diff --git a/tests/ui_and_conv/test_openai_shell_login.py b/tests/ui_and_conv/test_openai_shell_login.py index 73a0e377..0d189876 100644 --- a/tests/ui_and_conv/test_openai_shell_login.py +++ b/tests/ui_and_conv/test_openai_shell_login.py @@ -349,5 +349,86 @@ def test_login_chooser_includes_lm_studio_and_ollama(): assert "Ollama" in names +def test_zai_selector_entries_and_status_keys_are_route_exact() -> None: + from pydantic import SecretStr + + from pythinker_code.auth.z_ai import ZAI_API_ROUTE, ZAI_CODING_ROUTE + from pythinker_code.config import LLMProvider + + ids = {entry.id for entry in shell_oauth._SELECTOR_PROVIDER_ENTRIES} + logout_ids = {entry.id for entry in shell_oauth._LOGOUT_PROVIDER_ENTRIES} + assert {"z-ai-coding", "z-ai-api"} <= ids + assert {"z-ai-coding", "z-ai-api"} <= logout_ids + assert "z-ai" not in ids | logout_ids + + config = Config(is_from_default_location=True) + config.providers[ZAI_CODING_ROUTE.provider_key] = LLMProvider( + type="openai_legacy", + base_url=ZAI_CODING_ROUTE.base_url, + api_key=SecretStr("coding"), + ) + assert shell_oauth._get_provider_status(config, "z-ai-coding").source == "configured" + assert shell_oauth._get_provider_status(config, "z-ai-api").source == "unconfigured" + config.providers[ZAI_API_ROUTE.provider_key] = LLMProvider( + type="openai_legacy", + base_url=ZAI_API_ROUTE.base_url, + api_key=SecretStr("api"), + ) + assert shell_oauth._get_provider_status(config, "z-ai-api").source == "configured" + + +@pytest.mark.parametrize( + ("mode", "target_name", "other_name"), + [ + ("z-ai-coding", "login_z_ai_coding_api_key", "login_z_ai_api_key"), + ("z-ai-api", "login_z_ai_api_key", "login_z_ai_coding_api_key"), + ], +) +async def test_shell_login_zai_routes_never_cross_dispatch( + monkeypatch: pytest.MonkeyPatch, + mode: str, + target_name: str, + other_name: str, +) -> None: + target = Mock(side_effect=_success_event) + other = Mock(side_effect=_success_event) + monkeypatch.setattr(shell_oauth, target_name, target, raising=False) + monkeypatch.setattr(shell_oauth, other_name, other, raising=False) + monkeypatch.setattr(shell_oauth, "_prompt_api_key", lambda _label: _async_value("route-key")) + monkeypatch.setattr(shell_oauth.asyncio, "sleep", lambda _seconds: _async_value(None)) + + with pytest.raises(Reload): + await cast(Any, shell_oauth.login)(_app(), mode) + + assert target.call_args.args[1] == "route-key" + other.assert_not_called() + + +@pytest.mark.parametrize( + ("mode", "target_name", "other_name"), + [ + ("z-ai-coding", "logout_z_ai_coding", "logout_z_ai_api"), + ("z-ai-api", "logout_z_ai_api", "logout_z_ai_coding"), + ], +) +async def test_shell_logout_zai_routes_never_cross_dispatch( + monkeypatch: pytest.MonkeyPatch, + mode: str, + target_name: str, + other_name: str, +) -> None: + target = Mock(side_effect=_success_event) + other = Mock(side_effect=_success_event) + monkeypatch.setattr(shell_oauth, target_name, target, raising=False) + monkeypatch.setattr(shell_oauth, other_name, other, raising=False) + monkeypatch.setattr(shell_oauth.asyncio, "sleep", lambda _seconds: _async_value(None)) + + with pytest.raises(Reload): + await cast(Any, shell_oauth.logout)(_app(), mode) + + target.assert_called_once() + other.assert_not_called() + + async def _async_value[T](value: T) -> T: return value From 62d7638937c39792d3dadc9a875da3e5fab8433d Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 15 Jul 2026 00:51:28 -0400 Subject: [PATCH 5/9] feat(auth): add Z.AI CLI and usage routes --- src/pythinker_code/cli/__init__.py | 83 ++++++++++- .../ui/shell/usage_adapters/__init__.py | 5 + .../ui/shell/usage_adapters/z_ai.py | 33 +++++ tests/cli/test_z_ai_login_cli.py | 124 +++++++++++++++++ .../test_provider_key_coverage.py | 2 + tests/ui/usage_adapters/test_z_ai.py | 131 ++++++++++++++++++ 6 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 src/pythinker_code/ui/shell/usage_adapters/z_ai.py create mode 100644 tests/cli/test_z_ai_login_cli.py create mode 100644 tests/ui/usage_adapters/test_z_ai.py diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index 19d610f4..c2bb55de 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -74,6 +74,30 @@ def logout_deepseek(*args: Any, **kwargs: Any) -> Any: return impl(*args, **kwargs) +def login_z_ai_coding_api_key(*args: Any, **kwargs: Any) -> Any: + from pythinker_code.auth.z_ai import login_z_ai_coding_api_key as impl + + return impl(*args, **kwargs) + + +def login_z_ai_api_key(*args: Any, **kwargs: Any) -> Any: + from pythinker_code.auth.z_ai import login_z_ai_api_key as impl + + return impl(*args, **kwargs) + + +def logout_z_ai_coding(*args: Any, **kwargs: Any) -> Any: + from pythinker_code.auth.z_ai import logout_z_ai_coding as impl + + return impl(*args, **kwargs) + + +def logout_z_ai_api(*args: Any, **kwargs: Any) -> Any: + from pythinker_code.auth.z_ai import logout_z_ai_api as impl + + return impl(*args, **kwargs) + + def login_lm_studio(*args: Any, **kwargs: Any) -> Any: from pythinker_code.auth.lm_studio import login_lm_studio as impl @@ -1437,6 +1461,16 @@ def login( ), minimax: bool = typer.Option(False, "--minimax", help="Configure MiniMax with an API key."), deepseek: bool = typer.Option(False, "--deepseek", help="Configure DeepSeek with an API key."), + z_ai_coding: bool = typer.Option( + False, + "--z-ai-coding", + help="Configure a Z.AI Coding Plan subscription key.", + ), + z_ai_api: bool = typer.Option( + False, + "--z-ai-api", + help="Configure a Z.AI pay-as-you-go API key.", + ), anthropic: bool = typer.Option( False, "--anthropic", help="Configure Anthropic with an API key." ), @@ -1469,6 +1503,8 @@ async def _run() -> bool: opencode_go, minimax, deepseek, + z_ai_coding, + z_ai_api, anthropic, openrouter, lm_studio, @@ -1478,8 +1514,8 @@ async def _run() -> bool: if selected_modes > 1: typer.echo( "Choose only one of --browser, --headless, --api-key, " - "--opencode-go, --minimax, --deepseek, --anthropic, --openrouter, " - "--lm-studio, or --ollama.", + "--opencode-go, --minimax, --deepseek, --z-ai-coding, --z-ai-api, " + "--anthropic, --openrouter, --lm-studio, or --ollama.", err=True, ) return False @@ -1491,6 +1527,28 @@ async def _run() -> bool: elif anthropic: key = typer.prompt("Anthropic API key", hide_input=True).strip() events = login_anthropic_api_key(config, key) + elif z_ai_coding: + key = typer.prompt( + "Z.AI Coding Plan API key", + hide_input=True, + default="", + show_default=False, + ).strip() + if not key: + typer.echo("Z.AI Coding Plan API key is required.", err=True) + return False + events = login_z_ai_coding_api_key(config, key) + elif z_ai_api: + key = typer.prompt( + "Z.AI API key", + hide_input=True, + default="", + show_default=False, + ).strip() + if not key: + typer.echo("Z.AI API key is required.", err=True) + return False + events = login_z_ai_api_key(config, key) elif deepseek: key = typer.prompt("DeepSeek API key", hide_input=True).strip() events = login_deepseek_api_key(config, key) @@ -1569,6 +1627,8 @@ def logout( opencode_go: bool = typer.Option(False, "--opencode-go", help="Logout from OpenCode Go."), minimax: bool = typer.Option(False, "--minimax", help="Logout from MiniMax."), deepseek: bool = typer.Option(False, "--deepseek", help="Logout from DeepSeek."), + z_ai_coding: bool = typer.Option(False, "--z-ai-coding", help="Logout from Z.AI Coding Plan."), + z_ai_api: bool = typer.Option(False, "--z-ai-api", help="Logout from Z.AI API."), anthropic: bool = typer.Option(False, "--anthropic", help="Logout from Anthropic."), openrouter: bool = typer.Option(False, "--openrouter", help="Logout from OpenRouter."), lm_studio: bool = typer.Option( @@ -1583,11 +1643,22 @@ def logout( async def _run() -> bool: ok = True - selected_modes = (opencode_go, minimax, deepseek, anthropic, openrouter, lm_studio, ollama) + selected_modes = ( + opencode_go, + minimax, + deepseek, + z_ai_coding, + z_ai_api, + anthropic, + openrouter, + lm_studio, + ollama, + ) if sum(bool(v) for v in selected_modes) > 1: typer.echo( "Choose only one of --opencode-go, --minimax, --deepseek, " - "--anthropic, --openrouter, --lm-studio, or --ollama.", + "--z-ai-coding, --z-ai-api, --anthropic, --openrouter, " + "--lm-studio, or --ollama.", err=True, ) return False @@ -1597,6 +1668,10 @@ async def _run() -> bool: events = logout_openrouter(config) elif anthropic: events = logout_anthropic(config) + elif z_ai_coding: + events = logout_z_ai_coding(config) + elif z_ai_api: + events = logout_z_ai_api(config) elif deepseek: events = logout_deepseek(config) elif minimax: diff --git a/src/pythinker_code/ui/shell/usage_adapters/__init__.py b/src/pythinker_code/ui/shell/usage_adapters/__init__.py index 5c85d0a4..f586238f 100644 --- a/src/pythinker_code/ui/shell/usage_adapters/__init__.py +++ b/src/pythinker_code/ui/shell/usage_adapters/__init__.py @@ -15,10 +15,13 @@ from pythinker_code.ui.shell.usage_adapters.openrouter import OpenRouterAdapter from pythinker_code.ui.shell.usage_adapters.pythinker import PythinkerAdapter from pythinker_code.ui.shell.usage_adapters.pythinker_ai import PythinkerAIAdapter +from pythinker_code.ui.shell.usage_adapters.z_ai import ZaiUsageAdapter _pythinker_ai_adapter = PythinkerAIAdapter() _minimax_adapter = MiniMaxAdapter() _opencode_go_adapter = OpenCodeGoAdapter() +_zai_coding_adapter = ZaiUsageAdapter("z-ai-coding", "Z.AI Coding Plan") +_zai_api_adapter = ZaiUsageAdapter("z-ai-api", "Z.AI API") # A single provider can be registered under several `managed:` # keys when the chat path is exposed through both Anthropic-compat and @@ -47,6 +50,8 @@ # different host). PythinkerAIAdapter.platform_id: _pythinker_ai_adapter, "pythinker_ai-cn": _pythinker_ai_adapter, + "z-ai-coding": _zai_coding_adapter, + "z-ai-api": _zai_api_adapter, } __all__ = ["ADAPTERS", "UsageAdapter", "UsageReport", "UsageRow"] diff --git a/src/pythinker_code/ui/shell/usage_adapters/z_ai.py b/src/pythinker_code/ui/shell/usage_adapters/z_ai.py new file mode 100644 index 00000000..c1c17319 --- /dev/null +++ b/src/pythinker_code/ui/shell/usage_adapters/z_ai.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pythinker_code.ui.shell.usage_adapters.base import UsageReport + +if TYPE_CHECKING: + from pythinker_code.auth.oauth import OAuthManager + from pythinker_code.config import LLMProvider + + +class ZaiUsageAdapter: + requires_admin_key = False + + def __init__(self, platform_id: str, provider_label: str) -> None: + self.platform_id = platform_id + self.provider_label = provider_label + + async def fetch( + self, + provider: LLMProvider, + oauth_mgr: OAuthManager, + ) -> UsageReport: + del provider, oauth_mgr + return UsageReport( + provider_label=self.provider_label, + summary=None, + limits=[], + notes=[ + "Z.AI does not expose a documented route-wide usage endpoint; " + "live rate-limit headers are shown after a chat request when available." + ], + ) diff --git a/tests/cli/test_z_ai_login_cli.py b/tests/cli/test_z_ai_login_cli.py new file mode 100644 index 00000000..d2300419 --- /dev/null +++ b/tests/cli/test_z_ai_login_cli.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from unittest.mock import Mock + +import pytest +from typer.testing import CliRunner + +from pythinker_code.auth.oauth import OAuthEvent +from pythinker_code.cli import cli +from pythinker_code.config import Config + +runner = CliRunner() + + +async def _success_event(*_args: object, **_kwargs: object) -> AsyncIterator[OAuthEvent]: + yield OAuthEvent("success", "ok") + + +def _patch_config(monkeypatch: pytest.MonkeyPatch) -> Config: + config = Config(is_from_default_location=True) + monkeypatch.setattr("pythinker_code.cli.load_config", lambda: config, raising=False) + return config + + +@pytest.mark.parametrize( + ("flag", "target_name", "other_name"), + [ + ("--z-ai-coding", "login_z_ai_coding_api_key", "login_z_ai_api_key"), + ("--z-ai-api", "login_z_ai_api_key", "login_z_ai_coding_api_key"), + ], +) +def test_cli_login_zai_routes_dispatch_only_named_wrapper( + monkeypatch: pytest.MonkeyPatch, + flag: str, + target_name: str, + other_name: str, +) -> None: + config = _patch_config(monkeypatch) + target = Mock(side_effect=_success_event) + other = Mock(side_effect=_success_event) + monkeypatch.setattr(f"pythinker_code.cli.{target_name}", target, raising=False) + monkeypatch.setattr(f"pythinker_code.cli.{other_name}", other, raising=False) + + result = runner.invoke(cli, ["login", flag], input="route-key\n") + + assert result.exit_code == 0, result.output + assert target.call_args.args == (config, "route-key") + other.assert_not_called() + + +@pytest.mark.parametrize( + ("flag", "target_name", "other_name"), + [ + ("--z-ai-coding", "logout_z_ai_coding", "logout_z_ai_api"), + ("--z-ai-api", "logout_z_ai_api", "logout_z_ai_coding"), + ], +) +def test_cli_logout_zai_routes_dispatch_only_named_wrapper( + monkeypatch: pytest.MonkeyPatch, + flag: str, + target_name: str, + other_name: str, +) -> None: + config = _patch_config(monkeypatch) + target = Mock(side_effect=_success_event) + other = Mock(side_effect=_success_event) + monkeypatch.setattr(f"pythinker_code.cli.{target_name}", target, raising=False) + monkeypatch.setattr(f"pythinker_code.cli.{other_name}", other, raising=False) + + result = runner.invoke(cli, ["logout", flag]) + + assert result.exit_code == 0, result.output + assert target.call_args.args == (config,) + other.assert_not_called() + + +def test_cli_login_zai_json_emits_route_events(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_config(monkeypatch) + target = Mock(side_effect=_success_event) + monkeypatch.setattr( + "pythinker_code.cli.login_z_ai_coding_api_key", + target, + raising=False, + ) + + result = runner.invoke( + cli, + ["login", "--z-ai-coding", "--json"], + input="route-key\n", + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output.splitlines()[-1]) + assert payload["type"] == "success" + + +def test_cli_login_zai_flags_are_mutually_exclusive() -> None: + result = runner.invoke(cli, ["login", "--z-ai-coding", "--z-ai-api"]) + assert result.exit_code == 1 + assert "Choose only one" in result.output + + with_openai = runner.invoke(cli, ["login", "--z-ai-coding", "--api-key"]) + assert with_openai.exit_code == 1 + assert "Choose only one" in with_openai.output + + +def test_cli_logout_zai_flags_are_mutually_exclusive() -> None: + result = runner.invoke(cli, ["logout", "--z-ai-coding", "--z-ai-api"]) + assert result.exit_code == 1 + assert "Choose only one" in result.output + + +def test_cli_login_blank_zai_key_fails_before_dispatch(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_config(monkeypatch) + target = Mock(side_effect=_success_event) + monkeypatch.setattr("pythinker_code.cli.login_z_ai_api_key", target, raising=False) + + result = runner.invoke(cli, ["login", "--z-ai-api"], input="\n") + + assert result.exit_code == 1 + assert "required" in result.output.lower() + target.assert_not_called() diff --git a/tests/ui/usage_adapters/test_provider_key_coverage.py b/tests/ui/usage_adapters/test_provider_key_coverage.py index 96181df3..b00aaa3a 100644 --- a/tests/ui/usage_adapters/test_provider_key_coverage.py +++ b/tests/ui/usage_adapters/test_provider_key_coverage.py @@ -29,6 +29,8 @@ "managed:pythinker-code", "managed:pythinker-ai", "managed:pythinker_ai-cn", + "managed:z-ai-coding", + "managed:z-ai-api", ] diff --git a/tests/ui/usage_adapters/test_z_ai.py b/tests/ui/usage_adapters/test_z_ai.py new file mode 100644 index 00000000..0b572c85 --- /dev/null +++ b/tests/ui/usage_adapters/test_z_ai.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import cast +from unittest.mock import MagicMock + +from pydantic import SecretStr + +from pythinker_code.auth.oauth import OAuthManager +from pythinker_code.config import LLMProvider +from pythinker_code.ui.shell.usage import ( + _enrich_with_ratelimit_fallback, + _gather_reports, + _select_providers, +) +from pythinker_code.ui.shell.usage_adapters import ADAPTERS +from pythinker_code.ui.shell.usage_adapters.base import UsageAdapter +from pythinker_code.ui.shell.usage_adapters.z_ai import ZaiUsageAdapter +from pythinker_code.usage_ratelimit_cache import get_cache + + +def _provider(base_url: str) -> LLMProvider: + return LLMProvider( + type="openai_legacy", + base_url=base_url, + api_key=SecretStr("route-key"), + ) + + +def test_zai_usage_adapters_are_distinct_and_route_labeled() -> None: + coding = ADAPTERS["z-ai-coding"] + api = ADAPTERS["z-ai-api"] + + assert isinstance(coding, ZaiUsageAdapter) + assert isinstance(api, ZaiUsageAdapter) + assert coding is not api + assert coding.platform_id == "z-ai-coding" + assert coding.provider_label == "Z.AI Coding Plan" + assert api.platform_id == "z-ai-api" + assert api.provider_label == "Z.AI API" + + +async def test_zai_usage_adapter_is_notes_only_and_never_reads_credentials() -> None: + class _CredentialTrap: + @property + def api_key(self) -> None: + raise AssertionError("Z.AI usage adapter read a route credential") + + adapters = ( + ZaiUsageAdapter("z-ai-coding", "Z.AI Coding Plan"), + ZaiUsageAdapter("z-ai-api", "Z.AI API"), + ) + for adapter in adapters: + report = await adapter.fetch( + cast(LLMProvider, _CredentialTrap()), + cast(OAuthManager, MagicMock()), + ) + assert report.provider_label == adapter.provider_label + assert report.summary is None + assert report.limits == [] + assert report.notes + + +def test_default_usage_selection_is_scoped_to_exact_zai_provider_key() -> None: + providers = { + "managed:z-ai-coding": _provider("https://api.z.ai/api/coding/paas/v4"), + "managed:z-ai-api": _provider("https://api.z.ai/api/paas/v4"), + } + + selected = _select_providers( + providers, + registered_platform_ids=set(ADAPTERS), + filter_provider_key="managed:z-ai-coding", + ) + + assert selected == [("z-ai-coding", providers["managed:z-ai-coding"])] + + +async def test_usage_all_reports_both_zai_routes_with_distinct_labels() -> None: + providers = { + "managed:z-ai-coding": _provider("https://api.z.ai/api/coding/paas/v4"), + "managed:z-ai-api": _provider("https://api.z.ai/api/paas/v4"), + } + selected = _select_providers( + providers, + registered_platform_ids=set(ADAPTERS), + filter_provider_key=None, + ) + pairs = [(ADAPTERS[platform_id], provider) for platform_id, provider in selected] + + reports = await _gather_reports(pairs, cast(OAuthManager, MagicMock())) + + assert {report.provider_label for report in reports} == { + "Z.AI Coding Plan", + "Z.AI API", + } + + +async def test_zai_rate_limit_fallback_never_crosses_route_keys() -> None: + cache = get_cache() + cache.clear() + cache.record( + "managed:z-ai-coding", + { + "x-ratelimit-limit-requests": "100", + "x-ratelimit-remaining-requests": "75", + }, + ) + providers = { + "managed:z-ai-coding": _provider("https://api.z.ai/api/coding/paas/v4"), + "managed:z-ai-api": _provider("https://api.z.ai/api/paas/v4"), + } + selected = _select_providers( + providers, + registered_platform_ids=set(ADAPTERS), + filter_provider_key=None, + ) + pairs: list[tuple[UsageAdapter, LLMProvider]] = [ + (ADAPTERS[platform_id], provider) for platform_id, provider in selected + ] + + reports = await _gather_reports(pairs, cast(OAuthManager, MagicMock())) + enriched = _enrich_with_ratelimit_fallback(reports, selected) + by_label = {report.provider_label: report for report in enriched} + + coding = next(report for report in enriched if report.provider_label.startswith("Z.AI Coding")) + api = by_label["Z.AI API"] + assert coding.summary is not None + assert coding.summary.used == 25 + assert api.summary is None + assert api.limits == [] + cache.clear() From 4b442fa380f8fa9f35dc315a87e375be8d14de13 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 15 Jul 2026 01:02:40 -0400 Subject: [PATCH 6/9] test(llm): verify exact Z.AI requests --- tests/core/test_z_ai_provider_requests.py | 289 ++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 tests/core/test_z_ai_provider_requests.py diff --git a/tests/core/test_z_ai_provider_requests.py b/tests/core/test_z_ai_provider_requests.py new file mode 100644 index 00000000..0146a1cf --- /dev/null +++ b/tests/core/test_z_ai_provider_requests.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import json +from typing import cast + +import pytest +import respx +from httpx import Response +from pydantic import SecretStr +from pythinker_core.chat_provider import ThinkingEffort +from pythinker_core.message import Message, TextPart, ThinkPart, ToolCall +from pythinker_core.tooling import Tool + +from pythinker_code.auth.z_ai import ZAI_ROUTES, ZaiRoute +from pythinker_code.config import LLMModel, LLMProvider +from pythinker_code.llm import ModelCapability, create_llm + + +def _completion_response(model_id: str) -> dict[str, object]: + return { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 1, + "model": model_id, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + + +async def _captured_body( + *, + route: ZaiRoute | None, + provider_key: str, + base_url: str, + model_id: str, + max_context_size: int, + capabilities: set[ModelCapability] | None, + effort: ThinkingEffort, + history: list[Message], + tools: list[Tool], +) -> dict[str, object]: + provider = LLMProvider( + type="openai_legacy", + base_url=base_url, + api_key=SecretStr("test-key"), + ) + model = LLMModel( + provider=provider_key, + model=model_id, + max_context_size=max_context_size, + capabilities=capabilities, + ) + llm = create_llm(provider, model, thinking_effort=effort) + assert llm is not None + llm.chat_provider.stream = False # type: ignore[attr-defined] + + with respx.mock(base_url=base_url) as mock: + endpoint = mock.post("/chat/completions").mock( + return_value=Response(200, json=_completion_response(model_id)) + ) + try: + stream = await llm.chat_provider.generate("", tools, history) + async for _part in stream: + pass + finally: + await llm.chat_provider.client.close() # type: ignore[attr-defined] + + assert endpoint.called + body = json.loads(endpoint.calls.last.request.content.decode()) + assert body["model"] == model_id + if route is not None: + assert endpoint.calls.last.request.url == f"{route.base_url}/chat/completions" + return body + + +@pytest.mark.parametrize("route", ZAI_ROUTES) +@pytest.mark.parametrize( + ("effort", "thinking", "reasoning_effort"), + [ + ("off", {"type": "disabled"}, None), + ("minimal", {"type": "disabled"}, None), + ("low", {"type": "enabled", "clear_thinking": False}, "high"), + ("medium", {"type": "enabled", "clear_thinking": False}, "high"), + ("high", {"type": "enabled", "clear_thinking": False}, "high"), + ("xhigh", {"type": "enabled", "clear_thinking": False}, "max"), + ("max", {"type": "enabled", "clear_thinking": False}, "max"), + ], +) +async def test_glm52_request_effort_mapping( + route: ZaiRoute, + effort: ThinkingEffort, + thinking: dict[str, object], + reasoning_effort: str | None, +) -> None: + body = await _captured_body( + route=route, + provider_key=route.provider_key, + base_url=route.base_url, + model_id="glm-5.2", + max_context_size=1_000_000, + capabilities={"thinking"}, + effort=effort, + history=[Message(role="user", content=[TextPart(text="Use no tools")])], + tools=[], + ) + + assert body["thinking"] == thinking + assert body.get("reasoning_effort") == reasoning_effort + assert body["max_tokens"] == 131_072 + assert "tool_stream" not in body + + +@pytest.mark.parametrize("route", ZAI_ROUTES) +@pytest.mark.parametrize( + ("model_id", "context_tokens", "max_tokens", "tool_stream"), + [ + ("glm-5.1", 204_800, 131_072, True), + ("glm-5", 204_800, 131_072, True), + ("glm-5-turbo", 204_800, 131_072, True), + ("glm-4.7", 204_800, 131_072, True), + ("glm-4.5-air", 131_072, 98_304, False), + ], +) +@pytest.mark.parametrize( + ("effort", "thinking"), + [ + ("off", {"type": "disabled"}), + ("high", {"type": "enabled", "clear_thinking": False}), + ], +) +async def test_binary_curated_model_request_matrix( + route: ZaiRoute, + model_id: str, + context_tokens: int, + max_tokens: int, + tool_stream: bool, + effort: ThinkingEffort, + thinking: dict[str, object], +) -> None: + tool = Tool( + name="read", + description="Read a file", + parameters={"type": "object", "properties": {}}, + ) + body = await _captured_body( + route=route, + provider_key=route.provider_key, + base_url=route.base_url, + model_id=model_id, + max_context_size=context_tokens, + capabilities={"thinking"}, + effort=effort, + history=[Message(role="user", content="Read a file")], + tools=[tool], + ) + + assert body["thinking"] == thinking + assert "reasoning_effort" not in body + assert body["max_tokens"] == max_tokens + assert body.get("tool_stream") is (True if tool_stream else None) + + +@pytest.mark.parametrize("route", ZAI_ROUTES) +async def test_glm52_tool_stream_and_exact_reasoning_replay(route: ZaiRoute) -> None: + tool_call = ToolCall( + id="call_1", + function=ToolCall.FunctionBody(name="read", arguments='{"path":"a.py"}'), + ) + history = [ + Message(role="user", content="Read a.py"), + Message( + role="assistant", + content=[ + ThinkPart(think="first\n"), + ThinkPart(think="second"), + ], + tool_calls=[tool_call], + ), + Message(role="tool", tool_call_id="call_1", content="contents"), + ] + tool = Tool( + name="read", + description="Read a file", + parameters={"type": "object", "properties": {}}, + ) + + body = await _captured_body( + route=route, + provider_key=route.provider_key, + base_url=route.base_url, + model_id="glm-5.2", + max_context_size=1_000_000, + capabilities={"thinking"}, + effort="high", + history=history, + tools=[tool], + ) + + messages = cast(list[dict[str, object]], body["messages"]) + assistant = messages[1] + assert assistant["reasoning_content"] == "first\nsecond" + assert "[reasoning unavailable]" not in str(assistant) + assert body["reasoning_effort"] == "high" + assert body["tool_stream"] is True + + +@pytest.mark.parametrize("route", ZAI_ROUTES) +async def test_glm52_exact_replay_does_not_synthesize_missing_reasoning( + route: ZaiRoute, +) -> None: + history = [ + Message( + role="assistant", + content=[], + tool_calls=[ + ToolCall( + id="call_1", + function=ToolCall.FunctionBody(name="read", arguments="{}"), + ) + ], + ) + ] + + body = await _captured_body( + route=route, + provider_key=route.provider_key, + base_url=route.base_url, + model_id="glm-5.2", + max_context_size=1_000_000, + capabilities={"thinking"}, + effort="high", + history=history, + tools=[], + ) + + messages = cast(list[dict[str, object]], body["messages"]) + assistant = messages[0] + assert "reasoning_content" not in assistant + assert "[reasoning unavailable]" not in str(assistant) + assert body["reasoning_effort"] != "medium" + + +async def test_local_glm_name_has_no_zai_request_policy() -> None: + body = await _captured_body( + route=None, + provider_key="local", + base_url="http://localhost:8080/v1", + model_id="glm-5.2", + max_context_size=1_000_000, + capabilities={"thinking"}, + effort="high", + history=[Message(role="user", content="hello")], + tools=[], + ) + + assert "thinking" not in body + assert "reasoning_effort" not in body + assert "tool_stream" not in body + assert "max_tokens" not in body + + +@pytest.mark.parametrize("route", ZAI_ROUTES) +async def test_unknown_zai_model_has_conservative_request(route: ZaiRoute) -> None: + body = await _captured_body( + route=route, + provider_key=route.provider_key, + base_url=route.base_url, + model_id="glm-future", + max_context_size=131_072, + capabilities=None, + effort="high", + history=[Message(role="user", content="hello")], + tools=[], + ) + + assert "thinking" not in body + assert "reasoning_effort" not in body + assert "tool_stream" not in body + assert "max_tokens" not in body From 0f61c36d05bacf97750c37b2ecb008b5c092b9a1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 15 Jul 2026 01:06:29 -0400 Subject: [PATCH 7/9] fix(auth): honor route-scoped Z.AI env keys --- src/pythinker_code/cli/__init__.py | 28 ++++++++++++++++------------ tests/cli/test_z_ai_login_cli.py | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/pythinker_code/cli/__init__.py b/src/pythinker_code/cli/__init__.py index c2bb55de..66838a41 100644 --- a/src/pythinker_code/cli/__init__.py +++ b/src/pythinker_code/cli/__init__.py @@ -1528,23 +1528,27 @@ async def _run() -> bool: key = typer.prompt("Anthropic API key", hide_input=True).strip() events = login_anthropic_api_key(config, key) elif z_ai_coding: - key = typer.prompt( - "Z.AI Coding Plan API key", - hide_input=True, - default="", - show_default=False, - ).strip() + key = (os.getenv("ZAI_CODING_API_KEY") or "").strip() + if not key: + key = typer.prompt( + "Z.AI Coding Plan API key", + hide_input=True, + default="", + show_default=False, + ).strip() if not key: typer.echo("Z.AI Coding Plan API key is required.", err=True) return False events = login_z_ai_coding_api_key(config, key) elif z_ai_api: - key = typer.prompt( - "Z.AI API key", - hide_input=True, - default="", - show_default=False, - ).strip() + key = (os.getenv("ZAI_API_KEY") or "").strip() + if not key: + key = typer.prompt( + "Z.AI API key", + hide_input=True, + default="", + show_default=False, + ).strip() if not key: typer.echo("Z.AI API key is required.", err=True) return False diff --git a/tests/cli/test_z_ai_login_cli.py b/tests/cli/test_z_ai_login_cli.py index d2300419..66b49d9d 100644 --- a/tests/cli/test_z_ai_login_cli.py +++ b/tests/cli/test_z_ai_login_cli.py @@ -19,6 +19,8 @@ async def _success_event(*_args: object, **_kwargs: object) -> AsyncIterator[OAu def _patch_config(monkeypatch: pytest.MonkeyPatch) -> Config: + monkeypatch.delenv("ZAI_CODING_API_KEY", raising=False) + monkeypatch.delenv("ZAI_API_KEY", raising=False) config = Config(is_from_default_location=True) monkeypatch.setattr("pythinker_code.cli.load_config", lambda: config, raising=False) return config @@ -76,6 +78,30 @@ def test_cli_logout_zai_routes_dispatch_only_named_wrapper( other.assert_not_called() +def test_cli_login_zai_uses_only_the_named_route_environment_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = _patch_config(monkeypatch) + coding = Mock(side_effect=_success_event) + api = Mock(side_effect=_success_event) + monkeypatch.setattr( + "pythinker_code.cli.login_z_ai_coding_api_key", + coding, + raising=False, + ) + monkeypatch.setattr("pythinker_code.cli.login_z_ai_api_key", api, raising=False) + monkeypatch.setenv("ZAI_CODING_API_KEY", "coding-env-key") + monkeypatch.setenv("ZAI_API_KEY", "api-env-key") + + coding_result = runner.invoke(cli, ["login", "--z-ai-coding"]) + api_result = runner.invoke(cli, ["login", "--z-ai-api"]) + + assert coding_result.exit_code == 0, coding_result.output + assert api_result.exit_code == 0, api_result.output + assert coding.call_args.args == (config, "coding-env-key") + assert api.call_args.args == (config, "api-env-key") + + def test_cli_login_zai_json_emits_route_events(monkeypatch: pytest.MonkeyPatch) -> None: _patch_config(monkeypatch) target = Mock(side_effect=_success_event) From e90a552c6c580bb5fa6536de03fa082930723b97 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 15 Jul 2026 01:06:51 -0400 Subject: [PATCH 8/9] docs(auth): document dual Z.AI routes --- CHANGELOG.md | 1 + README.md | 15 ++++++++++++ docs/en/configuration/env-vars.md | 22 +++++++++++++++++ docs/en/configuration/providers.md | 35 +++++++++++++++++++++++++++ docs/en/customization/architecture.md | 18 +++++++++++--- docs/en/reference/slash-commands.md | 12 ++++++++- docs/en/release-notes/changelog.md | 1 + 7 files changed, 100 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b39b3c9f..d6aa6cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased - **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown. +- **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models. ## 0.58.0 (2026-07-11) diff --git a/README.md b/README.md index e2345977..ba270497 100644 --- a/README.md +++ b/README.md @@ -375,6 +375,21 @@ For hosted Pythinker models or ACP terminal auth: pythinker login ``` +Z.AI exposes two independent OpenAI-compatible routes. Choose the route that owns your key; +both may coexist, and Pythinker never infers, migrates, falls back, or retries across them: + +```sh +# Coding Plan subscription +pythinker login --z-ai-coding # reads ZAI_CODING_API_KEY when set + +# Standard pay-as-you-go API +pythinker login --z-ai-api # reads ZAI_API_KEY when set +``` + +These create distinct `z-ai-coding/*` and `z-ai-api/*` model identities. See the +[provider guide](./docs/en/configuration/providers.md#managed-zai-routes) for endpoints, +curated GLM capabilities, and route-scoped `/usage` behavior. + ### 💬 Try it out ```sh diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index b4e6e008..2b0ce922 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -134,6 +134,28 @@ Provides an OpenAI Admin API key for `/usage` cost data. If unset, `/usage` fall export OPENAI_ADMIN_KEY="sk-admin-xxx" ``` +## Z.AI environment variables + +Z.AI Coding Plan and Z.AI API use separate credential variables because they are independent +routes: + +| Environment Variable | Route | Base URL | +| --- | --- | --- | +| `ZAI_CODING_API_KEY` | Z.AI Coding Plan | `https://api.z.ai/api/coding/paas/v4` | +| `ZAI_API_KEY` | Z.AI API | `https://api.z.ai/api/paas/v4` | + +```sh +export ZAI_CODING_API_KEY="your-coding-plan-key" +pythinker login --z-ai-coding + +export ZAI_API_KEY="your-api-key" +pythinker login --z-ai-api +``` + +Each login reads only its route's variable. Pythinker does not infer the route from the key, +fall back to the other variable, migrate credentials, or retry requests across routes. Both +variables may be set when both routes are configured. + ## Other environment variables | Environment Variable | Description | diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index 42337916..26c98512 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -21,6 +21,8 @@ After configuration, Pythinker Code will automatically save settings to `~/.pyth | OpenAI ChatGPT Codex | OpenAI managed account login | | Pythinker AI Open Platform (pythinker-ai.cn) | China region API endpoint | | Pythinker AI Open Platform (pythinker-ai.ai) | Global region API endpoint | +| Z.AI Coding Plan | Subscription route at `api.z.ai/api/coding/paas/v4` | +| Z.AI API | Pay-as-you-go route at `api.z.ai/api/paas/v4` | | LM Studio | Local models served via LM Studio | | Ollama | Local models served via Ollama | @@ -64,6 +66,39 @@ base_url = "https://api.openai.com/v1" api_key = "sk-xxx" ``` +### Managed Z.AI routes + +Z.AI Coding Plan and Z.AI API are independent managed routes. Configure the route that owns +your key; Pythinker does not infer a route from the credential, migrate credentials between +routes, or retry a request against the other endpoint. + +| Route | Login | Provider key | Model prefix | Base URL | Environment variable | +| --- | --- | --- | --- | --- | --- | +| Coding Plan | `pythinker login --z-ai-coding` | `managed:z-ai-coding` | `z-ai-coding/` | `https://api.z.ai/api/coding/paas/v4` | `ZAI_CODING_API_KEY` | +| API | `pythinker login --z-ai-api` | `managed:z-ai-api` | `z-ai-api/` | `https://api.z.ai/api/paas/v4` | `ZAI_API_KEY` | + +The same routes are available in the interactive selector as `/login z-ai-coding` and +`/login z-ai-api`. They may coexist in one config; login, catalog refresh, logout, default-model +repair, and cached rate-limit headers remain scoped to the selected route. `/usage` shows a +route-specific note because Z.AI does not document a route-wide usage endpoint; after a chat +request, captured rate-limit headers are displayed for that route when available. + +Pythinker applies a provider compatibility profile to its curated GLM catalog: + +| Model | Context tokens | Maximum output tokens | Thinking | Streamed tool calls | +| --- | ---: | ---: | --- | --- | +| `glm-5.2` | 1,000,000 | 131,072 | Tiered (`high` / `max`) | Yes | +| `glm-5.1` | 204,800 | 131,072 | Binary | Yes | +| `glm-5` | 204,800 | 131,072 | Binary | Yes | +| `glm-5-turbo` | 204,800 | 131,072 | Binary | Yes | +| `glm-4.7` | 204,800 | 131,072 | Binary | Yes | +| `glm-4.5-air` | 131,072 | 98,304 | Binary | No | + +On these OpenAI-compatible routes, the full-context model id is plain `glm-5.2`; +`glm-5.2[1m]` is not an alias. Unknown Z.AI models keep conservative request defaults until +they are curated. Z.AI reasoning replay uses +only reasoning content the provider returned; Pythinker does not synthesize missing reasoning. + ### `openai_responses` For OpenAI Responses API (newer API format). diff --git a/docs/en/customization/architecture.md b/docs/en/customization/architecture.md index fd057aa2..28a31f7c 100644 --- a/docs/en/customization/architecture.md +++ b/docs/en/customization/architecture.md @@ -79,7 +79,8 @@ The end-to-end flow when a session starts and processes a turn: | `src/pythinker_code/cli/` | Typer command tree and UI-mode routing; lazy-loaded subcommands. | `cli`, `pythinker`, `login`, `logout`, `term`, `acp`, lazy group `info`, `export`, `mcp`, `plugin`, `skill`, `review`, `secscan`, `security-scan`, `debug`, `update`, `dashboard`, `web` | | `src/pythinker_code/app.py` | Builds `PythinkerCLI`, `Runtime`, and `PythinkerSoul`; wires telemetry and frontends. | `PythinkerCLI.create`, `PythinkerCLI.run`, `run_shell` / `run_print` / `run_acp` / `run_wire_stdio` | | `src/pythinker_code/config.py` | Three-scope config resolution (user → project → local TOML) with env overlay and JSON→TOML migration; `SecretStr` fields; scope locks on `api_key`/`providers`/`services`. | `Config`, `load_config`, `save_config`, `get_config_file` | -| `src/pythinker_code/llm.py` | Provider/model selection and capability derivation; wires `pythinker-core` backends. | `LLM`, `create_llm`, `augment_provider_with_env_vars`, `derive_model_capabilities` | +| `src/pythinker_code/llm.py` | Provider/model selection and capability derivation; resolves one compatibility profile and wires `pythinker-core` backends. | `LLM`, `create_llm`, `augment_provider_with_env_vars`, `derive_model_capabilities` | +| `src/pythinker_code/provider_compatibility.py` | Immutable provider/model compatibility profiles for request format, reasoning replay, generation overrides, output limits, and deferred-tool support. Resolution prefers managed identity, then normalized endpoint/API family. | `ProviderCompatibility`, `resolve_provider_compatibility`, `get_zai_model_policy` | | `src/pythinker_code/agentspec.py` | Parses/validates agent YAML specs and resolves `extend`. | `load_agent_spec`, `ResolvedAgentSpec`, `DEFAULT_AGENT_FILE` | ## Soul: the agent loop @@ -163,8 +164,19 @@ See `src/pythinker_code/tools/AGENTS.md`. Provider modules in `auth/`: `openai`, `anthropic_direct`, `opencode_go`, `minimax`, `deepseek`, `openrouter`, `z_ai`, `alibaba`, `lm_studio`, `ollama`, `moonshot`, and `github_feedback`. Managed provider keys follow `managed:`; managed model ids -follow `/`. Provider-aware code derives the provider from the active -model; `/usage` defaults to the active provider, with `/usage all` as the explicit aggregate. +follow `/`. Z.AI has two explicit identities: +`managed:z-ai-coding` / `z-ai-coding/*` for the Coding Plan route and +`managed:z-ai-api` / `z-ai-api/*` for the standard API route. Their credentials, model +catalogs, lifecycle operations, usage notes, and rate-limit snapshots never cross route +boundaries. Provider-aware code derives the provider from the active model; `/usage` defaults +to the active provider, with `/usage all` as the explicit aggregate. + +`src/pythinker_code/provider_compatibility.py` is the application-side source of provider +quirks. It resolves before `create_llm()` builds the `ChatProvider`; `PythinkerSoul` consumes +only generic profile fields (such as supported thinking levels) and stays free of provider-name +branches. `OpenAILegacy` owns transport +conversion, including explicit reasoning replay modes and copied per-request generation +parameters. ## Benchmark runner diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index e064a62a..1e266d95 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -52,9 +52,15 @@ first assembled request, the command reports that no manifest is available. Log in or configure an API platform. After execution, first select a platform: - **Pythinker**: Automatically opens a browser for OAuth authorization +- **Z.AI Coding Plan** (`/login z-ai-coding`): Configures the subscription route +- **Z.AI API** (`/login z-ai-api`): Configures the pay-as-you-go route - **Other platforms**: Enter an API key, then select an available model -After configuration, settings are automatically saved to `~/.pythinker/config.toml` and reloaded. See [Providers](../configuration/providers.md) for details. +The two Z.AI entries are independent and may coexist. Each keeps its own credential, endpoint, +model prefix, catalog refresh, and logout lifecycle; Pythinker never retries one route through +the other. After configuration, settings are automatically saved to +`~/.pythinker/config.toml` and reloaded. See [Providers](../configuration/providers.md) for +details. Alias: `/setup` @@ -220,6 +226,10 @@ Usage: - `/usage `: Show usage for a specific provider - `/usage --json`: Output the report as JSON +For Z.AI, the Coding Plan and API reports are labeled separately. Z.AI has no documented +route-wide usage endpoint, so each report shows an explanatory note and adds cached rate-limit +headers only from requests made through that exact route. + Aliases: `/status`, `/cost` ### `/mcp` diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 81076318..4dca28bf 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -18,6 +18,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased - **Parallel streamed tool calls are now correlated safely.** Interleaved argument chunks stay attached to their indexed calls, malformed or truncated call streams stop before tool execution, and failed attempts are not retried after output has already been shown. +- **Provider compatibility and Z.AI routing are now explicit.** Immutable compatibility profiles keep request-format quirks behind the chat-provider boundary, while independent Z.AI Coding Plan and API login routes use separate credentials, endpoints, model identities, catalog refresh, logout, and usage/rate-limit state. Curated GLM requests now apply exact context/output limits, thinking controls, reasoning replay, and tool-stream support without activating for local or unknown models. ## 0.58.0 (2026-07-11) From bbbe6f3c37a0fe068a90f97dd6dc0d451ee2fa61 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 15 Jul 2026 02:04:13 -0400 Subject: [PATCH 9/9] test(llm): verify compatibility through requests --- src/pythinker_code/llm.py | 15 +++++------ tests/core/test_compaction_overflow.py | 14 ++++++++-- tests/core/test_create_llm.py | 33 +++++++++-------------- tests/core/test_model_switch_carryover.py | 10 ++++++- tests/core/test_z_ai_provider_requests.py | 25 +++++++++++++++++ 5 files changed, 66 insertions(+), 31 deletions(-) diff --git a/src/pythinker_code/llm.py b/src/pythinker_code/llm.py index 3cde220d..8d505d16 100644 --- a/src/pythinker_code/llm.py +++ b/src/pythinker_code/llm.py @@ -73,9 +73,9 @@ def capped_chat_provider(llm: LLM, max_output_tokens: int) -> ChatProvider: usual output budget (e.g. a context-compaction summary) can use this instead of hand-picking a provider-specific kwarg name. """ - compatibility = getattr(llm, "compatibility", None) - kwarg = compatibility.output_tokens_kwarg if compatibility is not None else "max_tokens" - return cast(Any, llm.chat_provider).with_generation_kwargs(**{kwarg: max_output_tokens}) + return cast(Any, llm.chat_provider).with_generation_kwargs( + **{llm.compatibility.output_tokens_kwarg: max_output_tokens} + ) def supports_deferred_tool_search(llm: LLM | None) -> bool: @@ -529,11 +529,10 @@ def available_model_thinking_levels( ) -> tuple[ThinkingEffort, ...]: """Selectable thinking levels for *model*, scoped to provider-specific support. - Starts from the capability-derived ladder, then narrows to a provider's - actually-accepted set when known (currently the OpenAI GPT-5 family) so the - selector never offers — and :func:`create_llm` never sends — a level the - model rejects. Falls back to the full ladder for models without a known - per-model rule. + Starts from the capability-derived ladder, then narrows to the resolved + provider/model profile's accepted set when known so the selector never offers — + and :func:`create_llm` never sends — a level the model rejects. Falls back to + the full ladder for models without a known per-model rule. """ base = available_thinking_levels(capabilities) scoped_levels = ( diff --git a/tests/core/test_compaction_overflow.py b/tests/core/test_compaction_overflow.py index 2b286104..6c761cc8 100644 --- a/tests/core/test_compaction_overflow.py +++ b/tests/core/test_compaction_overflow.py @@ -21,7 +21,10 @@ from pythinker_code.config import LLMModel, LLMProvider from pythinker_code.llm import LLM, capped_chat_provider -from pythinker_code.provider_compatibility import resolve_provider_compatibility +from pythinker_code.provider_compatibility import ( + default_provider_compatibility, + resolve_provider_compatibility, +) from pythinker_code.soul.compaction import SimpleCompaction from pythinker_code.wire.types import TextPart @@ -50,7 +53,14 @@ def with_generation_kwargs(self, **kwargs: object) -> _FakeChatProvider: def _fake_llm() -> LLM: - return cast(LLM, SimpleNamespace(chat_provider=_FakeChatProvider(), provider_config=None)) + return cast( + LLM, + SimpleNamespace( + chat_provider=_FakeChatProvider(), + provider_config=None, + compatibility=default_provider_compatibility(), + ), + ) def _fake_llm_with_provider_type(provider_type: str) -> LLM: diff --git a/tests/core/test_create_llm.py b/tests/core/test_create_llm.py index 5540113a..853f0375 100644 --- a/tests/core/test_create_llm.py +++ b/tests/core/test_create_llm.py @@ -795,20 +795,14 @@ def test_create_llm_zai_glm52_activates_explicit_profile_policy( assert llm is not None assert isinstance(llm.chat_provider, OpenAILegacy) assert llm.compatibility.profile_id == provider_key.removeprefix("managed:") + assert llm.compatibility.reasoning_replay_mode == "exact" + assert llm.compatibility.auto_reasoning_effort is False + assert llm.compatibility.tool_stream is True + assert llm.compatibility.tool_message_conversion == "extract_text" + assert llm.compatibility.max_output_tokens == 131_072 assert llm.chat_provider.thinking_effort is None assert llm.thinking is True assert llm.thinking_effort == "xhigh" - assert llm.chat_provider._reasoning_replay_mode == "exact" # pyright: ignore[reportPrivateUsage] - assert llm.chat_provider._auto_reasoning_effort is False # pyright: ignore[reportPrivateUsage] - assert llm.chat_provider._tool_stream is True # pyright: ignore[reportPrivateUsage] - assert llm.chat_provider._tool_message_conversion == "extract_text" # pyright: ignore[reportPrivateUsage] - assert llm.chat_provider._generation_kwargs == { # pyright: ignore[reportPrivateUsage] - "max_tokens": 131_072, - "extra_body": { - "thinking": {"type": "enabled", "clear_thinking": False}, - "reasoning_effort": "max", - }, - } @pytest.mark.parametrize(("thinking", "enabled"), [(False, False), (True, True)]) @@ -831,10 +825,10 @@ def test_create_llm_self_hosted_qwen_uses_chat_template_thinking_toggle( assert llm is not None assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.compatibility.profile_id == "qwen-template" + assert llm.compatibility.thinking_format == "qwen_template" assert llm.chat_provider.thinking_effort is None - assert llm.chat_provider._generation_kwargs.get("extra_body") == { # pyright: ignore[reportPrivateUsage] - "chat_template_kwargs": {"enable_thinking": enabled} - } + assert llm.thinking is enabled def test_create_llm_zai_binary_model_maps_minimal_to_disabled() -> None: @@ -854,13 +848,11 @@ def test_create_llm_zai_binary_model_maps_minimal_to_disabled() -> None: assert llm is not None assert isinstance(llm.chat_provider, OpenAILegacy) + assert llm.compatibility.thinking_format == "zai_binary" + assert llm.compatibility.max_output_tokens == 131_072 assert llm.chat_provider.thinking_effort is None assert llm.thinking is False assert llm.thinking_effort == "off" - assert llm.chat_provider._generation_kwargs == { # pyright: ignore[reportPrivateUsage] - "max_tokens": 131_072, - "extra_body": {"thinking": {"type": "disabled"}}, - } def test_create_llm_local_glm_name_does_not_activate_zai_request_policy() -> None: @@ -881,8 +873,9 @@ def test_create_llm_local_glm_name_does_not_activate_zai_request_policy() -> Non assert llm is not None assert isinstance(llm.chat_provider, OpenAILegacy) assert llm.compatibility.profile_id == "openai-compatible" - assert "extra_body" not in llm.chat_provider._generation_kwargs # pyright: ignore[reportPrivateUsage] - assert "max_tokens" not in llm.chat_provider._generation_kwargs # pyright: ignore[reportPrivateUsage] + assert llm.compatibility.thinking_format == "none" + assert llm.compatibility.max_output_tokens is None + assert llm.compatibility.tool_stream is False def test_clone_llm_with_model_alias_preserves_kimi_thinking_disabled(): diff --git a/tests/core/test_model_switch_carryover.py b/tests/core/test_model_switch_carryover.py index 1161c9fb..189f706e 100644 --- a/tests/core/test_model_switch_carryover.py +++ b/tests/core/test_model_switch_carryover.py @@ -19,6 +19,7 @@ from pythinker_code.config import Config from pythinker_code.llm import LLM +from pythinker_code.provider_compatibility import default_provider_compatibility from pythinker_code.soul.compaction import SimpleCompaction from pythinker_code.wire.types import TextPart @@ -31,7 +32,14 @@ def with_generation_kwargs(self, **kwargs: object) -> _FakeChatProvider: def _fake_llm() -> LLM: - return cast(LLM, SimpleNamespace(chat_provider=_FakeChatProvider(), provider_config=None)) + return cast( + LLM, + SimpleNamespace( + chat_provider=_FakeChatProvider(), + provider_config=None, + compatibility=default_provider_compatibility(), + ), + ) def _history(n_pairs: int = 3) -> list[Message]: diff --git a/tests/core/test_z_ai_provider_requests.py b/tests/core/test_z_ai_provider_requests.py index 0146a1cf..9a14f71a 100644 --- a/tests/core/test_z_ai_provider_requests.py +++ b/tests/core/test_z_ai_provider_requests.py @@ -135,6 +135,7 @@ async def test_glm52_request_effort_mapping( ("effort", "thinking"), [ ("off", {"type": "disabled"}), + ("minimal", {"type": "disabled"}), ("high", {"type": "enabled", "clear_thinking": False}), ], ) @@ -250,6 +251,30 @@ async def test_glm52_exact_replay_does_not_synthesize_missing_reasoning( assert body["reasoning_effort"] != "medium" +@pytest.mark.parametrize( + ("effort", "enabled"), + [("off", False), ("high", True)], +) +async def test_self_hosted_qwen_uses_chat_template_thinking_toggle( + effort: ThinkingEffort, + enabled: bool, +) -> None: + body = await _captured_body( + route=None, + provider_key="local", + base_url="http://localhost:8080/v1", + model_id="Qwen3.6-35B-A3B", + max_context_size=262_144, + capabilities={"thinking"}, + effort=effort, + history=[Message(role="user", content="hello")], + tools=[], + ) + + assert body["chat_template_kwargs"] == {"enable_thinking": enabled} + assert "reasoning_effort" not in body + + async def test_local_glm_name_has_no_zai_request_policy() -> None: body = await _captured_body( route=None,