Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,11 @@ node_modules/
# 秘钥 / 凭证导出
*accounts*.json
zcode-accounts*.json

# 本地运行 / 工具杂物
serve.log
serve.err.log
serve.pid
.trae_*
.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
24 changes: 23 additions & 1 deletion app/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ def openai_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
})
elif role == "assistant":
blocks: list[dict] = []
# 多轮重放:reasoning_content(历史轮思考文本)→ Anthropic thinking 块。
# 上游对 thinking 块 signature 不校验(实测伪造值可通过),空串亦可。
rc = msg.get("reasoning_content") or msg.get("reasoning") or msg.get("reasoning_text")
if isinstance(rc, str) and rc.strip():
blocks.append({"type": "thinking", "thinking": rc, "signature": ""})
text = _text_from_content(content)
if text:
blocks.append({"type": "text", "text": text})
Expand Down Expand Up @@ -148,6 +153,12 @@ def openai_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
body["top_p"] = float(payload["top_p"])
except (TypeError, ValueError):
pass
# 推理强度:上游 GLM-5.3 系原生接受 reasoning_effort(low/high/max)。
# 其余值(含 off/none)不带该字段——上游不支持关闭思考,交给默认档。
effort = payload.get("reasoning_effort")
if isinstance(effort, str) and effort.strip().lower() in ("low", "high", "max"):
body["reasoning_effort"] = effort.strip().lower()

stop = payload.get("stop")
if isinstance(stop, str) and stop:
body["stop_sequences"] = [stop]
Expand Down Expand Up @@ -186,11 +197,14 @@ def openai_to_anthropic(payload: dict) -> tuple[dict | None, str | None]:
def anthropic_to_openai(data: dict, model: str) -> dict:
"""Anthropic message 响应 → OpenAI chat.completion。"""
text_parts: list[str] = []
thinking_parts: list[str] = []
tool_calls: list[dict] = []
for block in data.get("content") or []:
if not isinstance(block, dict):
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
if block.get("type") == "thinking" and isinstance(block.get("thinking"), str):
thinking_parts.append(block["thinking"])
elif block.get("type") == "text" and isinstance(block.get("text"), str):
text_parts.append(block["text"])
elif block.get("type") == "tool_use":
tool_calls.append({
Expand All @@ -205,6 +219,10 @@ def anthropic_to_openai(data: dict, model: str) -> dict:
in_tok = _as_int(usage.get("input_tokens")) or 0
out_tok = _as_int(usage.get("output_tokens")) or 0
message: dict = {"role": "assistant", "content": "".join(text_parts) or None}
# OpenAI 兼容侧通用约定:思考文本放 reasoning_content(pi-ai/DeepSeek 风格均可识别)
reasoning = "\n".join(p for p in thinking_parts if p)
if reasoning:
message["reasoning_content"] = reasoning
if tool_calls:
message["tool_calls"] = tool_calls
return {
Expand Down Expand Up @@ -261,6 +279,8 @@ def feed(self, evt: dict) -> list[str]:
return []
if etype == "content_block_start":
block = evt.get("content_block") or {}
if block.get("type") == "thinking":
return [self._chunk({"reasoning_content": ""})]
if block.get("type") == "tool_use":
idx = self._tool_seq
self._tool_seq += 1
Expand All @@ -273,6 +293,8 @@ def feed(self, evt: dict) -> list[str]:
return []
if etype == "content_block_delta":
delta = evt.get("delta") or {}
if delta.get("type") == "thinking_delta" and isinstance(delta.get("thinking"), str):
return [self._chunk({"reasoning_content": delta["thinking"]})]
if delta.get("type") == "text_delta" and isinstance(delta.get("text"), str):
return [self._chunk({"content": delta["text"]})]
if delta.get("type") == "input_json_delta" and isinstance(delta.get("partial_json"), str):
Expand Down
Loading