Skip to content

Commit ac0309c

Browse files
committed
fix(trae): 9074 不是限流而是设备标识格式——签到设备号改 16 位数字
TRAE 那个号连续签到失败 205 次(两天),日志一直刷「当前参与用户太多」。 此前把它当随机限流做软失败重试,所以永远好不了。 实测对照(同一账号、同一 token,只改 X-Device-Id): deviceId = hex32(登录 URL 那对 machine/device id) → 9074 限流 deviceId = 16 位纯数字 → code:0 success deviceId = 随机 hex32 → code:0 success deviceId = 空串 → 9004 参数错误 结论:9074 是设备标识格式不符(http 200 + 业务码),不是限流。长度/字符集 受限,具体校验规则未知(16 位数字与随机 hex 都过),实现按公开逆向的派生法取 16 位数字:sha256(uid[#genN]) mod 10^16 补零。派生值确定性,重试不会换号。 - ug_headers 新增 device_id 参数;默认取派生值,uid 为空时退回凭证自带 device_id - fetch_checkin_status / claim_checkin 支持 generation - checkin 支持 9074 时轮换代次重试(CHECKIN_DEVICE_GENERATIONS=3), 全部代次都失败才如实返回软失败(不误报成功、不无限重试) - machine_id 保持登录时那一对(非签到校验项,换掉可能与登录态不匹配) 验证:958 测试 / 100% 行+分支覆盖;ruff 通过。真实账号实测签到成功 (status.checked_in 由 false 变 true,重放 claim 幂等返回 code:0)。
1 parent 5bf1f19 commit ac0309c

6 files changed

Lines changed: 161 additions & 35 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
- 认证:浏览器登录 → 302 回调 `/authorize` → `ExchangeToken` → `GetUserInfo`
8484
- Token 刷新:`POST /cloudide/api/v3/trae/oauth/ExchangeToken`(refreshToken 轮换)
8585
- 签到:`/trae/api/v2/ug/checkin_credits/{status,claim}`;额度:`/trae/api/v2/pay/ide_user_ent_usage`
86+
- **签到设备头 `X-Device-Id` 必须是 16 位纯数字**:用登录 URL 那套 hex32 deviceId 调 claim 会稳定返回 `9074 当前参与用户太多`——看着像限流,实际是设备标识格式不符。实测对照:hex32 → 9074;16 位数字 / 随机 hex → `code:0`;空串 → 9004。本项目的取法是 sha256(uid[#genN]) mod 10^16 补零(确定性,重试不会换号),9074 时换代次再试
8687
- SSE 事件序列:`metadata` → `timing_cost` → `output`×N → `extra_info` → `token_usage` → `done`
8788
- `token_usage` 含缓存字段 `cache_read_input_tokens` / `cache_creation_input_tokens`(未命中为 0,非缺失),映射为统计的 `cached_tokens`;**无 per-request credit**
8889
- 错误码 `1005` = 权益不足;仅流式,非流式需聚合

‎TECHNICAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ coding2api/
6262
│ │ ├── trae/
6363
│ │ │ ├── client.py # SOLO 上游 + 双 httpx 客户端 + 额度探测
6464
│ │ │ ├── events.py # 自定义 SSE → Event
65-
│ │ │ ├── credential.py # 凭证解析(嵌套/扁平)+ 原子写回
65+
│ │ │ ├── credential.py # 凭证解析(嵌套/扁平)+ 原子写回 + 签到设备号派生
6666
│ │ │ └── callback.py # 登录 URL 构造 + 回调解析
6767
│ │ └── fixtures/ # fixture 清单
6868
│ │ ├── codebuddy/*.sse

‎src/provider/trae/client.py‎

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
new_machine_identity,
3232
parse_callback_url,
3333
)
34-
from .credential import TraeCredential, parse_credential
34+
from .credential import TraeCredential, checkin_device_id, parse_credential
3535
from .events import (
3636
AGENT_HOST,
3737
APP_ID,
@@ -50,6 +50,8 @@
5050
UpstreamProtocolViolation,
5151
)
5252

53+
# 9074 时的设备号轮换次数(同账号换不同的派生设备号,上游按 uid 记账不影响发放)
54+
CHECKIN_DEVICE_GENERATIONS = 3
5355
STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)
5456
SHORT_TIMEOUT = httpx.Timeout(30.0)
5557

@@ -230,12 +232,17 @@ def solo_headers(credential: TraeCredential, *, stream: bool = True) -> dict[str
230232
return headers
231233

232234

233-
def ug_headers(credential: TraeCredential) -> dict[str, str]:
234-
"""签到/积分端点头(api.trae.cn)。对照原实现 UgHeaders。
235+
def ug_headers(credential: TraeCredential, *, device_id: str = "") -> dict[str, str]:
236+
"""签到/积分端点头(api.trae.cn)。
235237
236-
论坛实测(topic/180147):设备头是签到 API 的隐藏必填项。
237-
缺 X-Machine-Id / X-Device-Id 时 status 直接返回 9004(参数错误);
238-
补齐后请求格式正确,只剩 9074(服务器繁忙/限流)。uid 对应 auth.userId。
238+
设备头是签到 API 的隐藏必填项(论坛实测 topic/180147):缺 X-Device-Id 时
239+
status 直接返回 9004(参数错误)。
240+
241+
**X-Device-Id 必须是 16 位纯数字**。用凭证自带的 deviceId(登录 URL 用的
242+
hex32)调 claim 会稳定得到 9074「当前参与用户太多」——这个码看起来像限流,
243+
实际是设备标识格式不符(实测:hex32 → 9074;16 位数字 / 随机 hex → code:0;
244+
空串 → 9004)。默认取 `checkin_device_id(uid)` 的派生值,调用方可在 9074 时
245+
传入轮换后的代次值。
239246
"""
240247
headers = {
241248
"Content-Type": "application/json",
@@ -244,10 +251,12 @@ def ug_headers(credential: TraeCredential) -> dict[str, str]:
244251
"Authorization": f"Cloud-IDE-JWT {credential.access_token}",
245252
"X-User-Region": "CN",
246253
}
254+
# machine_id 保持登录时那一对(它不是签到的校验项,换掉反而可能与登录态不匹配)
247255
if credential.machine_id:
248256
headers["X-Machine-Id"] = credential.machine_id
249-
if credential.device_id:
250-
headers["X-Device-Id"] = credential.device_id
257+
effective = device_id or checkin_device_id(credential.uid) or credential.device_id
258+
if effective:
259+
headers["X-Device-Id"] = effective
251260
if credential.uid:
252261
headers["X-Uid"] = credential.uid
253262
return headers
@@ -388,20 +397,24 @@ async def fetch_quota(self, credential: TraeCredential) -> Quota:
388397
used += float(pack_used) if isinstance(pack_used, (int, float)) else 0.0
389398
return Quota(remaining=max(0.0, limit - used), total=limit, probed_at=int(time.time()))
390399

391-
async def fetch_checkin_status(self, credential: TraeCredential) -> dict[str, Any]:
400+
async def fetch_checkin_status(self, credential: TraeCredential,
401+
*, generation: int = 0) -> dict[str, Any]:
392402
"""checkin_credits/status:checked_in / credits / enable。"""
393403
data = await self._post_json(
394-
f"{self.ug_host}{trae_events.EP_CHECKIN_STATUS}", {}, ug_headers(credential))
404+
f"{self.ug_host}{trae_events.EP_CHECKIN_STATUS}", {},
405+
ug_headers(credential, device_id=checkin_device_id(credential.uid, generation)))
395406
return {
396407
"checked_in": bool(data.get("checked_in")),
397408
"credits": data.get("credits"),
398409
"enable": bool(data.get("enable")),
399410
}
400411

401-
async def claim_checkin(self, credential: TraeCredential) -> dict[str, Any] | None:
412+
async def claim_checkin(self, credential: TraeCredential,
413+
*, generation: int = 0) -> dict[str, Any] | None:
402414
"""checkin_credits/claim:领取当日积分。"""
403415
return await self._post_json(
404-
f"{self.ug_host}{trae_events.EP_CHECKIN_CLAIM}", {}, ug_headers(credential))
416+
f"{self.ug_host}{trae_events.EP_CHECKIN_CLAIM}", {},
417+
ug_headers(credential, device_id=checkin_device_id(credential.uid, generation)))
405418

406419
async def refresh_token(self, credential: TraeCredential) -> TraeCredential:
407420
"""ExchangeToken;失败不改写原凭证字段。"""
@@ -603,25 +616,36 @@ async def checkin(self, credential_data: dict) -> CheckinResult:
603616
"""TRAE 签到:先查状态,未签且可签才领取。
604617
605618
上游没有独立的「已签到」错误码,status.checked_in 就是已签语义。
606-
claim 可能返回 HTTP 200 + 业务码非 0 的软失败(如实测 9074
607-
「当前参与用户太多」),必须解析 code,否则会误报成功。
619+
claim 可能返回 HTTP 200 + 业务码非 0 的软失败,必须解析 code,否则会误报成功。
620+
621+
9074「当前参与用户太多」**不是限流,而是设备标识格式不符**(实测:hex32
622+
→ 9074,16 位数字 → success)。默认已经用派生的 16 位数字设备号,所以
623+
正常不该再遇到;这里保留一次轮换代次重试,应对「派生值恰好在服务端侧不可用」
624+
这种未知情况(同账号换设备号不影响领取结果,上游按 uid 记账)。
608625
"""
609626
credential = TraeCredential.from_dict(credential_data)
610-
status = await self.client.fetch_checkin_status(credential)
611-
if status["checked_in"]:
612-
return CheckinResult(ok=True, credit=None, message="今天已签到",
613-
already_checked_in=True)
614-
if not status["enable"]:
615-
return CheckinResult(ok=False, message="当前账号不可签到")
616-
claim = await self.client.claim_checkin(credential) # _post_json 保证 dict
617-
code = claim.get("code")
618-
if code not in (0, None):
627+
for generation in range(CHECKIN_DEVICE_GENERATIONS):
628+
status = await self.client.fetch_checkin_status(
629+
credential, generation=generation)
630+
if status["checked_in"]:
631+
return CheckinResult(ok=True, credit=None, message="今天已签到",
632+
already_checked_in=True)
633+
if not status["enable"]:
634+
return CheckinResult(ok=False, message="当前账号不可签到")
635+
claim = await self.client.claim_checkin(credential, generation=generation)
636+
code = claim.get("code")
637+
if code in (0, None):
638+
# 领取成功 → 回查 status 带回当前积分总额
639+
latest = await self.client.fetch_checkin_status(
640+
credential, generation=generation)
641+
return CheckinResult(ok=True, credit=latest.get("credits"), code=0)
642+
if code == 9074 and generation + 1 < CHECKIN_DEVICE_GENERATIONS:
643+
continue # 换一个派生设备号再试(同账号其它代次)
619644
# 软失败:不抛异常(后台任务按失败计数,当日不封账,下轮重试)
620645
return CheckinResult(ok=False, code=int(code),
621646
message=str(claim.get("message") or "claim 失败"))
622-
# 领取成功 → 回查 status 带回当前积分总额
623-
latest = await self.client.fetch_checkin_status(credential)
624-
return CheckinResult(ok=True, credit=latest.get("credits"), code=0)
647+
# 循环必然在每轮内返回;保留兜底让静态检查满意
648+
return CheckinResult(ok=False, message="签到未完成") # pragma: no cover
625649

626650
# ------------------------------------------------- callback 轨道(Q17=C)
627651

‎src/provider/trae/credential.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,25 @@ def parse_credential(raw: bytes | dict[str, Any]) -> TraeCredential:
8585
if not credential.access_token:
8686
raise UpstreamProtocolViolation("credential missing accessToken")
8787
return credential
88+
89+
90+
# 签到设备标识:必须是 **16 位纯数字**。用 hex32(登录 URL 里的 machine/device id)
91+
# 调 claim 会稳定得到 9074「当前参与用户太多」——这个码看起来像限流,实际是设备
92+
# 标识格式不符(实测:hex32 → 9074,16 位数字 / 随机 hex → code:0;空串 → 9004)。
93+
# 派生规则来自公开逆向(trae2api-more 的 CheckinDeviceID):sha256(identity) 取模
94+
# 10^16 后补零成 16 位;generation > 0 时把代数并入摘要,用于 9074 时轮换设备。
95+
_CHECKIN_DEVICE_MODULUS = 10 ** 16
96+
97+
98+
def checkin_device_id(identity: str, generation: int = 0) -> str:
99+
"""派生签到的 X-Device-Id:16 位纯数字,由账号身份确定性地导出。
100+
101+
identity 为空时返回空串(调用方会退回凭证自带的 device_id)。
102+
"""
103+
import hashlib
104+
105+
if not identity:
106+
return ""
107+
material = identity if generation <= 0 else f"{identity}#gen{generation}"
108+
digest = hashlib.sha256(material.encode("utf-8")).digest()
109+
return f"{int.from_bytes(digest, 'big') % _CHECKIN_DEVICE_MODULUS:016d}"

‎tests/test_m15_operations.py‎

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3314,9 +3314,11 @@ async def test_trae_checkin_claim_soft_failure_and_success_paths():
33143314
from src.provider.trae.client import TraeClient, TraeProvider
33153315

33163316
seen: list[str] = []
3317+
device_ids: list[str] = []
33173318

33183319
async def handler(request: httpx.Request) -> httpx.Response:
33193320
seen.append(request.url.path)
3321+
device_ids.append(request.headers.get("x-device-id", ""))
33203322
if request.url.path.endswith("status"):
33213323
# 只有第二次(成功的)claim 之后才算已签
33223324
checked = len([p for p in seen if p.endswith("claim")]) >= 2
@@ -3335,12 +3337,78 @@ async def handler(request: httpx.Request) -> httpx.Response:
33353337
client = TraeClient(stream_client=_httpx.AsyncClient(transport=transport, timeout=None),
33363338
short_client=_httpx.AsyncClient(transport=transport, timeout=None))
33373339
provider = TraeProvider(client=client)
3338-
data = {"bearer_token": "t", "device_id": "d"}
3340+
data = {"bearer_token": "t", "device_id": "d", "uid": "u"}
33393341

3342+
# 单轮内 9074 会轮换代次设备号重试,所以一轮就应成功;期间用的都是 16 位数字
33403343
first = await provider.checkin(data)
3341-
assert not first.ok and first.code == 9074
3342-
assert "当前参与用户太多" in first.message
3344+
assert first.ok and first.code == 0 and first.credit == 200
3345+
# gen0: status + claim(9074);gen1: status + claim(成功) + 回查 status
3346+
assert len(device_ids) == 5
3347+
assert len(set(device_ids)) == 2 # 轮换过一次设备号
3348+
assert all(d.isdigit() and len(d) == 16 for d in device_ids)
33433349

3344-
second = await provider.checkin(data)
3345-
assert second.ok and second.code == 0 and second.credit == 200
33463350

3351+
3352+
async def test_trae_checkin_client_returns_soft_failure_after_all_generations():
3353+
"""所有代次设备号都 9074 → 如实返回软失败(不误报成功、不无限重试)。"""
3354+
from src.provider.trae.client import TraeClient
3355+
3356+
claims: list[str] = []
3357+
3358+
async def handler(request: httpx.Request) -> httpx.Response:
3359+
if request.url.path.endswith("status"):
3360+
return httpx.Response(200, json={"checked_in": False, "credits": 150,
3361+
"enable": True, "code": 0})
3362+
claims.append(request.headers.get("x-device-id", ""))
3363+
return httpx.Response(200, json={"code": 9074, "message": "当前参与用户太多"})
3364+
3365+
import httpx as _httpx
3366+
transport = _httpx.MockTransport(handler)
3367+
client = TraeClient(stream_client=_httpx.AsyncClient(transport=transport, timeout=None),
3368+
short_client=_httpx.AsyncClient(transport=transport, timeout=None))
3369+
from src.provider.trae.client import CHECKIN_DEVICE_GENERATIONS, TraeProvider
3370+
3371+
provider = TraeProvider(client=client)
3372+
result = await provider.checkin({"bearer_token": "t", "device_id": "d", "uid": "u"})
3373+
assert result.ok is False and result.code == 9074
3374+
assert "当前参与用户太多" in result.message
3375+
assert len(claims) == CHECKIN_DEVICE_GENERATIONS # 每代各试一次就收手
3376+
assert len(set(claims)) == CHECKIN_DEVICE_GENERATIONS # 每次换设备号
3377+
3378+
3379+
async def test_trae_checkin_not_enabled_short_circuits():
3380+
"""enable=False → 直接返回不可签到,不发 claim。"""
3381+
from src.provider.trae.client import TraeClient
3382+
3383+
claims: list[str] = []
3384+
3385+
async def handler(request: httpx.Request) -> httpx.Response:
3386+
if request.url.path.endswith("status"):
3387+
return httpx.Response(200, json={"checked_in": False, "credits": 0,
3388+
"enable": False, "code": 0})
3389+
claims.append(request.url.path)
3390+
return httpx.Response(200, json={"code": 0})
3391+
3392+
import httpx as _httpx
3393+
transport = _httpx.MockTransport(handler)
3394+
client = TraeClient(stream_client=_httpx.AsyncClient(transport=transport, timeout=None),
3395+
short_client=_httpx.AsyncClient(transport=transport, timeout=None))
3396+
from src.provider.trae.client import TraeProvider
3397+
3398+
result = await TraeProvider(client=client).checkin({"bearer_token": "t", "uid": "u"})
3399+
assert result.ok is False and result.message == "当前账号不可签到"
3400+
assert claims == []
3401+
3402+
3403+
def test_checkin_device_id_is_deterministic_and_generation_sensitive():
3404+
"""设备号派生:确定性、16 位数字、按代次变化、身份为空返回空串。"""
3405+
from src.provider.trae.credential import checkin_device_id
3406+
3407+
first = checkin_device_id("u1")
3408+
assert first == checkin_device_id("u1") # 确定性(重试不会换号)
3409+
assert first.isdigit() and len(first) == 16
3410+
assert checkin_device_id("u1", 0) == first # gen0 等价
3411+
assert checkin_device_id("u1", 1) != first # 代次不同则设备号不同
3412+
assert checkin_device_id("u2") != first
3413+
assert checkin_device_id("", 0) == ""
3414+
assert checkin_device_id("u1", -1) == first # 负代次按 0 处理

‎tests/test_m1a_gaps.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
TraeCredential,
2222
prepare_body,
2323
)
24+
from src.provider.trae.credential import checkin_device_id
2425
from src.provider.trae.events import UpstreamProtocolViolation
2526
from tests.conftest import SECRET
2627

@@ -247,10 +248,20 @@ def test_ug_headers_carry_region_and_auth():
247248
machine_id="m", uid="u"))
248249
assert headers["Authorization"] == "Cloud-IDE-JWT a"
249250
assert headers["X-User-Region"] == "CN"
250-
assert headers["X-Device-Id"] == "d"
251251
assert headers["X-Machine-Id"] == "m"
252252
assert headers["X-Uid"] == "u"
253253
assert "X-Cloudide-Token" not in headers
254+
# X-Device-Id 必须是 16 位纯数字:凭证自带的 hex32 会让 claim 返回 9074
255+
# (实测 hex32 → 9074,16 位数字 → code:0),所以默认用派生值而非 device_id
256+
assert headers["X-Device-Id"] == checkin_device_id("u", 0)
257+
assert headers["X-Device-Id"].isdigit() and len(headers["X-Device-Id"]) == 16
258+
# 显式传入时以传入值为准(供 9074 轮换代次使用)
259+
assert ug_headers(TraeCredential(access_token="a", uid="u"),
260+
device_id="123")["X-Device-Id"] == "123"
261+
# uid 为空 → 退回凭证自带的 device_id
262+
assert ug_headers(TraeCredential(access_token="a", device_id="d"))["X-Device-Id"] == "d"
263+
# 两者都空 → 不带该头(上游会返回 9004,不会静默当成有效请求)
264+
assert "X-Device-Id" not in ug_headers(TraeCredential(access_token="a"))
254265

255266

256267
async def test_client_aclose_is_idempotent():
@@ -700,8 +711,8 @@ def handler(request: httpx.Request) -> httpx.Response:
700711
# httpx 内部存储全小写
701712
assert headers.get("x-user-region") == "CN"
702713
assert headers.get("authorization") == "Cloud-IDE-JWT a"
703-
# 论坛实测(topic/180147):设备头是签到 API 的隐藏必填项
704-
assert headers.get("x-device-id") == "d"
714+
# 设备头是隐藏必填项;值必须是 16 位数字(hex32 会触发 9074)
715+
assert headers.get("x-device-id") == checkin_device_id("u", 0)
705716
assert headers.get("x-machine-id") == "m"
706717
assert headers.get("x-uid") == "u"
707718

0 commit comments

Comments
 (0)