Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2020,7 +2020,19 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
previous_response_id = self._response_store.get_conversation(conversation)
# No error if conversation doesn't exist yet — it's a new conversation

# Normalize input to message list
# Normalize input to message list.
# Responses API input arrays may contain typed items (function_call,
# function_call_output, reasoning, message, …) representing the prior
# turn's structured output. Open WebUI Responses mode forwards these
# verbatim when chaining without ``previous_response_id``. Treating
# every dict as a {role, content} message turns prior tool calls and
# tool outputs into spurious user-shaped history, which bloats context
# and causes the agent to re-address old user questions.
# We parse Responses item types explicitly: only ``message`` items
# (and untyped role/content dicts, for chat-style callers) become
# conversation messages. Other typed items are dropped here — the
# agent reconstructs tool flow from ``previous_response_id`` chaining
# when callers want stateful tool replay.
input_messages: List[Dict[str, Any]] = []
if isinstance(raw_input, str):
input_messages = [{"role": "user", "content": raw_input}]
Expand All @@ -2029,6 +2041,11 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
if isinstance(item, str):
input_messages.append({"role": "user", "content": item})
elif isinstance(item, dict):
item_type = str(item.get("type") or "").strip().lower()
if item_type and item_type != "message":
# function_call / function_call_output / reasoning /
# other built-in tool items — not user-visible turns.
continue
role = item.get("role", "user")
try:
content = _normalize_multimodal_content(item.get("content", ""))
Expand Down Expand Up @@ -2075,9 +2092,22 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
if instructions is None:
instructions = stored.get("instructions")

# Append new input messages to history (all but the last become history)
for msg in input_messages[:-1]:
conversation_history.append(msg)
# When conversation_history was loaded from a prior source
# (body.conversation_history or previous_response_id), the request's
# input array's leading items are a client-side replay of the same
# turns we just loaded — appending them would duplicate every prior
# turn. Open WebUI's Responses mode triggers this: it sends
# previous_response_id AND re-inlines the entire prior transcript
# in input[], so without this guard each chained turn doubled the
# stored conversation_history.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

bool(conversation_history) does not establish that input_messages[:-1] is a replay. This drops any legitimate new prefix messages whenever explicit history or a prior response is non-empty; please deduplicate only an input prefix that is actually shown to overlap the loaded history.

history_from_prior_source = bool(conversation_history)

# Append new input messages to history (all but the last become
# history). Skip when prior was loaded — those inlined items are a
# redundant client-side replay of what we already have.
if not history_from_prior_source:
for msg in input_messages[:-1]:
conversation_history.append(msg)

# Last input message is the user_message
user_message: Any = input_messages[-1].get("content", "") if input_messages else ""
Expand Down
168 changes: 168 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,174 @@ async def test_successful_response_with_array_input(self, adapter):
assert call_kwargs["user_message"] == "What is 2+2?"
assert len(call_kwargs["conversation_history"]) == 1

@pytest.mark.asyncio
async def test_responses_input_skips_function_call_items(self, adapter):
"""Open WebUI Responses mode forwards prior assistant turns as a
sequence of typed Responses items: user message, function_call,
function_call_output, assistant message, then the new user message.

function_call/function_call_output items must NOT become user-shaped
history entries — only ``type=message`` items (and the assistant's
``output_text`` reply) belong in conversation_history. The latest
user message becomes ``user_message``.
"""
mock_result = {"final_response": "Sure.", "messages": [], "api_calls": 1}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "What time is it?"}],
},
{
"type": "function_call",
"name": "get_time",
"arguments": "{}",
"call_id": "call_1",
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "HUGE_TOOL_OUTPUT_THAT_MUST_NOT_LEAK_INTO_HISTORY",
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "It is noon."}],
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Thanks, what about tomorrow?"}],
},
],
},
)

assert resp.status == 200
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["user_message"] == "Thanks, what about tomorrow?"
history = call_kwargs["conversation_history"]
assert history == [
{"role": "user", "content": "What time is it?"},
{"role": "assistant", "content": "It is noon."},
]
# Sanity: tool output text never leaked into history.
for entry in history:
assert "HUGE_TOOL_OUTPUT_THAT_MUST_NOT_LEAK_INTO_HISTORY" not in str(entry.get("content", ""))

@pytest.mark.asyncio
async def test_previous_response_id_does_not_duplicate_inlined_history(self, adapter):
"""When ``previous_response_id`` is provided AND the client also
re-inlines the prior transcript in ``input[]`` (Open WebUI's
Responses-mode pattern), the inlined items must NOT be appended on
top of the loaded prior history — that doubles every prior turn.
"""
# Seed a prior response in the store.
adapter._response_store.put(
"resp_prior",
{
"response": {"id": "resp_prior"},
"conversation_history": [
{"role": "user", "content": "What is 1+1?"},
{"role": "assistant", "content": "2"},
],
"instructions": None,
"session_id": "sess_prior",
},
)
mock_result = {"final_response": "3", "messages": [], "api_calls": 1}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"previous_response_id": "resp_prior",
# Client (Open WebUI) re-inlines the prior transcript
# alongside previous_response_id — these items must
# be ignored, not duplicated into history.
"input": [
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "What is 1+1?"}],
},
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "2"}],
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Now add 1 more"}],
},
],
},
)

assert resp.status == 200
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["user_message"] == "Now add 1 more"
history = call_kwargs["conversation_history"]
# Expect exactly the two prior messages — NOT four (which would
# be the loaded prior + duplicated inlined replay).
assert history == [
{"role": "user", "content": "What is 1+1?"},
{"role": "assistant", "content": "2"},
]

@pytest.mark.asyncio
async def test_explicit_conversation_history_is_not_duplicated_by_input(self, adapter):
"""When body.conversation_history is provided AND input[] re-inlines
the same turns, conversation_history must not be duplicated.
"""
mock_result = {"final_response": "ok", "messages": [], "api_calls": 1}

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (mock_result, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
resp = await cli.post(
"/v1/responses",
json={
"model": "hermes-agent",
"conversation_history": [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
],
# Same turns re-inlined alongside explicit history.
"input": [
{"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "Hello"}]},
{"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": "Hi"}]},
{"type": "message", "role": "user",
"content": [{"type": "input_text", "text": "Anything new?"}]},
],
},
)

assert resp.status == 200
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["user_message"] == "Anything new?"
assert call_kwargs["conversation_history"] == [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi"},
]

@pytest.mark.asyncio
async def test_instructions_as_ephemeral_prompt(self, adapter):
"""The instructions field maps to ephemeral_system_prompt."""
Expand Down