Skip to content

Commit 0143e79

Browse files
committed
fix: 流内 4001 恢复跳过轮换语义(此前被误改为立即终止)
字节级 diff 两实现的请求(头/体)确认渠道特征已完全对齐后, 用户仍报 400。最终定位:上一轮给流式 event ERROR 加 INVALID 分流时 误写为 yield 错误帧 + return——TRAE 4001 后直接终止,不再尝试 CB; 非流式 UpstreamStreamError 分支同样会把 4001 错误地计入冷却。 - 流式:4001 → record_invalid + skip_provider + 轮换到下一上游; 全部拒绝才以 invalid_request 帧结束(与 HTTP 400 路径语义一致) - 非流式:UpstreamStreamError 的 INVALID 分支同样跳过 + 轮换 调度轨迹验证:TRAE 4001 → 跳过 → CB 成功接住,TRAE 凭证零冷却。 hy4-preview PI 风格 x6 全部成功。后端 638 测试 / 覆盖 100%。
1 parent b0dfa64 commit 0143e79

3 files changed

Lines changed: 91 additions & 8 deletions

File tree

‎src/engine/executor.py‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,18 +128,18 @@ async def stream(self, request: ChatRequest, *, username: str = "unknown"
128128
if event.kind is EventKind.ERROR:
129129
kind = _event_kind(event)
130130
if kind is ErrKind.INVALID:
131-
# 流内 4001 等参数/模型错误:换凭证没用,跳过该上游
131+
# 流内 4001 等参数/模型错误:换凭证没用,
132+
# 跳过该上游继续试其他上游;全部拒绝才以 400 结束
132133
logger.warning(
133134
"上游 %s 流内拒绝模型 %s(凭证 %s 跳过): code=%s %s",
134135
provider_id, target.model, credential_id,
135136
event.error_code, event.error_message)
136137
self._record_invalid(username, provider_id, credential_id,
137138
target.model, started, event)
138-
yield _error_frame(
139-
_reject_message(target.model, last_error,
140-
self._suggestions(target.model)),
141-
"invalid_request")
142-
return
139+
last_error = UpstreamStreamError(event)
140+
last_kind = ErrKind.INVALID
141+
self._skip_provider(provider_id, tried)
142+
break
143143
logger.warning(
144144
"上游 %s 流内错误(凭证 %s,kind=%s): code=%s %s",
145145
provider_id, credential_id, kind,
@@ -260,8 +260,19 @@ async def complete(self, request: ChatRequest, *, username: str = "unknown"
260260
result = aggregate(events, target.model)
261261
except UpstreamStreamError as error:
262262
kind = _event_kind(error.event)
263-
self._record_error(credential_id, kind)
264-
last_error = error
263+
if kind is ErrKind.INVALID:
264+
# 流内参数/模型错误:跳过该上游继续轮换,不冷却
265+
logger.warning(
266+
"上游 %s 流内拒绝模型 %s(凭证 %s 跳过): code=%s %s",
267+
provider_id, target.model, credential_id,
268+
error.event.error_code, error.event.error_message)
269+
self._record_invalid(username, provider_id, credential_id,
270+
target.model, started, error)
271+
last_error, last_kind = error, ErrKind.INVALID
272+
self._skip_provider(provider_id, tried)
273+
else:
274+
self._record_error(credential_id, kind)
275+
last_error = error
265276
else:
266277
self._deps.credentials.save_success(credential_id)
267278
usage = result.get("usage") or {}

‎src/provider/codebuddy/client.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ async def stream_chat(self, credential: CodeBuddyCredential, payload: dict[str,
8787
body = dict(payload)
8888
body["model"] = model
8989
body["stream"] = True
90+
# 官方 CLI 请求的标准特征字段;缺失会被渠道风控判定非官方调用(11128)
91+
body.setdefault("enable_thinking", True)
92+
stream_options = body.get("stream_options")
93+
body["stream_options"] = {
94+
**(stream_options if isinstance(stream_options, dict) else {}),
95+
"include_usage": True,
96+
}
9097
url = f"{self.endpoint}{EP_CHAT}"
9198
async with self._stream.stream(
9299
"POST", url, json=body, headers=build_headers(credential, self.endpoint),

‎tests/test_m1b_codebuddy.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,3 +1165,68 @@ class P:
11651165
"SELECT err_count, cooling_until FROM credentials").fetchone()
11661166
assert tuple(row) == (0, None) # 不冷却
11671167
assert any("流内拒绝模型" in r.getMessage() for r in caplog.records)
1168+
1169+
1170+
async def test_stream_chat_body_carries_cli_signature_fields():
1171+
"""官方 CLI 特征字段:enable_thinking / stream_options.include_usage。"""
1172+
import json as _json
1173+
1174+
def handler(_request: httpx.Request) -> httpx.Response:
1175+
return httpx.Response(200, text=fixture("chat-basic.sse"))
1176+
1177+
captured: dict = {}
1178+
1179+
def capture_handler(request: httpx.Request) -> httpx.Response:
1180+
captured["body"] = _json.loads(request.read())
1181+
return httpx.Response(200, text=fixture("chat-basic.sse"))
1182+
1183+
provider = CodeBuddyProvider(client=_client(capture_handler))
1184+
payload = {"messages": [{"role": "user", "content": "hi"}],
1185+
"stream_options": {"include_usage": False}}
1186+
events = [e async for e in provider.stream_chat({"bearer_token": "t"}, payload, "m")]
1187+
1188+
body = captured["body"]
1189+
assert body["enable_thinking"] is True # 缺失触发 11128 渠道风控
1190+
assert body["stream_options"]["include_usage"] is True
1191+
assert body["stream"] is True
1192+
assert body["model"] == "m"
1193+
assert events[-1].kind is EventKind.FINISH
1194+
1195+
1196+
async def test_trae_4001_falls_through_to_codebuddy(dual_repo, caplog):
1197+
"""TRAE 流内 4001 → 跳过 TRAE → CB 成功接住;TRAE 凭证不被冷却。"""
1198+
1199+
from src.provider.base import Event
1200+
1201+
class Trae4001:
1202+
id = "trae"
1203+
calls = 0
1204+
1205+
async def stream_chat(self, _cred, _payload, _model):
1206+
self.calls += 1
1207+
yield Event(kind=EventKind.ERROR, error_code=4001, error_message="param")
1208+
1209+
class CbOk:
1210+
id = "codebuddy"
1211+
calls = 0
1212+
1213+
async def stream_chat(self, _cred, _payload, _model):
1214+
self.calls += 1
1215+
yield Event(kind=EventKind.CONTENT, content="ok")
1216+
yield Event(kind=EventKind.FINISH, finish_reason="stop")
1217+
1218+
repo, db = dual_repo
1219+
repo.add(provider="codebuddy", credential_data={"bearer_token": "cb"})
1220+
repo.add(provider="trae", credential_data={"accessToken": "tr"})
1221+
db.connect().execute("UPDATE credentials SET pinned = 1 WHERE provider = 'trae'")
1222+
trae, cb = Trae4001(), CbOk()
1223+
executor = Executor(ExecutorDeps(
1224+
providers={"trae": trae, "codebuddy": cb}, credentials=repo,
1225+
scheduler=Scheduler(), default_model="m"))
1226+
1227+
result = await executor.complete(_request("m"), username="u")
1228+
assert result["choices"][0]["message"]["content"] == "ok"
1229+
assert trae.calls == 1 and cb.calls == 1
1230+
rows = [tuple(r) for r in db.connect().execute(
1231+
"SELECT err_count, cooling_until FROM credentials ORDER BY provider")]
1232+
assert rows == [(0, None), (0, None)] # TRAE 4001 不冷却

0 commit comments

Comments
 (0)