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+
618It 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
1224That yields one LLM -> tool -> LLM cycle: a root trace with two LLM spans and
1325one 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
1632from __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+
63134class 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
125236if __name__ == "__main__" :
0 commit comments