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
36 changes: 30 additions & 6 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3916,21 +3916,23 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul
"status": "completed",
}))

# Relay ``reasoning.available`` previews as ``delta.reasoning_content``
# chunks (see ``_emit``). Filtered so tool-lifecycle events (already
# covered by ``_on_tool_start``/``_on_tool_complete``) aren't duplicated.
def _on_reasoning(event_type, tool_name=None, preview=None, args=None, **kwargs):
if event_type == "reasoning.available" and preview:
_stream_q.put(("__reasoning__", preview))

# Start agent in background. agent_ref is a mutable container
# so the SSE writer can interrupt the agent on client disconnect.
#
# ``tool_progress_callback`` is intentionally not wired here:
# it would duplicate every emit because ``run_agent`` fires it
# side-by-side with ``tool_start_callback``/``tool_complete_callback``.
# The structured callbacks are strictly richer (they carry
# the tool_call id), so they own the chat-completions SSE channel.
agent_ref = [None]
agent_task = asyncio.ensure_future(self._run_agent(
user_message=user_message,
conversation_history=history,
ephemeral_system_prompt=system_prompt,

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.

This callback only receives reasoning.available previews. Provider reasoning deltas flow through AIAgent._fire_reasoning_delta() → reasoning_callback (run_agent.py:5831-5842), so please thread and use reasoning_callback here as well; otherwise standard reasoning_content streaming remains absent.

session_id=session_id,
stream_delta_callback=_on_delta,
tool_progress_callback=_on_reasoning,
tool_start_callback=_on_tool_start,
tool_complete_callback=_on_tool_complete,
agent_ref=agent_ref,
Expand All @@ -3949,13 +3951,20 @@ def _on_tool_complete(tool_call_id, function_name, function_args, function_resul
)

# Non-streaming: run the agent (with optional Idempotency-Key)
_reasoning_parts: List[str] = []

def _on_reasoning(event_type, tool_name=None, preview=None, args=None, **kwargs):
if event_type == "reasoning.available" and preview:
_reasoning_parts.append(preview)

async def _compute_completion():
return await self._run_agent(
user_message=user_message,
conversation_history=history,
ephemeral_system_prompt=system_prompt,
session_id=session_id,
gateway_session_key=gateway_session_key,
tool_progress_callback=_on_reasoning,
**agent_overrides,
route=route,
)
Expand Down Expand Up @@ -4049,6 +4058,9 @@ async def _compute_completion():
"total_tokens": usage.get("total_tokens", 0),
},
}
reasoning_text = "\n\n".join(_reasoning_parts).strip()
if reasoning_text:
response_data["choices"][0]["message"]["reasoning_content"] = reasoning_text
if is_partial or is_failed or not completed:
response_data["hermes"] = {
"completed": completed,
Expand Down Expand Up @@ -4117,12 +4129,24 @@ async def _emit(item):
frontends can display them without storing the markers in
conversation history. See #6972 for the original event,
#16588 for the ``toolCallId``/``status`` lifecycle fields.
Tagged tuples ``("__reasoning__", text)`` are sent as a
standard ``delta.reasoning_content`` chunk — the same
field name DeepSeek/Moonshot/OpenRouter use for thinking
traces — so OpenAI-SDK clients can opt into rendering the
model's reasoning without it leaking into ``content``.
"""
if isinstance(item, tuple) and len(item) == 2 and item[0] == "__tool_progress__":
event_data = json.dumps(item[1])
await response.write(
f"event: hermes.tool.progress\ndata: {event_data}\n\n".encode()
)
elif isinstance(item, tuple) and len(item) == 2 and item[0] == "__reasoning__":
reasoning_chunk = {
"id": completion_id, "object": "chat.completion.chunk",
"created": created, "model": model,
"choices": [{"index": 0, "delta": {"reasoning_content": item[1]}, "finish_reason": None}],
}
await response.write(f"data: {json.dumps(reasoning_chunk)}\n\n".encode())
else:
content_chunk = {
"id": completion_id, "object": "chat.completion.chunk",
Expand Down
85 changes: 85 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,91 @@ async def _mock_run_agent(**kwargs):
assert '"status": "running"' not in body
assert '"status": "completed"' not in body

@pytest.mark.asyncio
async def test_stream_includes_reasoning_content(self, adapter):
"""``reasoning.available`` previews surface as ``delta.reasoning_content``
chunks, not mixed into ``delta.content`` — mirrors the DeepSeek/
Moonshot/OpenRouter ``reasoning_content`` field so OpenAI-SDK
clients can opt into rendering the model's reasoning."""
import asyncio
import json as _json

app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
async def _mock_run_agent(**kwargs):
cb = kwargs.get("stream_delta_callback")
reasoning_cb = kwargs.get("tool_progress_callback")
if reasoning_cb:
reasoning_cb("reasoning.available", "_thinking", "Let me check the calendar.", None)
if cb:
await asyncio.sleep(0.05)
cb("Here's your schedule.")
return (
{"final_response": "Here's your schedule.", "messages": [], "api_calls": 1},
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)

with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
resp = await cli.post(
"/v1/chat/completions",
json={
"model": "test",
"messages": [{"role": "user", "content": "what's on my calendar"}],
"stream": True,
},
)
assert resp.status == 200
body = await resp.text()

reasoning_chunks = []
for line in body.splitlines():
if line.startswith("data: ") and line.strip() != "data: [DONE]":
try:
chunk = _json.loads(line[len("data: "):])
except _json.JSONDecodeError:
continue
for choice in chunk.get("choices", []):
delta = choice.get("delta", {})
if "reasoning_content" in delta:
reasoning_chunks.append(delta["reasoning_content"])
# Reasoning text must never leak into delta.content.
assert "Let me check the calendar." not in delta.get("content", "")

assert reasoning_chunks == ["Let me check the calendar."]
assert "Here's your schedule." in body

@pytest.mark.asyncio
async def test_non_streaming_includes_reasoning_content(self, adapter):
"""Non-streaming ``/v1/chat/completions`` surfaces the model's
reasoning as ``message.reasoning_content``, matching the
streaming behaviour above."""
app = _create_app(adapter)

async def _mock_run_agent(**kwargs):
reasoning_cb = kwargs.get("tool_progress_callback")
if reasoning_cb:
reasoning_cb("reasoning.available", "_thinking", "Checking inbox for follow-ups.", None)
return (
{"final_response": "You have two emails needing a reply.", "messages": [], "api_calls": 1},
{"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
)

async with TestClient(TestServer(app)) as cli:
with patch.object(adapter, "_run_agent", side_effect=_mock_run_agent):
resp = await cli.post(
"/v1/chat/completions",
json={
"model": "test",
"messages": [{"role": "user", "content": "check my email"}],
},
)
assert resp.status == 200
data = await resp.json()

message = data["choices"][0]["message"]
assert message["reasoning_content"] == "Checking inbox for follow-ups."
assert message["content"] == "You have two emails needing a reply."


# ---------------------------------------------------------------------------
# _derive_chat_session_id unit tests
Expand Down