feat: support /v1/responses#1384
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new HTTP POST endpoint /v1/responses and its implementation in lightllm/server/api_responses.py to support the OpenAI Responses API, translating requests and responses to and from the standard Chat Completions format for both streaming and non-streaming modes. The review feedback highlights several critical issues: potential JSON parsing failures in SSE streaming due to arbitrary chunk boundaries, data corruption during parallel tool calls from using a single active item tracker, incorrect extraction of nested json_schema fields, delayed start signals for empty content deltas, potential AttributeError exceptions from missing type checks on input parts, and incorrect completion marking of unfinished items upon stream failure.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async for raw_chunk in openai_body_iterator: | ||
| if not raw_chunk: | ||
| continue | ||
| if isinstance(raw_chunk, (bytes, bytearray)): | ||
| raw_chunk = raw_chunk.decode("utf-8", errors="replace") | ||
| for line in raw_chunk.split("\n"): | ||
| line = line.strip() |
There was a problem hiding this comment.
在处理 SSE 流时,直接对 raw_chunk 进行 split("\n") 存在严重的安全隐患和正确性问题。因为 TCP/HTTP 传输中的数据块(chunk)边界是任意的,一个完整的 SSE data: ... 行可能会被拆分到两个不同的 raw_chunk 中。
如果直接按 \n 切分,会导致前一个 chunk 的末尾部分和后一个 chunk 的开头部分被当作独立的行处理,从而导致 JSON 解析失败(抛出异常或被忽略),丢失关键的生成内容。
建议引入一个行缓冲区(line buffer)来拼接不完整的行,确保只有在读取到完整的换行符时才进行解析。
| async for raw_chunk in openai_body_iterator: | |
| if not raw_chunk: | |
| continue | |
| if isinstance(raw_chunk, (bytes, bytearray)): | |
| raw_chunk = raw_chunk.decode("utf-8", errors="replace") | |
| for line in raw_chunk.split("\n"): | |
| line = line.strip() | |
| buffer = "" | |
| async for raw_chunk in openai_body_iterator: | |
| if not raw_chunk: | |
| continue | |
| if isinstance(raw_chunk, (bytes, bytearray)): | |
| raw_chunk = raw_chunk.decode("utf-8", errors="replace") | |
| buffer += raw_chunk | |
| while "\n" in buffer: | |
| line, buffer = buffer.split("\n", 1) | |
| line = line.strip() |
| for tc in delta.get("tool_calls") or []: | ||
| fn = tc.get("function") or {} | ||
| if fn.get("name"): | ||
| item = { | ||
| "type": "function_call", | ||
| "id": f"fc_{uuid.uuid4().hex}", | ||
| "call_id": tc.get("id") or f"call_{uuid.uuid4().hex[:24]}", | ||
| "name": fn["name"], | ||
| "arguments": "", | ||
| "status": "in_progress", | ||
| } | ||
| for e in open_item("function_call", item): | ||
| yield e | ||
| args = fn.get("arguments") | ||
| if args and current is not None and current[0] == "function_call": | ||
| item = current[1] | ||
| item["arguments"] += args | ||
| yield event( | ||
| "response.function_call_arguments.delta", | ||
| {"item_id": item["id"], "output_index": output_index, "delta": args}, | ||
| ) |
There was a problem hiding this comment.
当前的流式处理实现中,使用了一个单一的 current 变量来跟踪当前正在生成的输出项(如 message、reasoning 或 function_call)。
然而,现代大语言模型(如 GPT-4o、Claude 3.5 等)支持并行工具调用(Parallel Tool Calls)。在流式输出中,多个工具调用的 delta 数据可能会交错(interleaved)返回,或者按 index 标识并行传输。
如果使用单一的 current 变量,当收到另一个工具调用的 delta 时,会触发 open_item 从而强制关闭前一个工具调用,导致前一个工具调用被提前标记为 completed,并且后续到达的参数会被错误地追加到新的工具调用中,造成数据混乱。
建议使用一个字典(例如 active_function_calls: Dict[int, Dict[str, Any]])来根据 index 跟踪所有活跃的并行工具调用,并在流结束时统一关闭它们。
| if ftype == "json_schema": | ||
| return { | ||
| "type": "json_schema", | ||
| "json_schema": { | ||
| "name": fmt.get("name", "response"), | ||
| "description": fmt.get("description"), | ||
| "schema": fmt.get("schema"), | ||
| "strict": fmt.get("strict"), | ||
| }, | ||
| } |
There was a problem hiding this comment.
在 _text_format_to_response_format 函数中,当 ftype == "json_schema" 时,代码直接从 fmt(即 format 字典)中获取 name、description、schema 和 strict。然而,根据 OpenAI Responses API 的规范,json_schema 的具体定义是嵌套在 format.json_schema 对象中的。
因此,fmt(body["text"]["format"])中并不直接包含 name、schema 等字段,它们存在于 fmt.get("json_schema") 中。这会导致提取出来的 schema、name 和 strict 均为 None,从而使结构化输出(Structured Outputs)功能失效。
建议从 fmt.get("json_schema") 中提取这些字段。
| if ftype == "json_schema": | |
| return { | |
| "type": "json_schema", | |
| "json_schema": { | |
| "name": fmt.get("name", "response"), | |
| "description": fmt.get("description"), | |
| "schema": fmt.get("schema"), | |
| "strict": fmt.get("strict"), | |
| }, | |
| } | |
| if ftype == "json_schema": | |
| js = fmt.get("json_schema") or {} | |
| return { | |
| "type": "json_schema", | |
| "json_schema": { | |
| "name": js.get("name", "response"), | |
| "description": js.get("description"), | |
| "schema": js.get("schema"), | |
| "strict": js.get("strict"), | |
| }, | |
| } |
| content_piece = delta.get("content") | ||
| if content_piece: | ||
| if current is None or current[0] != "message": | ||
| item = { | ||
| "type": "message", | ||
| "id": f"msg_{uuid.uuid4().hex}", | ||
| "status": "in_progress", | ||
| "role": "assistant", | ||
| "content": [{"type": "output_text", "text": "", "annotations": []}], | ||
| } | ||
| for e in open_item("message", item): | ||
| yield e |
There was a problem hiding this comment.
在处理 content_piece 时,代码使用 if content_piece: 来判断并初始化消息项。然而,在流式输出的第一个 chunk 中,模型返回的 content 往往是一个空字符串 ""(用于标识内容输出的开始)。
如果使用 if content_piece:,空字符串会被判定为 False,从而导致无法及时触发 response.output_item.added 和 response.content_part.added 事件,直到第一个非空字符到达。这会延迟客户端接收到“消息已开始生成”的信号。
建议使用 if content_piece is not None: 来进行更精确的判断。
| content_piece = delta.get("content") | |
| if content_piece: | |
| if current is None or current[0] != "message": | |
| item = { | |
| "type": "message", | |
| "id": f"msg_{uuid.uuid4().hex}", | |
| "status": "in_progress", | |
| "role": "assistant", | |
| "content": [{"type": "output_text", "text": "", "annotations": []}], | |
| } | |
| for e in open_item("message", item): | |
| yield e | |
| content_piece = delta.get("content") | |
| if content_piece is not None: | |
| if current is None or current[0] != "message": | |
| item = { | |
| "type": "message", | |
| "id": f"msg_{uuid.uuid4().hex}", | |
| "status": "in_progress", | |
| "role": "assistant", | |
| "content": [{"type": "output_text", "text": "", "annotations": []}], | |
| } | |
| for e in open_item("message", item): | |
| yield e |
| def _content_parts_to_chat(parts: List[Any]) -> List[Dict[str, Any]]: | ||
| chat_parts = [] | ||
| for part in parts: | ||
| if isinstance(part, str): | ||
| chat_parts.append({"type": "text", "text": part}) | ||
| continue | ||
| ptype = part.get("type") |
There was a problem hiding this comment.
在 _content_parts_to_chat 函数中,遍历 parts 时,如果 part 既不是 str 也不是 dict(例如传入了非预期的类型),直接调用 part.get("type") 会抛出 AttributeError。
为了提高代码的健壮性,建议在调用 part.get 之前,先验证 part 是否为字典类型,若不是则抛出更友好的 ValueError。
def _content_parts_to_chat(parts: List[Any]) -> List[Dict[str, Any]]:
chat_parts = []
for part in parts:
if isinstance(part, str):
chat_parts.append({"type": "text", "text": part})
continue
if not isinstance(part, dict):
raise ValueError("content part must be a string or a dictionary")
ptype = part.get("type")| system_texts = [] | ||
| for m in messages: | ||
| if m["role"] == "system": | ||
| content = m["content"] | ||
| if isinstance(content, list): | ||
| content = "\n".join(p.get("text", "") for p in content) | ||
| if content: | ||
| system_texts.append(content) |
There was a problem hiding this comment.
在合并 system 消息时,如果 body["instructions"] 传入的是一个字符串列表(例如 ["prompt1", "prompt2"]),那么 content 将会是一个字符串列表。此时 p 是 str 类型,调用 p.get("text", "") 会抛出 AttributeError: 'str' object has no attribute 'get'。
为了兼容可能传入的字符串列表或字典列表,建议在获取 text 时进行类型检查。
| system_texts = [] | |
| for m in messages: | |
| if m["role"] == "system": | |
| content = m["content"] | |
| if isinstance(content, list): | |
| content = "\n".join(p.get("text", "") for p in content) | |
| if content: | |
| system_texts.append(content) | |
| system_texts = [] | |
| for m in messages: | |
| if m["role"] == "system": | |
| content = m["content"] | |
| if isinstance(content, list): | |
| content = "\n".join(p.get("text", "") if isinstance(p, dict) else str(p) for p in content) | |
| if content: | |
| system_texts.append(content) |
| for e in close_current(): | ||
| yield e | ||
|
|
||
| if failed_error is not None: | ||
| response["status"] = "failed" | ||
| response["error"] = {"code": "server_error", "message": failed_error.get("message", "generation failed")} | ||
| yield event("response.failed", {"response": response}) | ||
| return |
There was a problem hiding this comment.
在流式输出结束时,如果发生了 failed_error,代码依然会先调用 close_current()。这会导致即使生成失败,当前未完成的输出项(如 message)仍会被标记为 "completed" 并通过 response.output_item.done 事件发送给客户端。这在逻辑上是不正确的,因为该输出项并未成功生成完毕。
建议先检查 failed_error,如果失败则直接返回 response.failed,不再关闭并发送未完成的输出项。
| for e in close_current(): | |
| yield e | |
| if failed_error is not None: | |
| response["status"] = "failed" | |
| response["error"] = {"code": "server_error", "message": failed_error.get("message", "generation failed")} | |
| yield event("response.failed", {"response": response}) | |
| return | |
| if failed_error is not None: | |
| response["status"] = "failed" | |
| response["error"] = {"code": "server_error", "message": failed_error.get("message", "generation failed")} | |
| yield event("response.failed", {"response": response}) | |
| return | |
| for e in close_current(): | |
| yield e |
新增 OpenAI Responses API(
/v1/responses)兼容层,实现方式和api_anthropic.py一致:把请求翻译成内部chat_completions_impl调用,再把结果翻译回 Responses API 的结构。HTTP 入口加在api_http.py,跟anthropic_messages同样的异常处理。请求侧:
input支持字符串和 input items 数组(message/function_call/function_call_output/reasoning),reasoningitem 不重放,直接跳过。text.format(text / json_object / json_schema)映射到response_format。reasoning.effort→reasoning_effort,max_output_tokens→max_completion_tokens。instructions加上多个 developer/system 块合并成一条前置 system 消息(多数 chat template 只允许开头一条 system),保留相对顺序。tools里非 function 类型(如 Codex 的 web_search / namespace)跳过并告警,不报错。响应侧:
finish_reason=length标记为 incomplete。chat_completions_impl的 SSE 流,重新发射 Responses API 事件序列(response.created/response.output_item.added/response.output_text.delta/response.function_call_arguments.delta/response.completed等),带sequence_number。限制:服务端无状态,
store恒为 false,previous_response_id、background直接 400 拒绝。验证:black / flake8 通过,模块 import 正常。