Hacktoberfest 2026: những issue maintainer đã đánh dấu cho tháng Mười, đang mở và phù hợp người mới. Xem issue Hacktoberfest

Streamed Chat Completions turns report response_id '__fake_id__' instead of None, and it leaks into previous_response_id and compaction

Đang mở Phù hợp với người mới
#5,451 1 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
2/5
Thời gian dự kiến
1-3 giờ
Mức phù hợp với người mới
77/100
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
python
Lĩnh vực
ai, backend

Hướng nghiên cứu

Bắt đầu với phần tạo phản hồi dạng streaming trong src/agents/models/openai_chatcompletions.py và cách ChatCmplStreamHandler phát ra response.completed; so sánh với luồng phản hồi không streaming. Kiểm tra các bài kiểm thử về ID phản hồi của Chat Completions dạng streaming và bổ sung kiểm thử cho hành vi với None, bao gồm cách xử lý ID phản hồi trước đó hoặc việc nén ở các bước downstream. Hoàn thành khi Chat Completions dạng streaming báo cáo response_id=None và không chuyển ID giữ chỗ xuống các bước downstream.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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.

  1. With auto_previous_response_id=True (or an explicit previous_response_id), a handoff from a Responses agent to a Chat Completions agent and back replaces the real resp_... id in the chain with "__fake_id__". The next Responses request sends previous_response_id="__fake_id__". In non-streamed mode the tracker keeps resp_A1.

  2. With OpenAIResponsesCompactionSession, post-write compaction calls /responses/compact with previous_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 is None, 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, or pydantic): 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.

Ngôn ngữ chính
Python
Star
30.4k
Fork
4.9k
Merge trung bình
15 giờ 42 phút
Pull request đã merge (30 ngày)
124

Chuẩn bị môi trường

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của openai/openai-agents-python

Tất cả issue của openai/openai-agents-python

Issue tương tự

Thêm issue về Python

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.