Skip to content

feat: support /v1/responses#1384

Open
sufubao wants to merge 1 commit into
ModelTC:mainfrom
sufubao:feat-responses-api
Open

feat: support /v1/responses#1384
sufubao wants to merge 1 commit into
ModelTC:mainfrom
sufubao:feat-responses-api

Conversation

@sufubao

@sufubao sufubao commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

新增 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),reasoning item 不重放,直接跳过。
  • text.format(text / json_object / json_schema)映射到 response_format
  • reasoning.effortreasoning_effortmax_output_tokensmax_completion_tokens
  • instructions 加上多个 developer/system 块合并成一条前置 system 消息(多数 chat template 只允许开头一条 system),保留相对顺序。
  • tools 里非 function 类型(如 Codex 的 web_search / namespace)跳过并告警,不报错。

响应侧:

  • 非流式:chat completion 翻译成 response 对象,output 为 reasoning / message / function_call items,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_idbackground 直接 400 拒绝。

验证:black / flake8 通过,模块 import 正常。

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +386 to +392
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

在处理 SSE 流时,直接对 raw_chunk 进行 split("\n") 存在严重的安全隐患和正确性问题。因为 TCP/HTTP 传输中的数据块(chunk)边界是任意的,一个完整的 SSE data: ... 行可能会被拆分到两个不同的 raw_chunk 中。
如果直接按 \n 切分,会导致前一个 chunk 的末尾部分和后一个 chunk 的开头部分被当作独立的行处理,从而导致 JSON 解析失败(抛出异常或被忽略),丢失关键的生成内容。

建议引入一个行缓冲区(line buffer)来拼接不完整的行,确保只有在读取到完整的换行符时才进行解析。

Suggested change
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()

Comment on lines +466 to +486
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},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当前的流式处理实现中,使用了一个单一的 current 变量来跟踪当前正在生成的输出项(如 messagereasoningfunction_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 跟踪所有活跃的并行工具调用,并在流结束时统一关闭它们。

Comment on lines +117 to +126
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"),
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

_text_format_to_response_format 函数中,当 ftype == "json_schema" 时,代码直接从 fmt(即 format 字典)中获取 namedescriptionschemastrict。然而,根据 OpenAI Responses API 的规范,json_schema 的具体定义是嵌套在 format.json_schema 对象中的。
因此,fmtbody["text"]["format"])中并不直接包含 nameschema 等字段,它们存在于 fmt.get("json_schema") 中。这会导致提取出来的 schema、name 和 strict 均为 None,从而使结构化输出(Structured Outputs)功能失效。

建议从 fmt.get("json_schema") 中提取这些字段。

Suggested change
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"),
},
}

Comment on lines +442 to +453
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在处理 content_piece 时,代码使用 if content_piece: 来判断并初始化消息项。然而,在流式输出的第一个 chunk 中,模型返回的 content 往往是一个空字符串 ""(用于标识内容输出的开始)。
如果使用 if content_piece:,空字符串会被判定为 False,从而导致无法及时触发 response.output_item.addedresponse.content_part.added 事件,直到第一个非空字符到达。这会延迟客户端接收到“消息已开始生成”的信号。
建议使用 if content_piece is not None: 来进行更精确的判断。

Suggested change
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

Comment on lines +17 to +23
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

_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")

Comment on lines +143 to +150
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在合并 system 消息时,如果 body["instructions"] 传入的是一个字符串列表(例如 ["prompt1", "prompt2"]),那么 content 将会是一个字符串列表。此时 pstr 类型,调用 p.get("text", "") 会抛出 AttributeError: 'str' object has no attribute 'get'
为了兼容可能传入的字符串列表或字典列表,建议在获取 text 时进行类型检查。

Suggested change
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)

Comment on lines +490 to +497
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在流式输出结束时,如果发生了 failed_error,代码依然会先调用 close_current()。这会导致即使生成失败,当前未完成的输出项(如 message)仍会被标记为 "completed" 并通过 response.output_item.done 事件发送给客户端。这在逻辑上是不正确的,因为该输出项并未成功生成完毕。
建议先检查 failed_error,如果失败则直接返回 response.failed,不再关闭并发送未完成的输出项。

Suggested change
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant