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
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
40 changes: 29 additions & 11 deletions app/routes/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
report_activation_events,
)
from ..claim import claim as do_claim
from ..models import PROVIDERS, Status
from ..oauth import ZaiAuthFlow
from ..models import PROVIDERS, REALMS, Status
from ..oauth import REALM_INTL, ZaiAuthFlow
from ..quota import fetch_quota, refresh_accounts
from ..store import store

Expand Down Expand Up @@ -68,20 +68,30 @@ async def status_info():
@router.post("/accounts")
async def add_accounts(payload: dict = Body(...)):
provider = payload.get("provider", "zai")
realm = str(payload.get("realm") or "").strip().lower()
if provider not in PROVIDERS:
raise HTTPException(400, "不支持的 provider")
if realm and realm not in REALMS:
raise HTTPException(400, "realm 必须是 intl 或 cn")
tokens = payload.get("tokens") or []
if isinstance(tokens, str):
tokens = [t.strip() for t in tokens.splitlines() if t.strip()]
tokens = [t.strip() for t in tokens if t and t.strip()]
if not tokens:
raise HTTPException(400, "请输入至少一个 Token / API Key")

# 纠偏:bigmodel 提供商通道只接受 open.bigmodel.cn 的 API Key(x-api-key);
# 两段点的 zcodejwttoken 在该通道永远不可用,自动归位为 zai + 国内版
migrated = [t for t in tokens if provider == "bigmodel" and t.count(".") == 2]
if migrated:
provider, realm = "zai", "cn"
logs.info("accounts", f"检测到 {len(migrated)} 个 JWT 凭证,已从 bigmodel 归位为 zai/cn")

added = []
existing = {a.id for a in store.list_accounts(provider)} # 识别真新增(重复 token 跳过)
for tok in dict.fromkeys(tokens): # 去重保序
name = payload.get("name") or f"{provider}-{len(store.list_accounts(provider)) + 1}"
acc = store.add_account(provider, name, tok)
acc = store.add_account(provider, name, tok, realm=realm)
added.append(acc.id)
# 立即刷新一次额度(仅 zai jwt)
fresh = [a for a in store.list_accounts(provider) if a.id in added and a.mode == "jwt"]
Expand Down Expand Up @@ -225,24 +235,30 @@ def _login_gc() -> None:

@router.post("/login/start")
async def login_start(payload: dict = Body(default=None)):
"""发起 Z.AI OAuth,返回授权链接供前端展示。
"""发起 OAuth 授权,返回授权链接供前端展示。

payload 可选 {"label": "acct-1"} —— 作为账号名前缀入池,便于多号识别。
payload 可选 {"label": "acct-1", "realm": "intl"|"cn"} —— label 作为账号名
前缀入池,便于多号识别;realm 决定授权页与业务域(intl=z.ai 国际版,
cn=bigmodel.cn 国内版),缺省 intl。
"""
payload = payload or {}
label = (payload.get("label") or "").strip()[:32]
realm = str(payload.get("realm") or REALM_INTL).strip().lower()
if realm not in REALMS:
raise HTTPException(400, "realm 必须是 intl 或 cn")
_login_gc()
flow = ZaiAuthFlow()
flow = ZaiAuthFlow(realm=realm)
try:
flow_id, authorize_url = await flow.init()
except Exception as err: # noqa: BLE001
logs.warn("oauth", f"登录初始化失败: {type(err).__name__}: {err}")
logs.warn("oauth", f"登录初始化失败 realm={realm}: {type(err).__name__}: {err}")
raise HTTPException(502, f"登录初始化失败: {err}") from err
_login_flows[flow_id] = {"flow": flow, "created": time.time(), "label": label}
logs.info("oauth", f"发起登录 flow_id={flow_id} label={label or '-'}")
logs.info("oauth", f"发起登录 realm={realm} flow_id={flow_id} label={label or '-'}")
return {
"flow_id": flow_id,
"authorize_url": authorize_url,
"realm": realm,
"expires_in": int(LOGIN_FLOW_TTL),
}

Expand Down Expand Up @@ -297,17 +313,19 @@ async def login_poll(flow_id: str):

# JWT 先入池并立刻 ready;兑换 API Key / 额度刷新改后台,避免卡住前端下一轮 poll。
zcode_jwt = data.get("token")
access_token = (data.get("zai") or {}).get("access_token")
# 业务 token 块随区域:intl=data.zai,cn=data.bigmodel(flow 已带 realm)
access_token = flow.business_access_token(data)
realm = getattr(flow, "realm", REALM_INTL)
label = entry.get("label") or "oauth-login"
account = None
if zcode_jwt:
account = store.add_account("zai", label, zcode_jwt)
account = store.add_account("zai", label, zcode_jwt, realm=realm)
elif access_token:
try:
api_key = await asyncio.wait_for(
flow.exchange_api_key(access_token), timeout=LOGIN_EXCHANGE_TIMEOUT
)
account = store.add_account("zai", label, api_key)
account = store.add_account("zai", label, api_key, realm=realm)
except Exception as err: # noqa: BLE001
logs.warn("oauth", f"无 JWT 时兑换 API Key 失败: {err}")

Expand Down
3 changes: 3 additions & 0 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ def _bool(env_name: str, default: bool) -> bool:
# OAuth 与兑换链 origin(测试时指向 Mock 上游)
OAUTH_API_BASE = os.getenv("ZCODE_OAUTH_API_BASE", constants.ZCODE_ORIGIN + "/api/v1")
ZAI_EXCHANGE_ORIGIN = os.getenv("ZCODE_EXCHANGE_ORIGIN", constants.ZAI_API_ORIGIN)
BIGMODEL_EXCHANGE_ORIGIN = os.getenv(
"ZCODE_BIGMODEL_EXCHANGE_ORIGIN", constants.BIGMODEL_BUSINESS_ORIGIN
)

USER_AGENT = os.getenv("UPSTREAM_USER_AGENT", constants.USER_AGENT)
APP_VERSION = "2.6.8"
Expand Down
10 changes: 6 additions & 4 deletions app/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,10 @@ def _find_locked(self, provider: str, id_or_name: str) -> Account | None:
return None

# ── 账号增删改 ───────────────────────────────────────────────────────────
def add_account(self, provider: str, name: str, secret: str) -> Account:
def add_account(self, provider: str, name: str, secret: str, realm: str = "") -> Account:
if provider not in PROVIDERS:
raise ValueError(f"不支持的 provider: {provider}")
account = Account.create(provider, name, secret)
account = Account.create(provider, name, secret, realm=realm)
with self._lock:
for a in self._accounts[provider]:
if a.secret and a.secret == account.secret:
Expand Down Expand Up @@ -294,7 +294,8 @@ def export(self) -> dict:
"exported_at": time.time(),
"providers": {
p: [
{"name": a.name, "mode": a.mode, "secret": a.secret}
{"name": a.name, "mode": a.mode, "secret": a.secret,
"realm": a.realm}
for a in self._accounts[p]
]
for p in PROVIDERS
Expand All @@ -311,7 +312,8 @@ def import_accounts(self, payload: dict) -> int:
secret = it.get("secret") or it.get("token") or it.get("jwtToken") or it.get("apiKey")
if not secret:
continue
self.add_account(provider, it.get("name", provider), secret)
self.add_account(provider, it.get("name", provider), secret,
realm=str(it.get("realm") or ""))
count += 1
return count

Expand Down
Loading