返回 JoyAI-Echo
1 """Parse Responses API SSE streams and SDK response objects."""
2
3 from __future__ import annotations
4
5 import json
6 from collections.abc import Awaitable, Callable
7 from typing import Any, AsyncGenerator
8
9 import httpx
10 import json_repair
11 from loguru import logger
12
13 from nanobot.providers.base import LLMResponse, ToolCallRequest
14
15 FINISH_REASON_MAP = {
16 "completed": "stop",
17 "incomplete": "length",
18 "failed": "error",
19 "cancelled": "error",
20 }
21
22
23 def map_finish_reason(status: str | None) -> str:
24 """Map a Responses API status string to a Chat-Completions-style finish_reason."""
25 return FINISH_REASON_MAP.get(status or "completed", "stop")
26
27
28 async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
29 """Yield parsed JSON events from a Responses API SSE stream."""
30 buffer: list[str] = []
31
32 def _flush() -> dict[str, Any] | None:
33 data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")]
34 buffer.clear()
35 if not data_lines:
36 return None
37 data = "\n".join(data_lines).strip()
38 if not data or data == "[DONE]":
39 return None
40 try:
41 return json.loads(data)
42 except Exception:
43 logger.warning("Failed to parse SSE event JSON: {}", data[:200])
44 return None
45
46 async for line in response.aiter_lines():
47 if line == "":
48 if buffer:
49 event = _flush()
50 if event is not None:
51 yield event
52 continue
53 buffer.append(line)
54
55 # Flush any remaining buffer at EOF (#10)
56 if buffer:
57 event = _flush()
58 if event is not None:
59 yield event
60
61
62 async def consume_sse(
63 response: httpx.Response,
64 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
65 ) -> tuple[str, list[ToolCallRequest], str]:
66 """Consume a Responses API SSE stream into ``(content, tool_calls, finish_reason)``."""
67 content = ""
68 tool_calls: list[ToolCallRequest] = []
69 tool_call_buffers: dict[str, dict[str, Any]] = {}
70 finish_reason = "stop"
71
72 async for event in iter_sse(response):
73 event_type = event.get("type")
74 if event_type == "response.output_item.added":
75 item = event.get("item") or {}
76 if item.get("type") == "function_call":
77 call_id = item.get("call_id")
78 if not call_id:
79 continue
80 tool_call_buffers[call_id] = {
81 "id": item.get("id") or "fc_0",
82 "name": item.get("name"),
83 "arguments": item.get("arguments") or "",
84 }
85 elif event_type == "response.output_text.delta":
86 delta_text = event.get("delta") or ""
87 content += delta_text
88 if on_content_delta and delta_text:
89 await on_content_delta(delta_text)
90 elif event_type == "response.function_call_arguments.delta":
91 call_id = event.get("call_id")
92 if call_id and call_id in tool_call_buffers:
93 tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
94 elif event_type == "response.function_call_arguments.done":
95 call_id = event.get("call_id")
96 if call_id and call_id in tool_call_buffers:
97 tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
98 elif event_type == "response.output_item.done":
99 item = event.get("item") or {}
100 if item.get("type") == "function_call":
101 call_id = item.get("call_id")
102 if not call_id:
103 continue
104 buf = tool_call_buffers.get(call_id) or {}
105 args_raw = buf.get("arguments") or item.get("arguments") or "{}"
106 try:
107 args = json.loads(args_raw)
108 except Exception:
109 logger.warning(
110 "Failed to parse tool call arguments for '{}': {}",
111 buf.get("name") or item.get("name"),
112 args_raw[:200],
113 )
114 args = json_repair.loads(args_raw)
115 if not isinstance(args, dict):
116 args = {"raw": args_raw}
117 tool_calls.append(
118 ToolCallRequest(
119 id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
120 name=buf.get("name") or item.get("name") or "",
121 arguments=args,
122 )
123 )
124 elif event_type == "response.completed":
125 status = (event.get("response") or {}).get("status")
126 finish_reason = map_finish_reason(status)
127 elif event_type in {"error", "response.failed"}:
128 detail = event.get("error") or event.get("message") or event
129 raise RuntimeError(f"Response failed: {str(detail)[:500]}")
130
131 return content, tool_calls, finish_reason
132
133
134 def parse_response_output(response: Any) -> LLMResponse:
135 """Parse an SDK ``Response`` object into an ``LLMResponse``."""
136 if not isinstance(response, dict):
137 dump = getattr(response, "model_dump", None)
138 response = dump() if callable(dump) else vars(response)
139
140 output = response.get("output") or []
141 content_parts: list[str] = []
142 tool_calls: list[ToolCallRequest] = []
143 reasoning_content: str | None = None
144
145 for item in output:
146 if not isinstance(item, dict):
147 dump = getattr(item, "model_dump", None)
148 item = dump() if callable(dump) else vars(item)
149
150 item_type = item.get("type")
151 if item_type == "message":
152 for block in item.get("content") or []:
153 if not isinstance(block, dict):
154 dump = getattr(block, "model_dump", None)
155 block = dump() if callable(dump) else vars(block)
156 if block.get("type") == "output_text":
157 content_parts.append(block.get("text") or "")
158 elif item_type == "reasoning":
159 for s in item.get("summary") or []:
160 if not isinstance(s, dict):
161 dump = getattr(s, "model_dump", None)
162 s = dump() if callable(dump) else vars(s)
163 if s.get("type") == "summary_text" and s.get("text"):
164 reasoning_content = (reasoning_content or "") + s["text"]
165 elif item_type == "function_call":
166 call_id = item.get("call_id") or ""
167 item_id = item.get("id") or "fc_0"
168 args_raw = item.get("arguments") or "{}"
169 try:
170 args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
171 except Exception:
172 logger.warning(
173 "Failed to parse tool call arguments for '{}': {}",
174 item.get("name"),
175 str(args_raw)[:200],
176 )
177 args = json_repair.loads(args_raw) if isinstance(args_raw, str) else args_raw
178 if not isinstance(args, dict):
179 args = {"raw": args_raw}
180 tool_calls.append(ToolCallRequest(
181 id=f"{call_id}|{item_id}",
182 name=item.get("name") or "",
183 arguments=args if isinstance(args, dict) else {},
184 ))
185
186 usage_raw = response.get("usage") or {}
187 if not isinstance(usage_raw, dict):
188 dump = getattr(usage_raw, "model_dump", None)
189 usage_raw = dump() if callable(dump) else vars(usage_raw)
190 usage = {}
191 if usage_raw:
192 usage = {
193 "prompt_tokens": int(usage_raw.get("input_tokens") or 0),
194 "completion_tokens": int(usage_raw.get("output_tokens") or 0),
195 "total_tokens": int(usage_raw.get("total_tokens") or 0),
196 }
197
198 status = response.get("status")
199 finish_reason = map_finish_reason(status)
200
201 return LLMResponse(
202 content="".join(content_parts) or None,
203 tool_calls=tool_calls,
204 finish_reason=finish_reason,
205 usage=usage,
206 reasoning_content=reasoning_content if isinstance(reasoning_content, str) else None,
207 )
208
209
210 async def consume_sdk_stream(
211 stream: Any,
212 on_content_delta: Callable[[str], Awaitable[None]] | None = None,
213 ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
214 """Consume an SDK async stream from ``client.responses.create(stream=True)``."""
215 content = ""
216 tool_calls: list[ToolCallRequest] = []
217 tool_call_buffers: dict[str, dict[str, Any]] = {}
218 finish_reason = "stop"
219 usage: dict[str, int] = {}
220 reasoning_content: str | None = None
221
222 async for event in stream:
223 event_type = getattr(event, "type", None)
224 if event_type == "response.output_item.added":
225 item = getattr(event, "item", None)
226 if item and getattr(item, "type", None) == "function_call":
227 call_id = getattr(item, "call_id", None)
228 if not call_id:
229 continue
230 tool_call_buffers[call_id] = {
231 "id": getattr(item, "id", None) or "fc_0",
232 "name": getattr(item, "name", None),
233 "arguments": getattr(item, "arguments", None) or "",
234 }
235 elif event_type == "response.output_text.delta":
236 delta_text = getattr(event, "delta", "") or ""
237 content += delta_text
238 if on_content_delta and delta_text:
239 await on_content_delta(delta_text)
240 elif event_type == "response.function_call_arguments.delta":
241 call_id = getattr(event, "call_id", None)
242 if call_id and call_id in tool_call_buffers:
243 tool_call_buffers[call_id]["arguments"] += getattr(event, "delta", "") or ""
244 elif event_type == "response.function_call_arguments.done":
245 call_id = getattr(event, "call_id", None)
246 if call_id and call_id in tool_call_buffers:
247 tool_call_buffers[call_id]["arguments"] = getattr(event, "arguments", "") or ""
248 elif event_type == "response.output_item.done":
249 item = getattr(event, "item", None)
250 if item and getattr(item, "type", None) == "function_call":
251 call_id = getattr(item, "call_id", None)
252 if not call_id:
253 continue
254 buf = tool_call_buffers.get(call_id) or {}
255 args_raw = buf.get("arguments") or getattr(item, "arguments", None) or "{}"
256 try:
257 args = json.loads(args_raw)
258 except Exception:
259 logger.warning(
260 "Failed to parse tool call arguments for '{}': {}",
261 buf.get("name") or getattr(item, "name", None),
262 str(args_raw)[:200],
263 )
264 args = json_repair.loads(args_raw)
265 if not isinstance(args, dict):
266 args = {"raw": args_raw}
267 tool_calls.append(
268 ToolCallRequest(
269 id=f"{call_id}|{buf.get('id') or getattr(item, 'id', None) or 'fc_0'}",
270 name=buf.get("name") or getattr(item, "name", None) or "",
271 arguments=args,
272 )
273 )
274 elif event_type == "response.completed":
275 resp = getattr(event, "response", None)
276 status = getattr(resp, "status", None) if resp else None
277 finish_reason = map_finish_reason(status)
278 if resp:
279 usage_obj = getattr(resp, "usage", None)
280 if usage_obj:
281 usage = {
282 "prompt_tokens": int(getattr(usage_obj, "input_tokens", 0) or 0),
283 "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
284 "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
285 }
286 for out_item in getattr(resp, "output", None) or []:
287 if getattr(out_item, "type", None) == "reasoning":
288 for s in getattr(out_item, "summary", None) or []:
289 if getattr(s, "type", None) == "summary_text":
290 text = getattr(s, "text", None)
291 if text:
292 reasoning_content = (reasoning_content or "") + text
293 elif event_type in {"error", "response.failed"}:
294 detail = getattr(event, "error", None) or getattr(event, "message", None) or event
295 raise RuntimeError(f"Response failed: {str(detail)[:500]}")
296
297 return content, tool_calls, finish_reason, usage, reasoning_content
298
298 lines PYTHON