Streamed Chat Completions turns report response_id '__fake_id__' instead of None, and it leaks into previous_response_id and compaction
维护者通常 1 天内回复
还没有人认领这个 Issue。
评估
调研方向
从 src/agents/models/openai_chatcompletions.py 中流式响应的构造方式,以及 ChatCmplStreamHandler 如何发出 response.completed 开始;将其与非流式响应路径进行比较。检查有关流式 Chat Completions 响应 ID 的测试,并为 None 情况补充测试覆盖,包括下游对先前响应 ID 或压缩处理的行为。完成标准是流式 Chat Completions 报告 response_id=None,且不会将占位 ID 传递到下游。
由索引模型根据 Issue 内容生成。
描述
Describe the bug
When an agent runs on OpenAIChatCompletionsModel with Runner.run_streamed, the turn's ModelResponse.response_id is the SDK's own placeholder "__fake_id__". The same agent with Runner.run gets response_id=None. So result.last_response_id and result.raw_responses[i].response_id come back as "__fake_id__" only in streaming mode.
The rest of the runner treats None as "this provider has no server response id", so the placeholder slips past those checks. I hit two follow-on effects.
-
With
auto_previous_response_id=True(or an explicitprevious_response_id), a handoff from a Responses agent to a Chat Completions agent and back replaces the realresp_...id in the chain with"__fake_id__". The next Responses request sendsprevious_response_id="__fake_id__". In non-streamed mode the tracker keepsresp_A1. -
With
OpenAIResponsesCompactionSession, post-write compaction calls/responses/compactwithprevious_response_id="__fake_id__", an id the server never issued. In the repro the mocked 400 for that request comes out of the streamed run. In non-streamed mode compaction is skipped because the id isNone, and the run succeeds.
I know previous_response_id and compaction are documented as Responses features, so 1 and 2 are mixed setups. The last_response_id difference between streamed and non-streamed runs shows up with a plain Chat Completions agent though.
From reading the code, LiteLLM and AnyLLM streaming build the same placeholder response and go through the same path. I only ran the OpenAI Chat Completions model.
Debug information
- Agents SDK version: main at cd26bbb (the same line is in v0.24.0)
- Related library versions (optional, e.g.
any-llm,litellm, orpydantic): openai 3.29.0 - Python version: 3.14.4
- Operating system: macOS
- Model and model provider:
OpenAIChatCompletionsModel(LiteLLM and AnyLLM models look affected by code reading) - Does the issue reproduce with the latest Agents SDK release? I ran it on main. v0.24.0 has the same code.
- Does the issue occur consistently or intermittently? Consistently
Output of the script below. The two warnings at the top come from the handoff case and are expected.
OpenAIChatCompletionsModel does not support server-managed conversation state (previous_response_id). Chat Completions requires callers to pass the full conversation history; use a Responses API model for previous_response_id or a conversation-capable model for conversation_id. Ignoring unsupported server-managed conversation state; enable strict feature validation to raise an error instead.
OpenAIChatCompletionsModel does not support server-managed conversation state (previous_response_id). Chat Completions requires callers to pass the full conversation history; use a Responses API model for previous_response_id or a conversation-capable model for conversation_id. Ignoring unsupported server-managed conversation state; enable strict feature validation to raise an error instead.
case_last_response_id
run last_response_id=None
run_streamed last_response_id='__fake_id__'
case_compaction
run ok final_output='hi', compact previous_response_id sent=[]
run_streamed raised BadRequestError, compact previous_response_id sent=['__fake_id__']
case_handoff_chain
run second Responses request previous_response_id='resp_A1'
run_streamed second Responses request previous_response_id='__fake_id__'
Repro steps
Self-contained, no API key needed. All HTTP goes to an httpx2.MockTransport (the HTTP client the current openai package uses). The compaction mock answers with a 400 invalid_request_error, standing in for the real endpoint rejecting an id it never issued.
import asyncio
import json
import httpx2 # the HTTP library the current openai package is built on
from openai import AsyncOpenAI
from agents import Agent, Runner, SQLiteSession, set_tracing_disabled
from agents.memory import OpenAIResponsesCompactionSession
from agents.models.openai_chatcompletions import OpenAIChatCompletionsModel
from agents.models.openai_responses import OpenAIResponsesModel
set_tracing_disabled(True)
def mock_client(replies, seen):
def handler(request):
body = json.loads(request.content or b"{}")
seen.append((request.url.path, body))
status, ctype, content = replies.pop(0)(body)
return httpx2.Response(status, headers={"content-type": ctype}, content=content)
transport = httpx2.MockTransport(handler)
return AsyncOpenAI(
api_key="test",
base_url="http://test/v1",
http_client=httpx2.AsyncClient(transport=transport),
max_retries=0,
)
def sse(events, named=False):
out = ""
for e in events:
if named:
out += f"event: {e['type']}\n"
out += f"data: {json.dumps(e)}\n\n"
if not named:
out += "data: [DONE]\n\n"
return out.encode()
def chat_reply(message, finish, streamed):
if not streamed:
body = {"id": "chatcmpl-1", "object": "chat.completion", "created": 1, "model": "m",
"choices": [{"index": 0, "message": message, "finish_reason": finish}]}
return lambda b: (200, "application/json", json.dumps(body).encode())
delta = dict(message)
if "tool_calls" in delta:
delta["tool_calls"] = [{"index": 0, **tc} for tc in delta["tool_calls"]]
chunks = [
{"id": "chatcmpl-1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": delta, "finish_reason": None}]},
{"id": "chatcmpl-1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {}, "finish_reason": finish}]},
]
return lambda b: (200, "text/event-stream", sse(chunks))
def responses_reply(output, rid, streamed):
r = {"id": rid, "object": "response", "created_at": 0, "model": "m", "status": "completed",
"output": output, "tool_choice": "auto", "tools": [], "parallel_tool_calls": True}
if not streamed:
return lambda b: (200, "application/json", json.dumps(r).encode())
events = [{"type": "response.created", "sequence_number": 0,
"response": {**r, "status": "in_progress", "output": []}}]
for i, item in enumerate(output):
events.append({"type": "response.output_item.done", "sequence_number": i + 1,
"output_index": i, "item": item})
events.append({"type": "response.completed", "sequence_number": len(output) + 1, "response": r})
return lambda b: (200, "text/event-stream", sse(events, named=True))
async def run(agent, streamed, **kwargs):
if streamed:
result = Runner.run_streamed(agent, "hi", **kwargs)
async for _ in result.stream_events():
pass
return result
return await Runner.run(agent, "hi", **kwargs)
HI = {"role": "assistant", "content": "hi"}
async def case_last_response_id(streamed):
client = mock_client([chat_reply(HI, "stop", streamed)], [])
agent = Agent(name="a", model=OpenAIChatCompletionsModel(model="m", openai_client=client))
result = await run(agent, streamed)
return f"last_response_id={result.last_response_id!r}"
async def case_compaction(streamed):
def compact_400(body):
err = {"error": {"message": f"Invalid 'previous_response_id': "
f"'{body.get('previous_response_id')}'.",
"type": "invalid_request_error", "param": "previous_response_id"}}
return 400, "application/json", json.dumps(err).encode()
compact_seen = []
session = OpenAIResponsesCompactionSession(
"s", SQLiteSession("s"), client=mock_client([compact_400], compact_seen),
model="gpt-4.1", should_trigger_compaction=lambda ctx: True,
)
client = mock_client([chat_reply(HI, "stop", streamed)], [])
agent = Agent(name="a", model=OpenAIChatCompletionsModel(model="m", openai_client=client))
try:
result = await run(agent, streamed, session=session)
out = f"ok final_output={result.final_output!r}"
except Exception as e:
out = f"raised {type(e).__name__}"
sent = [b.get("previous_response_id") for _, b in compact_seen]
return f"{out}, compact previous_response_id sent={sent}"
async def case_handoff_chain(streamed):
to_b = {"type": "function_call", "id": "fc_1", "call_id": "call_1",
"name": "transfer_to_b", "arguments": "{}", "status": "completed"}
done = {"type": "message", "id": "msg_1", "role": "assistant", "status": "completed",
"content": [{"type": "output_text", "text": "done", "annotations": []}]}
to_a = {"role": "assistant", "content": None, "tool_calls": [
{"id": "call_2", "type": "function",
"function": {"name": "transfer_to_a", "arguments": "{}"}}]}
a_seen = []
a_client = mock_client([responses_reply([to_b], "resp_A1", streamed),
responses_reply([done], "resp_A2", streamed)], a_seen)
b_client = mock_client([chat_reply(to_a, "tool_calls", streamed)], [])
a = Agent(name="a", model=OpenAIResponsesModel(model="m", openai_client=a_client))
b = Agent(name="b", model=OpenAIChatCompletionsModel(model="m", openai_client=b_client))
a.handoffs = [b]
b.handoffs = [a]
await run(a, streamed, auto_previous_response_id=True)
return f"second Responses request previous_response_id={a_seen[1][1].get('previous_response_id')!r}"
async def main():
for case in (case_last_response_id, case_compaction, case_handoff_chain):
print(case.__name__)
for streamed in (False, True):
label = " run_streamed" if streamed else " run "
print(label, await case(streamed))
asyncio.run(main())
Expected behavior
A streamed Chat Completions turn should report response_id=None, the same as the non-streamed turn. Then last_response_id is None, a previous_response_id chain keeps the last real resp_... id, and compaction is not attempted with a made-up id.
Root cause
For streaming, the Chat Completions model builds a placeholder Response(id=FAKE_RESPONSES_ID, ...) (openai_chatcompletions.py#L770, same in litellm_model.py#L669 and any_llm_model.py#L982), and ChatCmplStreamHandler copies it into the response.completed event (chatcmpl_stream_handler.py#L1349).
run_single_turn_streamed then passes that id straight through (run_loop.py#L2694).
final_response = ModelResponse(
output=terminal_response.output,
usage=usage,
response_id=terminal_response.id,
...
The non-streamed path returns response_id=None (openai_chatcompletions.py#L381, litellm_model.py#L375). The downstream code only checks for None, in the conversation tracker (oai_conversation.py#L386-L392 and the resume path at L223-L228) and in the compaction gate (session_persistence.py#L730). So the placeholder gets treated as a real server id.
The SDK already ignores FAKE_RESPONSES_ID for item ids (_normalize_server_item_id in oai_conversation.py#L37), just not for response ids.
Suggested fix
Map the placeholder to None where the streamed ModelResponse is built.
response_id=(
None if terminal_response.id == FAKE_RESPONSES_ID else terminal_response.id
),
That covers all three adapters in one place.
- 主要语言
- Python
- 星标
- 30.4k
- 派生
- 4.9k
- 平均合并
- 18 小时 39 分钟
- 30 天内合并 PR
- 93
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
openai/openai-agents-python 的其他 Issue
-
Docs: `tool_choice="required"` can select a state-changing tool when the needed one is not shown未关闭
难度 1/5 1 小时以内 新手友好度 90/100
openai/openai-agents-python#5461 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 78/100
openai/openai-agents-python#5460 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 85/100
openai/openai-agents-python#5459 · 2 条评论 ·
维护者通常 1 天内回复
-
难度 1/5 1 小时以内 新手友好度 90/100
openai/openai-agents-python#5458 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 85/100
openai/openai-agents-python#5455 ·
维护者通常 1 天内回复
查看 openai/openai-agents-python 的全部 Issue
相似的 Issue
-
Status: Triage Type: Quest
难度 2/5 1-3 小时 新手友好度 66/100
GTNewHorizons/GT-New-Horizons-Modpack#27490 ·
维护者通常 1 天内回复
-
Bug
难度 2/5 1-3 小时 新手友好度 72/100
维护者通常 1 天内回复
-
bug
难度 2/5 1-3 小时 新手友好度 82/100
retentioneering/retentioneering-tools#231 ·
维护者通常 1 天内回复
-
backend bug documentation
难度 2/5 1-3 小时 新手友好度 78/100
geolens-io/geolens#3568 ·
维护者通常 1 天内回复
-
enhancement
难度 1/5 1 小时以内 新手友好度 88/100
mprov-ng/mprov_control_center#450 ·
维护者通常 1 天内回复