Skip to content

Commit 91679f2

Browse files
committed
refactor(provider): 四家 OpenAI 兼容渠道的 SSE 解析骨架合一
zen / kilo / officeace / codebuddy 的 parse_frame / parse_all_events / _event_from_payload / _payload 是同一份骨架,差异只有三处,全部参数化: - openai_chunk.payload_of(frame, label):帧 → JSON,label 只进报错文案。 - openai_chunk.openai_event(payload, *, reasoning_keys, error_event, error_as_string, usage):代表性事件,优先级 tool_calls > 正文 > 思考 > 错误 > usage > finish。 - openai_chunk.openai_events(...):合并帧补 usage/finish(丢 finish 会让 客户端永远收不到结束信号)。 不猜键,差异显式传参: - reasoning_keys:Zen/CodeBuddy/OfficeAce 用 reasoning_content,Kilo 用 reasoning。 - error_as_string:只有 OfficeAce 实测有 `{"error": "文本"}`。 - usage:Zen/CodeBuddy 的 credit=True 走各自的 _usage 薄包装(默认值会 静默吞掉 credit —— 已补 test_usage_parses_credit_when_present 锁住)。 - error_event:错误分类是各渠道私有码表,只注入回调。 顺带修掉 CodeBuddy 的重复解析:原先 parse_all_events 自己再 json.loads 一遍再调 parse_frame(后者又解析一次)。 四家 events.py 净减 177 行;openai_chunk 新增 114 行(含 docstring)。 保留 UpstreamProtocolViolation 的模块级 re-export,测试与 credential/ client 仍经各渠道 events 访问。 覆盖率 100%,3902 后端 + 644 前端全绿。
1 parent c72c377 commit 91679f2

7 files changed

Lines changed: 239 additions & 317 deletions

File tree

‎src/provider/codebuddy/events.py‎

Lines changed: 5 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -7,90 +7,29 @@
77

88
from __future__ import annotations
99

10-
import json
1110
from typing import Any
1211

1312
from ...engine.sse import SSEFrame
1413
from ...provider.base import (
1514
ErrKind,
1615
Event,
17-
EventKind,
18-
UpstreamProtocolViolation,
16+
UpstreamProtocolViolation, # noqa: F401 - credential/client 经本模块导入
1917
Usage,
2018
body_hint,
2119
business_codes,
2220
)
23-
from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai
21+
from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai
2422

2523

2624
def parse_frame(frame: SSEFrame) -> Event | None:
2725
"""单帧 → 中立事件;[DONE] 与无 delta 的帧返回 None。"""
28-
if not frame.data:
29-
return None
30-
if frame.data.strip() == "[DONE]":
31-
return None
32-
try:
33-
payload = json.loads(frame.data)
34-
except json.JSONDecodeError as error:
35-
raise UpstreamProtocolViolation("unparsable CodeBuddy SSE data") from error
36-
if not isinstance(payload, dict):
37-
raise UpstreamProtocolViolation("CodeBuddy SSE data is not an object")
38-
39-
choice = first_choice(payload)
40-
delta = choice.get("delta") if choice else None
41-
delta = delta if isinstance(delta, dict) else {}
42-
finish_reason = choice.get("finish_reason") if choice else None
43-
44-
tool_calls = delta.get("tool_calls")
45-
if isinstance(tool_calls, list):
46-
# 上游偶发噪声调用:无 name 且 arguments 为空/{}(客户端聚合后
47-
# 显示 "Tool not found")。正常分片块无 name 但带实际 arguments,
48-
# 必须保留。空名噪声整条丢弃。
49-
kept = [tc for tc in tool_calls if isinstance(tc, dict) and not is_blank_tool_call(tc)]
50-
if kept:
51-
return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept)
52-
53-
content = delta.get("content")
54-
if isinstance(content, str) and content:
55-
return Event(kind=EventKind.CONTENT, content=content)
56-
57-
reasoning = delta.get("reasoning_content")
58-
if isinstance(reasoning, str) and reasoning:
59-
return Event(kind=EventKind.REASONING, content=reasoning)
60-
61-
usage = payload.get("usage")
62-
if isinstance(usage, dict):
63-
return Event(kind=EventKind.USAGE, usage=_usage(usage))
64-
65-
if isinstance(finish_reason, str) and finish_reason:
66-
return Event(kind=EventKind.FINISH, finish_reason=finish_reason)
67-
return None
26+
payload = payload_of(frame, "CodeBuddy")
27+
return None if payload is None else openai_event(payload, usage=_usage)
6828

6929

7030
def parse_all_events(frame: SSEFrame) -> list[Event]:
7131
"""一帧拆成多个事件:带 usage 的收尾帧同时给出 finish_reason 与 usage。"""
72-
if not frame.data:
73-
return []
74-
if frame.data.strip() == "[DONE]":
75-
return []
76-
try:
77-
payload = json.loads(frame.data)
78-
except json.JSONDecodeError as error:
79-
raise UpstreamProtocolViolation("unparsable CodeBuddy SSE data") from error
80-
if not isinstance(payload, dict):
81-
raise UpstreamProtocolViolation("CodeBuddy SSE data is not an object")
82-
83-
events: list[Event] = [e for e in (parse_frame(frame),) if e is not None]
84-
has_usage = any(e.kind is EventKind.USAGE for e in events)
85-
has_finish = any(e.kind is EventKind.FINISH for e in events)
86-
usage = payload.get("usage")
87-
if isinstance(usage, dict) and not has_usage:
88-
events.append(Event(kind=EventKind.USAGE, usage=_usage(usage)))
89-
choice = first_choice(payload)
90-
finish_reason = choice.get("finish_reason") if choice else None
91-
if isinstance(finish_reason, str) and finish_reason and not has_finish:
92-
events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason))
93-
return events
32+
return openai_events(frame, "CodeBuddy", usage=_usage)
9433

9534

9635
def _usage(raw: dict[str, Any]) -> Usage:

‎src/provider/kilo/events.py‎

Lines changed: 9 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -20,105 +20,31 @@
2020

2121
from __future__ import annotations
2222

23-
import json
2423
from typing import Any
2524

2625
from ...engine.sse import SSEFrame
2726
from ...provider.base import (
2827
ErrKind,
2928
Event,
3029
EventKind,
31-
UpstreamProtocolViolation,
30+
UpstreamProtocolViolation, # noqa: F401 - 测试经本模块访问
3231
Usage,
3332
)
34-
from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai
33+
from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai
3534

3635

3736
def parse_frame(frame: SSEFrame) -> Event | None:
3837
"""单帧 → 单个代表性事件;`[DONE]` 与无内容帧返回 None。"""
39-
payload = _payload(frame)
40-
if payload is None:
41-
return None
42-
return _event_from_payload(payload)
43-
44-
45-
def _event_from_payload(payload: dict[str, Any]) -> Event | None:
46-
"""已解析 JSON → 代表性事件。
47-
48-
优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。
49-
"""
50-
choice = first_choice(payload)
51-
delta = choice.get("delta") if choice else None
52-
delta = delta if isinstance(delta, dict) else {}
53-
finish_reason = choice.get("finish_reason") if choice else None
54-
55-
tool_calls = delta.get("tool_calls")
56-
if isinstance(tool_calls, list):
57-
# 空名且空参的噪声调用(客户端聚合后显示 "Tool not found"):
58-
# 正常分片块无 name 但带实际 arguments,必须保留。
59-
kept = [tc for tc in tool_calls
60-
if isinstance(tc, dict) and not is_blank_tool_call(tc)]
61-
if kept:
62-
return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept)
63-
64-
content = delta.get("content")
65-
if isinstance(content, str) and content:
66-
return Event(kind=EventKind.CONTENT, content=content)
67-
68-
# Kilo 用 delta.reasoning(而非 reasoning_content)
69-
reasoning = delta.get("reasoning")
70-
if isinstance(reasoning, str) and reasoning:
71-
return Event(kind=EventKind.REASONING, content=reasoning)
72-
73-
error = payload.get("error")
74-
if isinstance(error, dict):
75-
return _error_event(error)
76-
77-
usage = payload.get("usage")
78-
if isinstance(usage, dict):
79-
return Event(kind=EventKind.USAGE, usage=_usage(usage))
80-
81-
if isinstance(finish_reason, str) and finish_reason:
82-
return Event(kind=EventKind.FINISH, finish_reason=finish_reason)
83-
return None
38+
payload = payload_of(frame, "Kilo")
39+
return None if payload is None else openai_event(
40+
payload, reasoning_keys=("reasoning",), error_event=_error_event,
41+
usage=_usage)
8442

8543

8644
def parse_all_events(frame: SSEFrame) -> list[Event]:
87-
"""一帧拆成多个事件:同一 chunk 可同时携带正文/思考与 finish_reason。
88-
89-
必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」
90-
这种合并帧会丢掉 finish(客户端永远收不到结束信号)。
91-
"""
92-
payload = _payload(frame)
93-
if payload is None:
94-
return []
95-
events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None]
96-
has_usage = any(e.kind is EventKind.USAGE for e in events)
97-
has_finish = any(e.kind is EventKind.FINISH for e in events)
98-
99-
usage = payload.get("usage")
100-
if isinstance(usage, dict) and not has_usage:
101-
events.append(Event(kind=EventKind.USAGE, usage=_usage(usage)))
102-
choice = first_choice(payload)
103-
finish_reason = choice.get("finish_reason") if choice else None
104-
if isinstance(finish_reason, str) and finish_reason and not has_finish:
105-
events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason))
106-
return events
107-
108-
109-
def _payload(frame: SSEFrame) -> dict[str, Any] | None:
110-
"""帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。"""
111-
if not frame.data:
112-
return None
113-
if frame.data.strip() == "[DONE]":
114-
return None
115-
try:
116-
payload = json.loads(frame.data)
117-
except json.JSONDecodeError as error:
118-
raise UpstreamProtocolViolation("unparsable Kilo SSE data") from error
119-
if not isinstance(payload, dict):
120-
raise UpstreamProtocolViolation("Kilo SSE data is not an object")
121-
return payload
45+
"""一帧拆成多个事件:同一 chunk 可同时携带正文/思考与 finish_reason。"""
46+
return openai_events(frame, "Kilo", reasoning_keys=("reasoning",),
47+
error_event=_error_event, usage=_usage)
12248

12349

12450
def _error_event(error: dict[str, Any]) -> Event:

‎src/provider/officeace/events.py‎

Lines changed: 7 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131

3232
from __future__ import annotations
3333

34-
import json
3534
import time
3635
from typing import Any
3736

@@ -44,7 +43,7 @@
4443
Usage,
4544
business_codes,
4645
)
47-
from ..openai_chunk import first_choice, is_blank_tool_call, usage_from_openai
46+
from ..openai_chunk import openai_event, openai_events, payload_of, usage_from_openai
4847

4948
# ---------------------------------------------------------------- 域与端点
5049

@@ -198,91 +197,15 @@ def classify_error_code(code: int | None, message: str = "") -> ErrKind:
198197

199198
def parse_frame(frame: SSEFrame) -> Event | None:
200199
"""单帧 → 代表性事件;`[DONE]` 与无内容帧返回 None。"""
201-
payload = _payload(frame)
202-
if payload is None:
203-
return None
204-
return _event_from_payload(payload)
200+
payload = payload_of(frame, "officeAce")
201+
return None if payload is None else openai_event(
202+
payload, error_event=_error_event, error_as_string=True)
205203

206204

207205
def parse_all_events(frame: SSEFrame) -> list[Event]:
208-
"""一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。
209-
210-
必须基于代表性事件再补 usage/finish,否则「最后一段正文 + finish」
211-
这种合并帧会丢掉 finish(客户端永远收不到结束信号)。
212-
"""
213-
payload = _payload(frame)
214-
if payload is None:
215-
return []
216-
events: list[Event] = [e for e in (_event_from_payload(payload),) if e is not None]
217-
has_usage = any(e.kind is EventKind.USAGE for e in events)
218-
has_finish = any(e.kind is EventKind.FINISH for e in events)
219-
220-
usage = payload.get("usage")
221-
if isinstance(usage, dict) and not has_usage:
222-
events.append(Event(kind=EventKind.USAGE, usage=_usage(usage)))
223-
choice = first_choice(payload)
224-
finish_reason = choice.get("finish_reason") if choice else None
225-
if isinstance(finish_reason, str) and finish_reason and not has_finish:
226-
events.append(Event(kind=EventKind.FINISH, finish_reason=finish_reason))
227-
return events
228-
229-
230-
def _event_from_payload(payload: dict[str, Any]) -> Event | None:
231-
"""已解析 JSON → 代表性事件。
232-
233-
优先级:tool_calls > 正文 > 思考 > 错误 > usage > finish。
234-
错误排在正文之后是有意的:伪 200 的第二帧常常「无正文 + error」,
235-
而带正文的帧不该被错误覆盖。
236-
"""
237-
choice = first_choice(payload)
238-
delta = choice.get("delta") if choice else None
239-
delta = delta if isinstance(delta, dict) else {}
240-
finish_reason = choice.get("finish_reason") if choice else None
241-
242-
tool_calls = delta.get("tool_calls")
243-
if isinstance(tool_calls, list):
244-
kept = [tc for tc in tool_calls
245-
if isinstance(tc, dict) and not is_blank_tool_call(tc)]
246-
if kept:
247-
return Event(kind=EventKind.TOOL_CALLS, tool_calls=kept)
248-
249-
content = delta.get("content")
250-
if isinstance(content, str) and content:
251-
return Event(kind=EventKind.CONTENT, content=content)
252-
253-
# 上游推理模型给 `reasoning_content`(OpenAI 兼容惯例)
254-
reasoning = delta.get("reasoning_content")
255-
if isinstance(reasoning, str) and reasoning:
256-
return Event(kind=EventKind.REASONING, content=reasoning)
257-
258-
error = payload.get("error")
259-
if isinstance(error, dict):
260-
return _error_event(error)
261-
if isinstance(error, str) and error:
262-
return _error_event({"message": error})
263-
264-
usage = payload.get("usage")
265-
if isinstance(usage, dict):
266-
return Event(kind=EventKind.USAGE, usage=_usage(usage))
267-
268-
if isinstance(finish_reason, str) and finish_reason:
269-
return Event(kind=EventKind.FINISH, finish_reason=finish_reason)
270-
return None
271-
272-
273-
def _payload(frame: SSEFrame) -> dict[str, Any] | None:
274-
"""帧 → JSON 对象;空帧 / `[DONE]` 返回 None,坏帧显式失败。"""
275-
if not frame.data:
276-
return None
277-
if frame.data.strip() == "[DONE]":
278-
return None
279-
try:
280-
payload = json.loads(frame.data)
281-
except json.JSONDecodeError as error:
282-
raise UpstreamProtocolViolation("unparsable officeAce SSE data") from error
283-
if not isinstance(payload, dict):
284-
raise UpstreamProtocolViolation("officeAce SSE data is not an object")
285-
return payload
206+
"""一帧拆成多个事件:同一 chunk 可同时携带正文与 finish_reason。"""
207+
return openai_events(frame, "officeAce", error_event=_error_event,
208+
error_as_string=True)
286209

287210

288211
def _error_event(error: dict[str, Any]) -> Event:

0 commit comments

Comments
 (0)