Skip to content

Commit 10aedf2

Browse files
authored
feat(auth): add explicit Z.AI compatibility routes (#206)
1 parent dbc59bc commit 10aedf2

35 files changed

Lines changed: 2995 additions & 905 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1616
## Unreleased
1717

1818
- **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.
19+
- **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.
1920

2021
## 0.58.0 (2026-07-11)
2122

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,21 @@ For hosted Pythinker models or ACP terminal auth:
375375
pythinker login
376376
```
377377

378+
Z.AI exposes two independent OpenAI-compatible routes. Choose the route that owns your key;
379+
both may coexist, and Pythinker never infers, migrates, falls back, or retries across them:
380+
381+
```sh
382+
# Coding Plan subscription
383+
pythinker login --z-ai-coding # reads ZAI_CODING_API_KEY when set
384+
385+
# Standard pay-as-you-go API
386+
pythinker login --z-ai-api # reads ZAI_API_KEY when set
387+
```
388+
389+
These create distinct `z-ai-coding/*` and `z-ai-api/*` model identities. See the
390+
[provider guide](./docs/en/configuration/providers.md#managed-zai-routes) for endpoints,
391+
curated GLM capabilities, and route-scoped `/usage` behavior.
392+
378393
### 💬 Try it out
379394

380395
```sh

docs/en/configuration/env-vars.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,28 @@ Provides an OpenAI Admin API key for `/usage` cost data. If unset, `/usage` fall
134134
export OPENAI_ADMIN_KEY="sk-admin-xxx"
135135
```
136136

137+
## Z.AI environment variables
138+
139+
Z.AI Coding Plan and Z.AI API use separate credential variables because they are independent
140+
routes:
141+
142+
| Environment Variable | Route | Base URL |
143+
| --- | --- | --- |
144+
| `ZAI_CODING_API_KEY` | Z.AI Coding Plan | `https://api.z.ai/api/coding/paas/v4` |
145+
| `ZAI_API_KEY` | Z.AI API | `https://api.z.ai/api/paas/v4` |
146+
147+
```sh
148+
export ZAI_CODING_API_KEY="your-coding-plan-key"
149+
pythinker login --z-ai-coding
150+
151+
export ZAI_API_KEY="your-api-key"
152+
pythinker login --z-ai-api
153+
```
154+
155+
Each login reads only its route's variable. Pythinker does not infer the route from the key,
156+
fall back to the other variable, migrate credentials, or retry requests across routes. Both
157+
variables may be set when both routes are configured.
158+
137159
## Other environment variables
138160

139161
| Environment Variable | Description |

docs/en/configuration/providers.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ After configuration, Pythinker Code will automatically save settings to `~/.pyth
2121
| OpenAI ChatGPT Codex | OpenAI managed account login |
2222
| Pythinker AI Open Platform (pythinker-ai.cn) | China region API endpoint |
2323
| Pythinker AI Open Platform (pythinker-ai.ai) | Global region API endpoint |
24+
| Z.AI Coding Plan | Subscription route at `api.z.ai/api/coding/paas/v4` |
25+
| Z.AI API | Pay-as-you-go route at `api.z.ai/api/paas/v4` |
2426
| LM Studio | Local models served via LM Studio |
2527
| Ollama | Local models served via Ollama |
2628

@@ -64,6 +66,39 @@ base_url = "https://api.openai.com/v1"
6466
api_key = "sk-xxx"
6567
```
6668

69+
### Managed Z.AI routes
70+
71+
Z.AI Coding Plan and Z.AI API are independent managed routes. Configure the route that owns
72+
your key; Pythinker does not infer a route from the credential, migrate credentials between
73+
routes, or retry a request against the other endpoint.
74+
75+
| Route | Login | Provider key | Model prefix | Base URL | Environment variable |
76+
| --- | --- | --- | --- | --- | --- |
77+
| 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` |
78+
| API | `pythinker login --z-ai-api` | `managed:z-ai-api` | `z-ai-api/` | `https://api.z.ai/api/paas/v4` | `ZAI_API_KEY` |
79+
80+
The same routes are available in the interactive selector as `/login z-ai-coding` and
81+
`/login z-ai-api`. They may coexist in one config; login, catalog refresh, logout, default-model
82+
repair, and cached rate-limit headers remain scoped to the selected route. `/usage` shows a
83+
route-specific note because Z.AI does not document a route-wide usage endpoint; after a chat
84+
request, captured rate-limit headers are displayed for that route when available.
85+
86+
Pythinker applies a provider compatibility profile to its curated GLM catalog:
87+
88+
| Model | Context tokens | Maximum output tokens | Thinking | Streamed tool calls |
89+
| --- | ---: | ---: | --- | --- |
90+
| `glm-5.2` | 1,000,000 | 131,072 | Tiered (`high` / `max`) | Yes |
91+
| `glm-5.1` | 204,800 | 131,072 | Binary | Yes |
92+
| `glm-5` | 204,800 | 131,072 | Binary | Yes |
93+
| `glm-5-turbo` | 204,800 | 131,072 | Binary | Yes |
94+
| `glm-4.7` | 204,800 | 131,072 | Binary | Yes |
95+
| `glm-4.5-air` | 131,072 | 98,304 | Binary | No |
96+
97+
On these OpenAI-compatible routes, the full-context model id is plain `glm-5.2`;
98+
`glm-5.2[1m]` is not an alias. Unknown Z.AI models keep conservative request defaults until
99+
they are curated. Z.AI reasoning replay uses
100+
only reasoning content the provider returned; Pythinker does not synthesize missing reasoning.
101+
67102
### `openai_responses`
68103

69104
For OpenAI Responses API (newer API format).

docs/en/customization/architecture.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ The end-to-end flow when a session starts and processes a turn:
7979
| `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` |
8080
| `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` |
8181
| `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` |
82-
| `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` |
82+
| `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` |
83+
| `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` |
8384
| `src/pythinker_code/agentspec.py` | Parses/validates agent YAML specs and resolves `extend`. | `load_agent_spec`, `ResolvedAgentSpec`, `DEFAULT_AGENT_FILE` |
8485

8586
## Soul: the agent loop
@@ -163,8 +164,19 @@ See `src/pythinker_code/tools/AGENTS.md`.
163164
Provider modules in `auth/`: `openai`, `anthropic_direct`, `opencode_go`, `minimax`,
164165
`deepseek`, `openrouter`, `z_ai`, `alibaba`, `lm_studio`, `ollama`, `moonshot`, and
165166
`github_feedback`. Managed provider keys follow `managed:<platform_id>`; managed model ids
166-
follow `<platform_id>/<model_id>`. Provider-aware code derives the provider from the active
167-
model; `/usage` defaults to the active provider, with `/usage all` as the explicit aggregate.
167+
follow `<platform_id>/<model_id>`. Z.AI has two explicit identities:
168+
`managed:z-ai-coding` / `z-ai-coding/*` for the Coding Plan route and
169+
`managed:z-ai-api` / `z-ai-api/*` for the standard API route. Their credentials, model
170+
catalogs, lifecycle operations, usage notes, and rate-limit snapshots never cross route
171+
boundaries. Provider-aware code derives the provider from the active model; `/usage` defaults
172+
to the active provider, with `/usage all` as the explicit aggregate.
173+
174+
`src/pythinker_code/provider_compatibility.py` is the application-side source of provider
175+
quirks. It resolves before `create_llm()` builds the `ChatProvider`; `PythinkerSoul` consumes
176+
only generic profile fields (such as supported thinking levels) and stays free of provider-name
177+
branches. `OpenAILegacy` owns transport
178+
conversion, including explicit reasoning replay modes and copied per-request generation
179+
parameters.
168180

169181
## Benchmark runner
170182

docs/en/reference/slash-commands.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,15 @@ first assembled request, the command reports that no manifest is available.
5252
Log in or configure an API platform. After execution, first select a platform:
5353

5454
- **Pythinker**: Automatically opens a browser for OAuth authorization
55+
- **Z.AI Coding Plan** (`/login z-ai-coding`): Configures the subscription route
56+
- **Z.AI API** (`/login z-ai-api`): Configures the pay-as-you-go route
5557
- **Other platforms**: Enter an API key, then select an available model
5658

57-
After configuration, settings are automatically saved to `~/.pythinker/config.toml` and reloaded. See [Providers](../configuration/providers.md) for details.
59+
The two Z.AI entries are independent and may coexist. Each keeps its own credential, endpoint,
60+
model prefix, catalog refresh, and logout lifecycle; Pythinker never retries one route through
61+
the other. After configuration, settings are automatically saved to
62+
`~/.pythinker/config.toml` and reloaded. See [Providers](../configuration/providers.md) for
63+
details.
5864

5965
Alias: `/setup`
6066

@@ -220,6 +226,10 @@ Usage:
220226
- `/usage <provider-key>`: Show usage for a specific provider
221227
- `/usage --json`: Output the report as JSON
222228

229+
For Z.AI, the Coding Plan and API reports are labeled separately. Z.AI has no documented
230+
route-wide usage endpoint, so each report shows an explanatory note and adds cached rate-limit
231+
headers only from requests made through that exact route.
232+
223233
Aliases: `/status`, `/cost`
224234

225235
### `/mcp`

docs/en/release-notes/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1818
## Unreleased
1919

2020
- **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.
21+
- **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.
2122

2223
## 0.58.0 (2026-07-11)
2324

packages/pythinker-core/src/pythinker_core/contrib/chat_provider/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
from typing import Literal
44

55
type ToolMessageConversion = Literal["extract_text"]
6+
type ReasoningReplayMode = Literal["exact", "tool_calls", "strict_synthetic"]

packages/pythinker-core/src/pythinker_core/contrib/chat_provider/openai_legacy.py

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@
2929
thinking_effort_to_reasoning_effort,
3030
tool_to_openai,
3131
)
32-
from pythinker_core.contrib.chat_provider.common import ToolMessageConversion
32+
from pythinker_core.contrib.chat_provider.common import (
33+
ReasoningReplayMode,
34+
ToolMessageConversion,
35+
)
3336
from pythinker_core.message import ContentPart, Message, TextPart, ThinkPart, ToolCall, ToolCallPart
3437
from pythinker_core.tooling import Tool
3538

@@ -76,6 +79,9 @@ def __init__(
7679
base_url: str | None = None,
7780
stream: bool = True,
7881
reasoning_key: str | None = None,
82+
reasoning_replay_mode: ReasoningReplayMode | None = None,
83+
auto_reasoning_effort: bool = True,
84+
tool_stream: bool = False,
7985
tool_message_conversion: ToolMessageConversion | None = None,
8086
**client_kwargs: Any,
8187
):
@@ -98,6 +104,11 @@ def __init__(
98104
"""The underlying `AsyncOpenAI` client."""
99105
self._reasoning_effort: ReasoningEffort | Omit = omit
100106
self._reasoning_key = reasoning_key
107+
self._reasoning_replay_mode: ReasoningReplayMode = reasoning_replay_mode or (
108+
"strict_synthetic" if _is_strict_interleaved_model(model) else "tool_calls"
109+
)
110+
self._auto_reasoning_effort = auto_reasoning_effort
111+
self._tool_stream = tool_stream
101112
self._tool_message_conversion: ToolMessageConversion | None = tool_message_conversion
102113
self._generation_kwargs: OpenAILegacy.GenerationKwargs = {}
103114

@@ -124,14 +135,22 @@ async def generate(
124135
messages.extend(self._convert_message(message) for message in history)
125136

126137
generation_kwargs: dict[str, Any] = {}
127-
generation_kwargs.update(self._generation_kwargs)
138+
generation_kwargs.update(copy.deepcopy(self._generation_kwargs))
139+
if self._tool_stream and tools:
140+
extra_body = dict(generation_kwargs.get("extra_body") or {})
141+
extra_body["tool_stream"] = True
142+
generation_kwargs["extra_body"] = extra_body
128143

129144
reasoning_effort = self._reasoning_effort
130145
# Auto-enable reasoning_effort when the history contains ThinkPart but reasoning
131146
# was not explicitly configured. This prevents server validation errors from APIs
132147
# (e.g. One API) that require reasoning_effort when messages contain reasoning_content.
133148
# See: https://github.com/Pythoughts-labs/pythinker-code/issues/1616
134-
if isinstance(reasoning_effort, Omit) and self._reasoning_key:
149+
if (
150+
self._auto_reasoning_effort
151+
and isinstance(reasoning_effort, Omit)
152+
and self._reasoning_key
153+
):
135154
has_think_part = any(
136155
isinstance(part, ThinkPart) for message in history for part in message.content
137156
)
@@ -213,19 +232,15 @@ def _convert_message(self, message: Message) -> ChatCompletionMessageParam:
213232
else:
214233
message.content = content
215234
dumped_message = message.model_dump(exclude_none=True)
216-
if self._reasoning_key:
217-
# Kimi-style interleaved-thinking providers require consistent
218-
# reasoning replay metadata on assistant history. At a minimum
219-
# tool-call turns need the field, and known strict models are
220-
# safest when all assistant turns include it, even if empty.
221-
has_tool_calls = message.role == "assistant" and bool(message.tool_calls)
222-
strict_interleaved = message.role == "assistant" and _is_strict_interleaved_model(
223-
self.model
224-
)
225-
if reasoning_content or has_tool_calls or strict_interleaved:
226-
if strict_interleaved and not reasoning_content:
227-
reasoning_content = message.extract_text() or "[reasoning unavailable]"
228-
dumped_message[self._reasoning_key] = reasoning_content
235+
if self._reasoning_key and reasoning_content:
236+
dumped_message[self._reasoning_key] = reasoning_content
237+
elif self._reasoning_key and message.role == "assistant":
238+
if self._reasoning_replay_mode == "tool_calls" and message.tool_calls:
239+
dumped_message[self._reasoning_key] = ""
240+
elif self._reasoning_replay_mode == "strict_synthetic":
241+
dumped_message[self._reasoning_key] = (
242+
message.extract_text() or "[reasoning unavailable]"
243+
)
229244
return cast(ChatCompletionMessageParam, dumped_message)
230245

231246

0 commit comments

Comments
 (0)