Skip to content

Commit 6e1729e

Browse files
committed
[NA] [CI] fix: serve both LLM wire protocols in the E2E mock
The nightly E2E started failing on 2026-08-01 without any change to this repo. A rolling nousresearch/hermes-agent:latest rebuild began honoring the openai-api provider's declared codex_responses transport, switching the wire from POST /v1/chat/completions to POST /v1/responses. The mock only spoke Chat Completions, so the stream never emitted a terminal event and every turn died after three retries with no tool call — surfacing as 'trace has no output' and 'no tool spans' across all three E2E jobs. Serve both protocols rather than pinning the image: the nightly cron exists to catch exactly this upstream drift, so pinning would silence the detector. The Chat Completions handler is unchanged, so older Hermes images keep passing. Also replace the catch-all 200 for unknown POST routes with a 501. The benign 200 let an unimplemented protocol masquerade as a hung stream, which is what made this break hard to diagnose. Verified against v2026.7.20, v2026.7.30 (chat_completions) and :latest (codex_responses): all three E2E jobs pass, 113 unit tests pass.
1 parent f701092 commit 6e1729e

1 file changed

Lines changed: 120 additions & 9 deletions

File tree

e2e/mock_llm_server.py

Lines changed: 120 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
"""Mock OpenAI-compatible LLM for E2E.
22
3-
The Hermes openai-api provider (as configured here) calls the **Chat
4-
Completions** API with streaming: POST /v1/chat/completions, and consumes
5-
`data: {"choices":[{"delta":{...}}]}` SSE chunks terminated by `data: [DONE]`.
3+
Serves **both** wire protocols the Hermes openai-api provider may pick, because
4+
which one it uses is upstream's choice and has changed under us before:
5+
6+
* **Chat Completions** — POST /v1/chat/completions, consuming
7+
`data: {"choices":[{"delta":{...}}]}` SSE chunks terminated by `data: [DONE]`.
8+
* **Responses** — POST /v1/responses, consuming typed SSE events
9+
(`response.output_item.added/done`, `response.output_text.delta`) and
10+
terminated by `response.completed`. Hermes calls this its `codex_responses`
11+
api_mode; the openai-api provider declares that transport, and a rolling
12+
`nousresearch/hermes-agent:latest` rebuild started honoring it (2026-08-02),
13+
which is what made a chat-only mock fail with "Codex Responses stream did
14+
not emit a terminal response".
15+
16+
Both are kept so the suite passes against old and new Hermes images alike.
17+
618
It also probes GET /v1/models and POST /api/show (Ollama-style).
719
8-
Deterministic two-step interaction:
9-
call 1 -> a tool_call delta (Hermes runs a tool -> tool span)
10-
call 2+ -> a content delta (turn completes)
20+
Deterministic two-step interaction, identical on either protocol:
21+
call 1 -> a tool call (Hermes runs a tool -> tool span)
22+
call 2+ -> assistant text (turn completes)
1123
1224
That yields one LLM -> tool -> LLM cycle: a root trace with two LLM spans and
1325
one tool span. No real model, no key, no external network.
26+
27+
Unknown POST routes return 501 rather than a benign 200: a silent 200 makes an
28+
unimplemented protocol look like a hung stream, which is exactly how the
29+
2026-08-02 breakage disguised itself.
1430
"""
1531

1632
from __future__ import annotations
@@ -60,6 +76,61 @@ def _final_chunks():
6076
yield {**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": _USAGE}
6177

6278

79+
_RESPONSES_USAGE = {"input_tokens": 20, "output_tokens": 8, "total_tokens": 28}
80+
81+
82+
def _responses_tool_events():
83+
"""Emit a function_call item, then response.completed.
84+
85+
Hermes marks `has_tool_calls` from the `output_item.added` event and
86+
collects the executable item from `output_item.done`; both are required.
87+
"""
88+
item = {
89+
"id": "fc_mock_1",
90+
"type": "function_call",
91+
"status": "completed",
92+
"call_id": "call_mock_1",
93+
"name": "execute_code",
94+
"arguments": json.dumps({"code": "print(2**10)"}),
95+
}
96+
yield "response.output_item.added", {"output_index": 0, "item": item}
97+
yield "response.output_item.done", {"output_index": 0, "item": item}
98+
yield "response.completed", {
99+
"response": {
100+
"id": "resp_mock1",
101+
"status": "completed",
102+
"output": [item],
103+
"usage": _RESPONSES_USAGE,
104+
}
105+
}
106+
107+
108+
def _responses_final_events():
109+
"""Emit assistant text deltas, then response.completed."""
110+
text = "The answer is 1024."
111+
item = {
112+
"id": "msg_mock_1",
113+
"type": "message",
114+
"status": "completed",
115+
"role": "assistant",
116+
"content": [{"type": "output_text", "text": text}],
117+
}
118+
yield "response.output_item.added", {
119+
"output_index": 0,
120+
"item": {**item, "status": "in_progress", "content": []},
121+
}
122+
yield "response.output_text.delta", {"output_index": 0, "delta": text}
123+
yield "response.output_item.done", {"output_index": 0, "item": item}
124+
yield "response.completed", {
125+
"response": {
126+
"id": "resp_mock2",
127+
"status": "completed",
128+
"output": [item],
129+
"usage": _RESPONSES_USAGE,
130+
}
131+
}
132+
133+
63134
class Handler(BaseHTTPRequestHandler):
64135
def log_message(self, *args):
65136
pass
@@ -83,6 +154,22 @@ def _sse(self, chunks):
83154
self.wfile.write(b"data: [DONE]\n\n")
84155
self.wfile.flush()
85156

157+
def _sse_typed(self, events):
158+
"""SSE for the Responses API: named events carrying a `type` field.
159+
160+
Unlike Chat Completions there is no `[DONE]` sentinel — the stream ends
161+
at the terminal `response.completed` event.
162+
"""
163+
self.send_response(200)
164+
self.send_header("Content-Type", "text/event-stream")
165+
self.send_header("Cache-Control", "no-cache")
166+
self.end_headers()
167+
for seq, (event_type, payload) in enumerate(events):
168+
frame = {"type": event_type, "sequence_number": seq, **payload}
169+
self.wfile.write(f"event: {event_type}\n".encode())
170+
self.wfile.write(f"data: {json.dumps(frame)}\n\n".encode())
171+
self.wfile.flush()
172+
86173
def do_GET(self):
87174
# /v1/models and health probes
88175
self._json({"object": "list", "data": [{"id": "gpt-5", "object": "model"}]})
@@ -97,7 +184,25 @@ def do_POST(self):
97184
self._json({"model": "gpt-5", "details": {"family": "gpt"}})
98185
return
99186

100-
# Chat Completions (the path Hermes uses here).
187+
# Responses API (codex_responses api_mode).
188+
if self.path.rstrip("/").endswith("/responses"):
189+
_STATE["calls"] += 1
190+
events = (
191+
_responses_tool_events()
192+
if _STATE["calls"] == 1
193+
else _responses_final_events()
194+
)
195+
wants_stream = b'"stream": true' in body or b'"stream":true' in body
196+
if wants_stream:
197+
self._sse_typed(events)
198+
else:
199+
# Non-streaming fallback: the terminal event's response object
200+
# is already the full non-streamed body.
201+
terminal = list(events)[-1][1]
202+
self._json({"object": "response", "model": "gpt-5", **terminal["response"]})
203+
return
204+
205+
# Chat Completions (the other wire Hermes may pick).
101206
if "chat/completions" in self.path:
102207
_STATE["calls"] += 1
103208
chunks = _tool_call_chunks() if _STATE["calls"] == 1 else _final_chunks()
@@ -118,8 +223,14 @@ def do_POST(self):
118223
)
119224
return
120225

121-
# Anything else → benign 200.
122-
self._json({"ok": True})
226+
# An unimplemented *inference* route must fail loudly. Returning 200
227+
# here would let Hermes hang until its retries expire and surface as a
228+
# vague stream error, hiding the real cause (a protocol we don't serve).
229+
print(f"UNIMPLEMENTED inference route: {self.path}", flush=True)
230+
self._json(
231+
{"error": {"message": f"mock-llm does not implement {self.path}", "type": "not_implemented"}},
232+
code=501,
233+
)
123234

124235

125236
if __name__ == "__main__":

0 commit comments

Comments
 (0)