File size: 11,514 Bytes
fa1140b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""anuma.ai Responses SSE 事件 → IREvent(原生协议与 OpenAI Responses 一致)。

上游:``POST portal.anuma.ai/api/v1/responses``,返回标准 OpenAI Responses SSE:
``response.created`` / ``response.output_text.delta`` / ``response.completed`` 等。
正文增量由 ``response.output_text.delta`` 产出;思维链摘要增量由
``response.reasoning_summary_text.delta`` 产出(必须产出 kind="thinking")。

**原生 function calling**:上游接受客户端 tools 并原生返回 function_call 事件
(``response.output_item.added`` type=function_call + ``response.function_call_arguments.delta/done``)。
本 parser 把 function_call 统一转成 ``<tool_call>{...}</tool_call>`` 围栏文本
(携带上游 call_id),走宿主既有解析链路(parse_tool_calls / ToolCallStreamParser)。
"""
from __future__ import annotations

import json
from typing import Any

from app.events import IREvent, Usage
from app.upstream.base import EventParser


class DefaultParser(EventParser):
    def __init__(self) -> None:
        # 流式 function_call 状态:call_id -> {name, arguments(字符串累积)}
        self._pending_calls: dict[str, dict[str, str]] = {}
        # 本轮是否已通过 output_text.delta 吐过正文(用于 done 去重)
        self._saw_text_delta = False

    def parse(self, raw: Any) -> list[IREvent]:
        """raw 为单个 SSE 事件 dict(``{"type": ..., ...}``),返回 0..n 个 IREvent。"""
        if not isinstance(raw, dict):
            return []
        etype = raw.get("type") or ""
        delta = raw.get("delta") or {}
        if not isinstance(delta, dict):
            delta = {}

        events: list[IREvent] = []

        # ---- 新响应开始:清本轮状态(parser 跨请求复用)----
        if etype == "response.created":
            self._pending_calls.clear()
            self._saw_text_delta = False
            return events

        # ---- function_call 开始(output_item.added 里 type=function_call)----
        if etype == "response.output_item.added":
            item = raw.get("item") or {}
            if isinstance(item, dict) and item.get("type") == "function_call":
                call_id = str(item.get("call_id") or item.get("id") or "")
                if call_id:
                    self._pending_calls[call_id] = {
                        "name": str(item.get("name") or ""),
                        "arguments": str(item.get("arguments") or ""),
                    }
            return events

        # ---- function_call arguments 增量 ----
        if etype == "response.function_call_arguments.delta":
            # delta 字段是 arguments 的字符串增量
            for call_id, pending in self._pending_calls.items():
                pending["arguments"] += str(delta if isinstance(delta, str) else delta.get("delta") or "")
            return events

        # ---- function_call arguments 完整 ----
        if etype == "response.function_call_arguments.done":
            for call_id, pending in self._pending_calls.items():
                fence = _function_call_fence(pending["name"], pending["arguments"], call_id)
                if fence:
                    events.append(IREvent(kind="text", text=fence))
            self._pending_calls.clear()
            return events

        # ---- 思维链摘要增量(独立事件,kimi 等推理模型的思考链)----
        # 2026-08-08 实测(HAR「聊天」kimi/kimi-k3):kimi 的思考是**独立事件**
        # response.reasoning_summary_text.delta(×128),结构:
        #   {"delta": {"OfString": "We", "OfResponseReasoningSummaryDeltaEventDelta": "We"},
        #    "type": "response.reasoning_summary_text.delta", ...}
        # 两个字段都是**字符串**(GPT 系模型在 output_text.delta 里带的则是 dict)。
        # 不解析 → 思考全丢 → 客户端等待期空白 + 正文瞬间喷出 = "一大段突然出现"。
        if etype == "response.reasoning_summary_text.delta":
            t = delta.get("OfString") if isinstance(delta, dict) else None
            if not t:
                s = delta.get("OfResponseReasoningSummaryDeltaEventDelta") if isinstance(delta, dict) else None
                if isinstance(s, dict):
                    t = s.get("text") or ""
                else:
                    t = s or ""
            if t:
                events.append(IREvent(kind="thinking", thinking=str(t)))
            return events

        # ---- 正文增量 ----
        # 事件 type 是唯一语义来源:output_text.delta 只产出正文。
        # 某些上游 union 序列化会把同一个正文 token 同时写入 OfString 和
        # OfResponseReasoningSummaryDeltaEventDelta;后者无论字符串还是 dict 都不能在
        # 此事件中再解释成 thinking,否则客户端会在正文后生成逐 token 的碎片思考卡。
        if etype == "response.output_text.delta":
            text = delta.get("OfString") if isinstance(delta, dict) else ""
            if isinstance(text, dict):  # 部分模型带 {"text": ...} 结构
                text = text.get("text") or ""
            if text:
                self._saw_text_delta = True
                events.append(IREvent(kind="text", text=str(text)))
            return events

        # ---- 输出完整文本(生图时图片 markdown 只在此事件的**顶层 text** 里)----
        # 2026-08-08 实测(debug_parser.py):生图响应里 /media/ 图片 URL(如
        # "![可爱的小猫咪写真](https://portal.anuma.ai/api/v1/media/media/...)")只
        # 出现在 response.output_text.done 的**事件顶层 text 字段**(与 delta 同层,
        # 不是 delta 里)。delta 事件只带思维链摘要,没有图片。不处理顶层 text
        # 会把图片链接整个吞掉(此前生图只有 function_call 空壳、无媒体输出)。
        #
        # 聊天路径:delta 已流式吐完正文,done 再吐整段会重复(客户端先逐字再整段 dump)。
        # 去重:已见 text delta 且 done 不含 media URL → 跳过;生图 / 无 delta 兜底照发。
        if etype == "response.output_text.done":
            text = raw.get("text")
            if isinstance(text, str) and text:
                if self._saw_text_delta and "portal.anuma.ai/api/v1/media" not in text:
                    return events
                events.append(IREvent(kind="text", text=text))
            return events

        # ---- 完成事件:非流式 function_call 在 output 里;usage + finish ----
        # 生图时图片签名 URL 在 response.tool_call_events[].output(JSON 字符串,
        # 含 output_images[].url)——上游把生图结果当作工具执行结果回传。
        # 2026-08-08 实测(debug_parser.py):流式响应的 output_text 是空壳
        # ({"type":"output_text"}),图片 markdown 只出现在 tool_call_events。
        if etype == "response.completed":
            resp = raw.get("response") or {}
            if isinstance(resp, dict):
                for item in resp.get("output") or []:
                    if isinstance(item, dict) and item.get("type") == "function_call":
                        fence = _function_call_fence(
                            str(item.get("name") or ""),
                            item.get("arguments") or "{}",
                            str(item.get("call_id") or item.get("id") or ""),
                        )
                        if fence:
                            events.append(IREvent(kind="text", text=fence))
                # 生图结果:tool_call_events 里带 output 的调用 → 把图片 URL 以
                # markdown 形式输出,客户端可见真实图片(不产出空壳调用围栏)。
                for tce in resp.get("tool_call_events") or []:
                    if not isinstance(tce, dict):
                        continue
                    output = tce.get("output")
                    if not isinstance(output, str) or not output:
                        continue
                    try:
                        out = json.loads(output)
                    except json.JSONDecodeError:
                        continue
                    urls = []
                    for img in out.get("output_images") or []:
                        u = img.get("url") if isinstance(img, dict) else None
                        if u:
                            urls.append(u)
                    if urls:
                        text = "\n".join(f"![generated image]({u})" for u in urls)
                        events.append(IREvent(kind="text", text=text))
            usage = self._parse_usage(raw)
            if usage:
                events.append(IREvent(kind="finish", usage_delta=usage, finish_reason="stop"))
            else:
                events.append(IREvent(kind="finish", finish_reason="stop"))
            return events

        # ---- 结尾独立 usage 事件(无 type 或 type 为 usage 的收尾事件)----
        if etype == "" or etype == "response.usage":
            usage = self._parse_usage(raw)
            if usage:
                events.append(IREvent(kind="finish", usage_delta=usage, finish_reason="stop"))
            return events

        # ---- 失败事件 ----
        if etype == "response.failed":
            err = raw.get("error") or raw.get("message") or "upstream response failed"
            msg = err.get("message") if isinstance(err, dict) else str(err)
            events.append(IREvent(kind="error", error=msg or "upstream response failed"))
            return events

        return events

    @staticmethod
    def _parse_usage(raw: dict[str, Any]) -> Usage | None:
        usage = raw.get("usage")
        if not isinstance(usage, dict):
            return None
        # anuma 返回 prompt_tokens/completion_tokens/total_tokens/cost_micro_usd/credits_used
        # (OpenAI 标准为 input_tokens/output_tokens)
        input_tokens = usage.get("input_tokens") or usage.get("prompt_tokens") or 0
        output_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or 0
        if not input_tokens and not output_tokens:
            return None
        details = usage.get("output_tokens_details") or {}
        thinking = details.get("reasoning_tokens") if isinstance(details, dict) else 0
        cached = 0
        in_details = usage.get("input_tokens_details") or {}
        if isinstance(in_details, dict):
            cached = in_details.get("cached_tokens") or 0
        return Usage(
            input_tokens=int(input_tokens),
            output_tokens=int(output_tokens),
            thinking_tokens=int(thinking or 0),
            cached_tokens=int(cached or 0),
            model=str(usage.get("model") or "") or None,
            provider="anuma",
        )


def _function_call_fence(name: str, arguments: str, call_id: str) -> str | None:
    """function_call → ``<tool_call>{"name","arguments","id"}</tool_call>`` 围栏文本。"""
    if not name:
        return None
    try:
        args = json.loads(arguments) if arguments.strip() else {}
    except json.JSONDecodeError:
        args = {"value": arguments}
    if not isinstance(args, dict):
        args = {"value": args}
    return (
        f"<tool_call>{json.dumps({'name': name, 'arguments': args, 'id': call_id},
                                 ensure_ascii=False)}</tool_call>"
    )