diff --git a/app/agent.py b/app/agent.py
index f189963..d36e0a5 100644
--- a/app/agent.py
+++ b/app/agent.py
@@ -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("账号缺少有效凭证")
diff --git a/app/constants.py b/app/constants.py
index 53104f5..cf8e8ea 100644
--- a/app/constants.py
+++ b/app/constants.py
@@ -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 实测;
diff --git a/app/models.py b/app/models.py
index 36b10c0..5f32793 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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:
@@ -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
@@ -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,
)
@@ -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,
diff --git a/app/oauth.py b/app/oauth.py
index eab588b..b09223a 100644
--- a/app/oauth.py
+++ b/app/oauth.py
@@ -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
@@ -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):
@@ -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]:
@@ -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()
@@ -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",
diff --git a/app/routes/admin_api.py b/app/routes/admin_api.py
index 0299316..316222c 100644
--- a/app/routes/admin_api.py
+++ b/app/routes/admin_api.py
@@ -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
@@ -68,8 +68,11 @@ 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()]
@@ -77,11 +80,18 @@ async def add_accounts(payload: dict = Body(...)):
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"]
@@ -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),
}
@@ -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}")
diff --git a/app/settings.py b/app/settings.py
index 67f0ab5..c5bcd10 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -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"
diff --git a/app/store.py b/app/store.py
index d9d6f74..8a379a7 100644
--- a/app/store.py
+++ b/app/store.py
@@ -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:
@@ -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
@@ -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
diff --git a/frontend/admin/accounts.html b/frontend/admin/accounts.html
index 3fd356b..70e3ffa 100644
--- a/frontend/admin/accounts.html
+++ b/frontend/admin/accounts.html
@@ -77,7 +77,11 @@
提供商
-
+
+
+
+ 账号区域
+
@@ -89,12 +93,19 @@
-
通过 Z.AI 官方 OAuth 授权登录,授权成功后自动导入到账号池(含 Coding Plan 额度)。链接约 5 分钟内有效,换设备打开亦可。
+
通过官方 OAuth 授权登录,授权成功后自动导入到账号池(含 Coding Plan 额度)。链接约 5 分钟内有效,换设备打开亦可。
+
+ 账号区域
+
+
-
+
💡海外邮箱 / Google 账号登录说明
@@ -104,6 +115,12 @@
2. 登录成功后,点击上方「生成授权链接」并打开授权,即可秒级自动入池。
+
+
+ ✅国内版(智谱开放平台)登录说明
+
+
点击「生成授权链接」后,在浏览器里完成 bigmodel.cn 的手机号 / 微信扫码登录并授权,凭证(含 Coding Plan 额度)自动入池,无需其他操作。
+
在任意浏览器打开下面的链接完成授权(本页保持打开即自动入池):
@@ -114,10 +131,14 @@
等待授权中…
-
+
⚠️ 提示:若打开后未见邮箱/Google登录选项
上游官方前端默认仅展示手机登录。若需海外邮箱/Google登录:请先打开
邮箱/Google 预登录入口 登录账号,再点击上方「打开授权」即可秒级入池。
+
+
✅ 国内版授权中
+
打开链接后若未登录,会先进入 bigmodel.cn 登录页(手机号 / 微信扫码),登录并确认授权后本页会自动检测并导入账号。
+
@@ -303,8 +324,9 @@
function credCell(a){
const isJwt=a.mode==='jwt';
+ const realm=realmLabel(a.realm||'intl');
return `
${esc(a.name)}${tierBadgeHtml(a)}
-
${esc(a.token_masked)}·${esc(a.provider)} / ${isJwt?'JWT':'API Key'}
`;
+
${esc(a.token_masked)}·${esc(a.provider)} / ${isJwt?'JWT':'API Key'}·${esc(realm||'—')}
`;
}
function statusCell(a){
@@ -439,19 +461,34 @@
}
/* ── Actions ── */
+function realmLabel(r){return r==='cn'?'国内版':(r==='intl'?'国际版':'');}
function openAdd(){
document.getElementById('add-tokens').value='';
switchAddTab('paste');
resetLoginPane();
+ onProviderChange();
openModal('modal-add');
}
+/* 粘贴凭证:bigmodel 提供商只收国内域 API Key,区域选择仅对 zai 有意义 */
+function onProviderChange(){
+ const isZai=document.getElementById('add-provider').value==='zai';
+ document.getElementById('add-realm-field').style.display=isZai?'':'none';
+}
+/* 授权登录:切换区域 = 更换授权出口,未完成的在途链接一并作废重置 */
+function onLoginRealmChange(){
+ const realm=document.getElementById('login-realm').value;
+ document.getElementById('login-help-intl').style.display=realm==='intl'?'':'none';
+ document.getElementById('login-help-cn').style.display=realm==='cn'?'':'none';
+ resetLoginPane();
+}
async function doAdd(){
const tokens=document.getElementById('add-tokens').value;
const provider=document.getElementById('add-provider').value;
+ const realm=provider==='zai'?document.getElementById('add-realm').value:'';
const list=tokens.split('\n').map(s=>s.trim()).filter(Boolean);
if(!list.length)return showToast('请输入至少一个 Token','error');
try{
- const d=await api('POST','/accounts',{provider,tokens:list});
+ const d=await api('POST','/accounts',{provider,tokens:list,...(realm?{realm}:{})});
closeModal('modal-add');
showToast(`新增 ${d.count} 个账号`,'success');
load();
@@ -480,10 +517,14 @@
btn.disabled=true;
try{
const label=document.getElementById('login-label').value.trim();
- const d=await api('POST','/login/start',label?{label}:{});
+ const realm=document.getElementById('login-realm').value;
+ const d=await api('POST','/login/start',{realm,...(label?{label}:{})});
_loginUrl=d.authorize_url;
_loginDeadline=Date.now()+(d.expires_in||300)*1000;
document.getElementById('login-url').value=_loginUrl;
+ // 激活态提示块随区域切换(intl 的邮箱/Google 预登录技巧不适用于 cn)
+ document.getElementById('login-active-tip-intl').style.display=realm==='intl'?'':'none';
+ document.getElementById('login-active-tip-cn').style.display=realm==='cn'?'':'none';
document.getElementById('login-idle').style.display='none';
document.getElementById('login-active').style.display='';
setLoginStatus('等待授权中…',false);
diff --git a/tests/conftest.py b/tests/conftest.py
index aacfe68..67a3c92 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -123,6 +123,8 @@ async def gateway_client(fresh_app, mock_server, monkeypatch, stub_captcha):
# OAuth(cli init/poll + api-key 兑换链)全部收敛到 Mock —— 测试永不打真网
monkeypatch.setattr(settings, "OAUTH_API_BASE", f"{base}/api/v1")
monkeypatch.setattr(settings, "ZAI_EXCHANGE_ORIGIN", base)
+ # 国内版(bigmodel.cn)兑换链业务域同样指向 Mock
+ monkeypatch.setattr(settings, "BIGMODEL_EXCHANGE_ORIGIN", base)
from app.routes import gateway as gateway_module
monkeypatch.setattr(gateway_module, "captcha_manager", stub_captcha)
diff --git a/tests/integration/test_oauth_login.py b/tests/integration/test_oauth_login.py
index 3070714..e1c5208 100644
--- a/tests/integration/test_oauth_login.py
+++ b/tests/integration/test_oauth_login.py
@@ -7,6 +7,7 @@
from __future__ import annotations
import asyncio
+import json
import pytest
@@ -273,3 +274,131 @@ async def test_exchange_api_key_chain_on_ready(self, gateway_client):
assert any(p.endswith("/api_keys/copy/mock-api-key-id") for p in paths)
# ready 后立即触发一次额度刷新(billing 三端点)
assert any(p.endswith("/billing/current") for p in paths)
+
+
+@pytest.mark.integration
+class TestOAuthCnRealm:
+ """国内版(bigmodel.cn)OAuth 双区域回归(对齐 codeapi providers/zcode 实证):
+
+ - init 请求体 provider=bigmodel(国内版授权出口)
+ - poll 业务 token 在 data.bigmodel 块,且本身就是业务 token —— 不打 z/login
+ - 兑换链(getCustomerInfo → api_keys → copy)照走,Key 挂回 JWT 作回退
+ - 账号以 realm=cn 入池;非法 realm 400
+ """
+
+ @pytest.fixture(autouse=True)
+ async def _reset_mock(self, gateway_client):
+ _, mock = gateway_client
+ mock.state.oauth_state = "pending"
+ mock.state.oauth_poll_status = 200
+ mock.state.oauth_poll_realm = "cn"
+ yield
+ mock.state.oauth_poll_realm = None
+ await _drain_login_followup()
+
+ async def test_invalid_realm_rejected(self, gateway_client):
+ client, _ = gateway_client
+ res = await client.post("/admin/api/login/start", json={"realm": "xx"},
+ headers={"Authorization": "Bearer zcode"})
+ assert res.status_code == 400
+
+ async def test_cn_start_uses_bigmodel_provider(self, gateway_client):
+ client, mock = gateway_client
+ # mock 上游 session 级共享,calls 跨用例累积 —— 记录基线后只断言增量
+ base = len(mock.state.calls)
+ start = (await client.post("/admin/api/login/start", json={"realm": "cn"},
+ headers={"Authorization": "Bearer zcode"})).json()
+ assert start["realm"] == "cn"
+ init = next(c for c in mock.state.calls[base:] if c[1].endswith("/oauth/cli/init"))
+ assert json.loads(init[3])["provider"] == "bigmodel"
+ # 对照:缺省(intl)发起时 provider=zai
+ base2 = len(mock.state.calls)
+ await client.post("/admin/api/login/start",
+ headers={"Authorization": "Bearer zcode"})
+ init_intl = next(c for c in mock.state.calls[base2:]
+ if c[1].endswith("/oauth/cli/init"))
+ assert json.loads(init_intl[3])["provider"] == "zai"
+
+ async def test_cn_full_flow_skips_z_login_and_pools_cn_account(self, gateway_client):
+ client, mock = gateway_client
+ base = len(mock.state.calls)
+ start = (await client.post("/admin/api/login/start",
+ json={"label": "cn-1", "realm": "cn"},
+ headers={"Authorization": "Bearer zcode"})).json()
+ mock.state.oauth_state = "ready"
+ poll = (await client.get(f"/admin/api/login/poll/{start['flow_id']}",
+ headers={"Authorization": "Bearer zcode"})).json()
+ assert poll["status"] == "ready"
+ acc = poll["account"]
+ assert acc["provider"] == "zai"
+ assert acc["realm"] == "cn"
+ assert acc["mode"] == "jwt"
+
+ await _drain_login_followup()
+ paths = [c[1] for c in mock.state.calls[base:]]
+ # cn 的业务 token 即 access_token:绝不打国际版的 z/login 换取
+ assert "/api/auth/z/login" not in paths
+ # 兑换链照走,回退 Key 挂回同一 JWT 账号
+ assert "/api/biz/customer/getCustomerInfo" in paths
+ assert any(p.endswith("/api_keys/copy/mock-api-key-id") for p in paths)
+ accounts = (await client.get("/admin/api/accounts",
+ headers={"Authorization": "Bearer zcode"})).json()
+ stored = next(a for a in accounts["accounts"] if a["id"] == acc["id"])
+ assert stored["realm"] == "cn"
+ assert (stored["token_masked"] or "") != ""
+
+
+@pytest.mark.integration
+class TestPasteRealm:
+ """粘贴入池的区域语义:
+
+ - zai + realm=cn 的两段点 JWT → JWT 模式、realm=cn 入池
+ - bigmodel 提供商粘入两段点 JWT(zcodejwttoken)→ 自动归位 zai/cn
+ (bigmodel 通道只接受 open.bigmodel.cn 的 x-api-key,JWT 在该通道永不可用)
+ - 非法 realm 400
+ """
+
+ async def _post(self, client, payload):
+ return await client.post("/admin/api/accounts", json=payload,
+ headers={"Authorization": "Bearer zcode"})
+
+ async def test_zai_cn_jwt_pools_with_cn_realm(self, gateway_client):
+ client, _ = gateway_client
+ res = await self._post(client, {"provider": "zai", "realm": "cn",
+ "tokens": ["h.cnjwt.sig"]})
+ assert res.status_code == 200
+ accounts = (await client.get("/admin/api/accounts",
+ headers={"Authorization": "Bearer zcode"})).json()
+ acc = accounts["accounts"][0]
+ assert acc["realm"] == "cn"
+ assert acc["mode"] == "jwt"
+
+ async def test_bigmodel_jwt_auto_migrates_to_zai_cn(self, gateway_client):
+ client, _ = gateway_client
+ res = await self._post(client, {"provider": "bigmodel",
+ "tokens": ["h.migrated.sig"]})
+ assert res.status_code == 200
+ accounts = (await client.get("/admin/api/accounts",
+ headers={"Authorization": "Bearer zcode"})).json()
+ acc = accounts["accounts"][0]
+ assert acc["provider"] == "zai"
+ assert acc["realm"] == "cn"
+ assert acc["mode"] == "jwt"
+
+ async def test_bigmodel_apikey_defaults_cn(self, gateway_client):
+ client, _ = gateway_client
+ res = await self._post(client, {"provider": "bigmodel",
+ "tokens": ["mock-api-key-id.secret"]})
+ assert res.status_code == 200
+ accounts = (await client.get("/admin/api/accounts",
+ headers={"Authorization": "Bearer zcode"})).json()
+ acc = accounts["accounts"][0]
+ assert acc["provider"] == "bigmodel"
+ assert acc["realm"] == "cn"
+ assert acc["mode"] == "apiKey"
+
+ async def test_invalid_realm_rejected(self, gateway_client):
+ client, _ = gateway_client
+ res = await self._post(client, {"provider": "zai", "realm": "xx",
+ "tokens": ["abc"]})
+ assert res.status_code == 400
diff --git a/tests/mock_upstream/server.py b/tests/mock_upstream/server.py
index 050b144..a709f9b 100644
--- a/tests/mock_upstream/server.py
+++ b/tests/mock_upstream/server.py
@@ -412,11 +412,15 @@ async def oauth_poll(flow_id: str, request: Request) -> Response:
if state == "failed":
data["message"] = getattr(app.state, "oauth_fail_message", "user denied")
if state == "ready":
- data.update({
- "status": "ready",
- "token": "mock-gateway-jwt-header.eyJzdWIiOiJtb2NrIn0.sig",
- "zai": {"access_token": "mock-access-token"},
- })
+ data["status"] = "ready"
+ data["token"] = "mock-gateway-jwt-header.eyJzdWIiOiJtb2NrIn0.sig"
+ # 业务 token 块随区域(真实上游只下发与 init provider 匹配的块):
+ # oauth_poll_realm="cn" → data.bigmodel(业务 token 即 access_token);
+ # 缺省 intl → data.zai(OAuth 会话凭证,还需 z/login 换业务 token)
+ if str(getattr(app.state, "oauth_poll_realm", "") or "") == "cn":
+ data["bigmodel"] = {"access_token": "mock-cn-access-token"}
+ else:
+ data["zai"] = {"access_token": "mock-access-token"}
return Response(json.dumps({"data": data}), media_type="application/json")
@app.post("/api/auth/z/login")
diff --git a/tests/unit/test_constants.py b/tests/unit/test_constants.py
index 78bb687..756f9bb 100644
--- a/tests/unit/test_constants.py
+++ b/tests/unit/test_constants.py
@@ -21,6 +21,19 @@ def test_billing_base():
assert constants.BILLING_BASE == "https://zcode.z.ai/api/v1/zcode-plan"
+def test_oauth_realms():
+ """双区域表(对齐 codeapi providers/zcode/realms.py,官方客户端 3.14.x 实证):
+ CLI init/poll 端点 intl/cn 共用,provider 参数、业务 token 块名与业务域随区域。"""
+ assert constants.BIGMODEL_BUSINESS_ORIGIN == "https://bigmodel.cn"
+ assert constants.OAUTH_REALMS["intl"] == {
+ "oauth_provider": "zai", "business_origin": "https://api.z.ai",
+ }
+ assert constants.OAUTH_REALMS["cn"] == {
+ "oauth_provider": "bigmodel", "business_origin": "https://bigmodel.cn",
+ }
+ assert constants.OAUTH_PROVIDER_BLOCKS == {"intl": "zai", "cn": "bigmodel"}
+
+
def test_client_configs():
assert constants.CLIENT_CONFIGS_URL == "https://zcode.z.ai/api/v1/client/configs"
# 实测带 platform 参数上游 3001,只允许 app_version
@@ -88,6 +101,9 @@ def test_settings_upstream_defaults_from_constants():
assert settings.UPSTREAM["zai_fallback"] == constants.MESSAGES_URLS["zai_fallback"]
assert settings.UPSTREAM["bigmodel"] == constants.MESSAGES_URLS["bigmodel"]
assert settings.ZCODE_BILLING_BASE == constants.BILLING_BASE
+ # 兑换链业务域随区域:intl=api.z.ai,cn=bigmodel.cn
+ assert settings.ZAI_EXCHANGE_ORIGIN == constants.ZAI_API_ORIGIN
+ assert settings.BIGMODEL_EXCHANGE_ORIGIN == constants.BIGMODEL_BUSINESS_ORIGIN
# 网关常量(底座原值,回归锁定)
diff --git a/tests/unit/test_models_realm.py b/tests/unit/test_models_realm.py
new file mode 100644
index 0000000..b069ddf
--- /dev/null
+++ b/tests/unit/test_models_realm.py
@@ -0,0 +1,65 @@
+"""账号区域(realm)单元测试:
+
+- Account.create 的区域默认与纠偏(bigmodel 提供商 → cn;非法值回退 intl)
+- realm 随 to_dict/from_dict 持久化往返
+- public_view 暴露 realm
+- agent.build_request 回退 Key 通道按区域分流(intl → api.z.ai,cn → open.bigmodel.cn)
+"""
+
+from __future__ import annotations
+
+from app import settings
+from app.agent import build_request
+from app.models import Account
+
+
+def test_create_zai_defaults_intl():
+ acc = Account.create("zai", "a1", "h.payload.sig")
+ assert acc.realm == "intl"
+ assert acc.mode == "jwt" # 两段点 + zai 提供商 → JWT
+
+
+def test_create_bigmodel_defaults_cn():
+ acc = Account.create("bigmodel", "a2", "keyid.secret")
+ assert acc.realm == "cn"
+ assert acc.mode == "apiKey"
+
+
+def test_create_invalid_realm_falls_back():
+ assert Account.create("zai", "a3", "k", realm="xx").realm == "intl"
+ assert Account.create("bigmodel", "a4", "k", realm="xx").realm == "cn"
+
+
+def test_create_explicit_cn_keeps_jwt_mode():
+ acc = Account.create("zai", "a5", "h.cn.sig", realm="cn")
+ assert acc.realm == "cn"
+ assert acc.mode == "jwt"
+
+
+def test_realm_roundtrip_persistence():
+ acc = Account.create("zai", "a6", "h.cn.sig", realm="cn")
+ restored = Account.from_dict(acc.to_dict())
+ assert restored.realm == "cn"
+ # 旧数据(无 realm 字段)加载后回退默认 intl
+ legacy = {k: v for k, v in acc.to_dict().items() if k != "realm"}
+ assert Account.from_dict(legacy).realm == "intl"
+
+
+def test_public_view_exposes_realm():
+ view = Account.create("zai", "a7", "h.cn.sig", realm="cn").public_view()
+ assert view["realm"] == "cn"
+
+
+def _fallback_url(realm: str) -> str:
+ acc = Account.create("zai", "n", "keyid.secret", realm=realm)
+ url, headers, _ = build_request(acc, {"model": "GLM-5.3-Flash"}, None)
+ assert headers.get("x-api-key") == "keyid.secret"
+ return url
+
+
+def test_fallback_channel_intl_routes_to_zai():
+ assert _fallback_url("intl") == settings.UPSTREAM["zai_fallback"]
+
+
+def test_fallback_channel_cn_routes_to_bigmodel():
+ assert _fallback_url("cn") == settings.UPSTREAM["bigmodel"]