Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
025efd7
feat(oauth): 双区域登录入池(intl/cn)——bigmodel.cn 国内版授权与业务域分域
jk-uzi Oct 4, 2026
b4bcab3
feat(openai-compat): thinking/reasoning_content 双向透传与 reasoning_effort
jk-uzi Oct 4, 2026
5e94585
docs: README 增加 Docker 部署说明(compose 一键起,数据持久化 ./data)
jk-uzi Oct 4, 2026
cd18534
chore: 前端版本号对齐 + captcha_node lockfile 同步(puppeteer-core 真浏览器求解)
jk-uzi Oct 4, 2026
89ff25a
chore: Linux/Windows 启动脚本(功能对齐)+ Docker 部署文件 + 本地杂物忽略
jk-uzi Oct 4, 2026
e3222ef
fix(scripts): start.sh 启动前剔除 ALL_PROXY —— Clash 系统代理注入的 socks:// 会让 h…
jk-uzi Oct 4, 2026
a9f633c
chore: 前端版本号对齐 2.6.8(合并 main 指纹线后与 APP_VERSION 保持一致)
jk-uzi Oct 4, 2026
1f7cddc
test: fresh_app 钉住默认管理密钥——本机 .env 自定义 ZCODE_ADMIN_KEY 不再漏入夹具
jk-uzi Oct 4, 2026
d5a4e7a
ci: 双平台矩阵(ubuntu+windows)——push 触发扩展到 develop/feat/**,feature 分支推送即跑双…
jk-uzi Oct 4, 2026
ed40f7d
chore: .gitignore 补回 .sg_* 通配(凭证文件兜底,永不入库)
jk-uzi Oct 4, 2026
1d599ed
refactor: 简化启动脚本为极简前台运行版本,并添加调试输出文件
jk-uzi Oct 4, 2026
69c19ba
fix(scripts): start.sh 代理兜底条件化——ALL_PROXY 为非 http(s) scheme(如 Clash 注…
jk-uzi Oct 4, 2026
b7df3ce
fix(scripts): start.bat 代理兜底条件化——与 start.sh 对齐:ALL_PROXY 为非 http(s) s…
jk-uzi Oct 4, 2026
4df382c
chore: 清理误提交的调试残留(_debug_start.bat/_debug_out.txt,随 1d599ed 误入库)
jk-uzi Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 构建上下文瘦身 + 秘密不入镜像(roadmap 约定:.env / data/ 永不入镜像)
.git
.github
.venv
.env
data
tests
docs
scripts
__pycache__
**/__pycache__
.pytest_cache
.ruff_cache
.mypy_cache
captcha_node/node_modules
*.md
Dockerfile
docker-compose.yml
.dockerignore
start.bat
start.sh
uv.lock
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
* text=auto eol=lf

*.bat text eol=crlf
*.cmd text eol=crlf
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ name: ci

on:
push:
branches: [main]
branches: [main, develop, "feat/**"]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,12 @@ node_modules/
# 秘钥 / 凭证导出
*accounts*.json
zcode-accounts*.json

# 本地运行 / 工具杂物
serve.log
serve.err.log
serve.pid
.trae_*
.sg_*
.sg-js/
.sg_login.html
70 changes: 70 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# zcode-hub 容器镜像:Python 3.11 网关 + Node 22 验证码求解子进程 + Debian Chromium
#
# 运行时结构:
# /app 代码(app/ cli.py frontend/ captcha_node/)
# /app/data 持久化(accounts.db + device_mid),挂载卷
# /usr/bin/node 从 node:22 官方镜像复制(bookworm 与运行时基础镜像同系,glibc 兼容)
# /usr/bin/chromium Debian chromium,solver_pw.js findChromium() 默认探测路径之一
#
# captcha 求解依赖真浏览器(solver 已带 --no-sandbox/--disable-dev-shm-usage),
# 且 oom 看门狗需要写 /proc/*/oom_score_adj —— 容器以 root 运行是前提,勿改 USER。

# ── 阶段 1:求解器 Node 依赖(纯 JS,无原生编译) ─────────────────────────────
FROM node:22-bookworm-slim AS captcha-deps
WORKDIR /build
COPY captcha_node/package.json captcha_node/package-lock.json ./
RUN npm ci --omit=dev

# ── 阶段 2:运行时 ────────────────────────────────────────────────────────────
FROM python:3.11-slim-bookworm

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
ZCODE_HOST=0.0.0.0 \
ZCODE_PORT=3000 \
ZCODE_DATA_DIR=/app/data \
ZCODE_NODE_PATH=/usr/local/bin/node \
ZCODE_CHROMIUM_PATH=/usr/bin/chromium

# tini 作 PID 1:回收 uvicorn→node→chrome 进程树遗留的僵尸
# fonts-noto-cjk:验证码挑战页含中文文案,缺字形可能影响求解
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
chromium \
fonts-noto-cjk \
fonts-liberation \
ca-certificates \
tini \
&& rm -rf /var/lib/apt/lists/*

# Node 运行时(构建期不需要 npm,只带 node 本体)
COPY --from=captcha-deps /usr/local/bin/node /usr/local/bin/node

WORKDIR /app

# 运行时依赖(与 requirements.txt 上半段保持一致;dev/test 工具不进镜像)
RUN pip install --no-cache-dir \
"fastapi>=0.115.0" \
"uvicorn[standard]>=0.30.0" \
"httpx>=0.27.0" \
"python-dotenv>=1.0.1" \
"cryptography>=42.0.0" \
"pyyaml>=6.0.0"

COPY app/ app/
COPY captcha_node/ captcha_node/
COPY --from=captcha-deps /build/node_modules/ captcha_node/node_modules/
COPY frontend/ frontend/
COPY cli.py ./

# data/ 不打进镜像(.dockerignore 双保险),挂卷持久化
RUN mkdir -p /app/data

EXPOSE 3000

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["python", "cli.py", "serve"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:3000/meta', timeout=4)"]
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ cp .env.example .env # 按需修改密钥、端口等

> 使用 Z.AI **JWT 模式**需要 Node.js 求解验证码,首次先执行:`cd captcha_node && npm install`。

### Docker 部署(推荐)

镜像自带 Python 3.11 + Node 22 + Chromium,无需宿主机装任何运行时:

```bash
cp .env.example .env # 按需修改 ZCODE_ADMIN_KEY、端口等
docker compose up -d --build
```

- 访问 `http://127.0.0.1:3000/admin/login`;宿主机端口取 `.env` 的 `ZCODE_PORT`
- 账号库与设备指纹持久化在宿主机 `./data/`(首次启动自动生成,已有数据直接沿用)
- `.env` 里的 `ZCODE_CHROMIUM_PATH` 等宿主机路径会被 compose 覆盖为容器内路径,无需改动
- 容器内 CLI:`docker compose exec zcode-hub python cli.py accounts`(其余子命令同理)
- 停止:`docker compose down`(数据保留在 `./data/`)

## 快速上手一个账号

```bash
Expand Down
8 changes: 7 additions & 1 deletion app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,16 @@ def build_request(

if provider == "zai":
if account.uses_plan_channel() and not force_fallback:
# Plan 通道 intl/cn 同端点(zcode.z.ai,JWT 决定归属)
target_url = settings.UPSTREAM["zai"]
auth = {"Authorization": f"Bearer {account.jwt_token}"}
elif account.api_key:
target_url = settings.UPSTREAM["zai_fallback"]
# 回退 Key 的域随账号区域:intl → api.z.ai;cn(bigmodel OAuth
# 兑换的 Key)→ open.bigmodel.cn
if account.realm == "cn":
target_url = settings.UPSTREAM["bigmodel"]
else:
target_url = settings.UPSTREAM["zai_fallback"]
auth = {"x-api-key": account.api_key}
else:
raise RuntimeError("账号缺少有效凭证")
Expand Down
17 changes: 17 additions & 0 deletions app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@
OAUTH_CLI_INIT_PATH = "/api/v1/oauth/cli/init"
OAUTH_CLI_POLL_PATH = "/api/v1/oauth/cli/poll" # + /{flow_id}

# OAuth 双区域(对齐 codeapi providers/zcode/realms.py,官方客户端 3.14.x 实证):
# CLI init/poll 端点 intl/cn 共用,provider 参数与业务域随区域——
# intl → provider=zai,授权页 chat.z.ai;poll 的 data.zai.access_token 是
# OAuth 会话凭证,需经 api.z.ai/api/auth/z/login 换业务 token;
# cn → provider=bigmodel,授权页 bigmodel.cn/login(服务端回调);
# poll 的 data.bigmodel.access_token 本身就是业务 token(官方客户端
# BigModel adapter 不做二次换取)。
# 两个区域的 data.token 都是 zcodejwttoken(Plan 通道 Bearer,JWT 决定归属,
# messages / billing / claim 端点 intl/cn 完全相同,无需分域)。
BIGMODEL_BUSINESS_ORIGIN = "https://bigmodel.cn"
OAUTH_REALMS = {
"intl": {"oauth_provider": "zai", "business_origin": ZAI_API_ORIGIN},
"cn": {"oauth_provider": "bigmodel", "business_origin": BIGMODEL_BUSINESS_ORIGIN},
}
# poll 响应里业务 token 块的块名随区域
OAUTH_PROVIDER_BLOCKS = {"intl": "zai", "cn": "bigmodel"}

# ── 客户端版本(单一真相源:官方客户端 3.14.4,2026-10-02 跟进)────────────────
# 客户端 claim 头实测缺版本/平台头 → 上游 3007;client/configs 带 platform 参数 → 3001
# 出处:本机官方 ZCode.exe(注册表 DisplayVersion=3.14.4,2026-10-02 实测;
Expand Down
11 changes: 10 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from dataclasses import asdict, dataclass, field

PROVIDERS = ("zai", "bigmodel")
# 账号区域:intl=国际版(z.ai 授权,业务域 api.z.ai);cn=国内版(bigmodel.cn 授权,
# 业务域 bigmodel.cn)。Plan 通道(messages/billing/claim)两区域同端点,JWT 决定归属
REALMS = ("intl", "cn")


class Status:
Expand All @@ -33,6 +36,7 @@ class Account:
name: str
provider: str
mode: str # "jwt" | "apiKey"
realm: str = "intl" # 账号区域(models.REALMS):intl=国际版 / cn=国内版
jwt_token: str | None = None
api_key: str | None = None
enabled: bool = True
Expand Down Expand Up @@ -61,14 +65,18 @@ class Account:
installed_at: float | None = None # 按账号安装序完成时间;None = 未安装

@staticmethod
def create(provider: str, name: str, secret: str) -> Account:
def create(provider: str, name: str, secret: str, realm: str = "") -> Account:
secret = (secret or "").strip()
is_jwt = secret.count(".") == 2 and provider == "zai"
# 区域缺省/非法值时自动归区:bigmodel 提供商即国内域 Key,其余国际版
if realm not in REALMS:
realm = "cn" if provider == "bigmodel" else "intl"
return Account(
id=_account_id(name),
name=name or f"{provider}-account",
provider=provider,
mode="jwt" if is_jwt else "apiKey",
realm=realm,
jwt_token=secret if is_jwt else None,
api_key=None if is_jwt else secret,
)
Expand Down Expand Up @@ -192,6 +200,7 @@ def public_view(self) -> dict:
"id": self.id,
"name": self.name,
"provider": self.provider,
"realm": self.realm,
"mode": self.mode,
"token_masked": masked,
"enabled": self.enabled,
Expand Down
83 changes: 64 additions & 19 deletions app/oauth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
"""Z.AI OAuth 登录流程。

主要供 CLI `login zai` 使用:发起 OAuth → 轮询 → 兑换 API Key。
"""Z.AI / BigModel(国内版)OAuth 登录流程。

主要供 CLI `login zai` 与后台「授权登录」使用:发起 OAuth → 轮询 → 兑换 API Key。

双区域(constants.OAUTH_REALMS,对齐官方客户端 3.14.x 实证):
- intl(zai):init `{"provider":"zai"}`,授权页 chat.z.ai;poll 的
`data.zai.access_token` 是 OAuth 会话凭证,需经 `api.z.ai/api/auth/z/login`
换业务 token 后再走机构/项目/Key 兑换链(业务域 api.z.ai);
- cn(bigmodel):init `{"provider":"bigmodel"}`,授权页 bigmodel.cn/login
(服务端回调);poll 的 `data.bigmodel.access_token` 本身就是业务 token
(官方客户端 BigModel adapter 不做二次换取),业务链在 bigmodel.cn 域。
两个区域的 `data.token` 都是 zcodejwttoken(Plan 通道 Bearer),messages /
billing / claim 端点 intl/cn 完全相同,无需分域。
"""

from __future__ import annotations
Expand All @@ -9,11 +19,16 @@

import httpx

from . import settings
from . import constants, settings

REALM_INTL = "intl"
REALM_CN = "cn"


class ZaiAuthFlow:
"""api_base / exchange_origin 可注入(测试指向 Mock 上游);
"""一次 OAuth CLI 登录会话;realm 决定 provider 参数、业务 token 块与业务域。

api_base / exchange_origin 可注入(测试指向 Mock 上游);
默认值来自 settings(其缺省又来自 constants 收口)。

官方 CLI 规范(对齐 zcode.cjs createZaiCliOAuthClient):
Expand All @@ -26,9 +41,19 @@ class ZaiAuthFlow:
(保留旧协议兼容)。
"""

def __init__(self, api_base: str | None = None, exchange_origin: str | None = None) -> None:
def __init__(self, api_base: str | None = None, exchange_origin: str | None = None,
realm: str = REALM_INTL) -> None:
realm = str(realm or REALM_INTL).strip().lower()
self.realm = realm if realm in constants.OAUTH_REALMS else REALM_INTL
self.oauth_provider = constants.OAUTH_REALMS[self.realm]["oauth_provider"]
self.api_base = api_base or settings.OAUTH_API_BASE
self.exchange_origin = exchange_origin or settings.ZAI_EXCHANGE_ORIGIN
if exchange_origin:
self.exchange_origin = exchange_origin
else:
self.exchange_origin = (
settings.BIGMODEL_EXCHANGE_ORIGIN if self.realm == REALM_CN
else settings.ZAI_EXCHANGE_ORIGIN
)
self.poll_token = secrets.token_hex(32)

async def init(self) -> tuple[str, str]:
Expand All @@ -39,7 +64,7 @@ async def init(self) -> tuple[str, str]:
"Authorization": f"Bearer {self.poll_token}",
"Content-Type": "application/json",
},
json={"provider": "zai"},
json={"provider": self.oauth_provider},
)
res.raise_for_status()
body = res.json()
Expand All @@ -64,19 +89,39 @@ async def poll(self, flow_id: str) -> dict:
res.raise_for_status()
return res.json().get("data") or {}

def business_access_token(self, data: dict) -> str:
"""从 poll 结果提取业务 access_token;块名随区域(intl=data.zai,cn=data.bigmodel)。"""
block = data.get(constants.OAUTH_PROVIDER_BLOCKS[self.realm])
if isinstance(block, dict):
return str(block.get("access_token") or "").strip()
return ""

async def _resolve_business_token(self, client: httpx.AsyncClient, access_token: str) -> str:
"""OAuth access_token → 业务 token。

cn(bigmodel):poll 下发的 access_token 本身就是业务 token,直接使用;
intl(zai):需经 exchange_origin 的 /api/auth/z/login 换业务 token。
"""
if self.realm == REALM_CN:
return access_token
login = await client.post(
f"{self.exchange_origin}/api/auth/z/login",
headers={"Content-Type": "application/json"},
json={"token": access_token},
)
login.raise_for_status()
biz = (login.json().get("data") or {})
biz_token = biz.get("access_token") or biz.get("accessToken")
if not biz_token:
raise RuntimeError("返回数据中不含业务凭证")
return biz_token

async def exchange_api_key(self, access_token: str) -> str:
"""OAuth access_token → 业务 token → 机构/项目 → API Key。"""
"""OAuth access_token → 业务 token → 机构/项目 → API Key(业务域随区域)。"""
if not access_token:
raise RuntimeError("缺少 access_token,无法兑换 API Key")
async with httpx.AsyncClient(timeout=30) as client:
login = await client.post(
f"{self.exchange_origin}/api/auth/z/login",
headers={"Content-Type": "application/json"},
json={"token": access_token},
)
login.raise_for_status()
biz = (login.json().get("data") or {})
biz_token = biz.get("access_token") or biz.get("accessToken")
if not biz_token:
raise RuntimeError("返回数据中不含业务凭证")
biz_token = await self._resolve_business_token(client, access_token)

info = await client.get(
f"{self.exchange_origin}/api/biz/customer/getCustomerInfo",
Expand Down
Loading