Files
neuron/tests/gate-openai/stub-openai.py
T
Tim Lingo de65991807 feat(engine): tools + agentic loop on the OpenAI wire, and two chat-breaking fixes found proving it
Teaches the OpenAI-format lane (Groq/OpenAI/Grok/Gemini/Ollama) to offer tools,
execute them, and loop — the capability that until now existed only on the
Anthropic wire. The tool-execution, consent, bridge and run-progress machinery is
reused unchanged; only the wire dialect is new.

Two pre-existing defects were found while proving it, and are fixed here because
both silently break chat:

1. PROVIDER WIRING NEVER CONNECTED. The launcher exports SOUL_LLM_PROVIDER /
   SOUL_LLM_BASE_URL and puts the provider key in ANTHROPIC_API_KEY + SOUL_API_KEY;
   the engine's provider fork read only NEURON_LLM_0_*, which nothing sets in a
   customer build. So use_openai was ALWAYS false: every non-Anthropic user's turns
   went to api.anthropic.com carrying, say, a Groq key, and came back
   "llm unavailable". Proven side-by-side against the pinned round-9 brain
   (sha256 15cf7d1b…): identical env, shipped brain = "llm unavailable" both chat
   modes with ZERO calls to the configured endpoint; this build = a real answer,
   with the probe logging POST /v1/chat/completions and Bearer <provider key>.
   Fixed brain-side only (env fallbacks) — no app or launcher change needed.

2. TRUNCATION SPLITS UTF-8 CHARACTERS. The session preload cuts recalled memory at
   fixed BYTE lengths (continuity snippet 350; session_preload_bullets per bullet).
   A cut landing inside a multi-byte character leaves a dangling lead byte in the
   SYSTEM PROMPT, making the whole request body invalid UTF-8 — providers reject it
   and the user sees an unexplained failure. Captured from a real body: 18,710 bytes,
   decode fails at 18,248 on 'e2', a box-drawing rule (U+2500 = E2 94 80) sliced in
   half. Trigger is ordinary content — em dash, curly quote, accented name, emoji,
   table border — and it gets MORE likely as memory grows. Shared code: this hit the
   Anthropic wire too. Fixed with utf8_safe_slice() applied at BOTH cut sites.

WHAT IS IN THE PORT
- llm_base_url / llm_wire_format / agentic_api_key: fall back to the launcher's own
  SOUL_LLM_* names; anthropic deliberately still returns "" so its native path is
  untouched (endpoint configurability remains neuron#62).
- openai_tools_json(): Anthropic tool schema -> OpenAI function schema; entries with
  no input_schema (Anthropic's server-side web_search) are skipped — they cannot
  execute on this wire.
- agentic_tools_no_web(): the standard set minus that server tool.
- openai_agentic_loop(): forked rather than parameterised, so agentic_loop — which
  carries every round-7/8/9 fix — is provably untouched. Same envelopes, same state
  keys, same consent policy (ask_all / escalate / builtin / always-allow), same
  client-bridge contract, same run-progress ledger, same 12-iteration cap.
- ADR-0005 mirrored on this wire: parallel_tool_calls:false is sent explicitly, and
  if a provider ignores it we honour the FIRST call and echo only that one, so the
  conversation we send is never self-contradictory. The drop is logged loudly.
- The assistant turn echoes the provider's own content bytes (json_get_raw), so a
  JSON null stays null and nothing is lost to a decode/re-encode round trip.
- Tool results are embedded already-escaped (dispatch_tool json_safe's them);
  truncation trims a dangling escape so a cut can't invalidate the body.
- bridge_save() gains a "wire" scalar and agentic_resume branches on it, so a
  suspended turn resumes on the wire it suspended on. Legacy blobs (no field) resume
  as anthropic. The field is read from the blob's SCALAR HEAD only — an unbounded
  first-match scan would run on into messages_raw, which is model-controlled, and
  that is exactly the round-9 resume defect. Pinned by a test.
- Three fork sites: handle_chat_agentic, handle_dharma_room_turn_agentic,
  agentic_resume. Tool assembly is computed once per lane at both entry points
  (it makes an HTTP call to the connector bridge; it was being paid for twice).

TOOLING THAT DID NOT EXIST
- tests/run-el-test.sh — engine tests were never runnable: elc is a compiler, it
  emits C and exits. This emits the test to C, compiles soul.c with main renamed
  away, links the rest + the repo-pinned runtime, and runs it. It also COMPUTES THE
  VERDICT, because every counted test file's "N passed, M failed" summary is a
  permanent 0/0 — the counters increment inside if BLOCKS, which El scoping
  discards (9 files; real fix filed as neuron#116). Proven to discriminate with a
  deliberately-broken assertion.
- tests/gate-openai/ — deterministic OpenAI-dialect provider stub + scenarios +
  driver + hostile modes, and a strict request validator that rejects any
  Anthropic-shaped field so dialect leakage fails loudly.

VERIFICATION (rungs named)
- E2E-VERIFIED against a LIVE provider (Anthropic's OpenAI-compatible endpoint,
  confirmed live): real answer; a tool call whose out-of-root path was DENIED by the
  guard, after which the model refused to claim success ("I won't tell you I did it,
  because I didn't"); then a valid path -> file physically on disk with exact content,
  honest reply, ledger with per-round entries + {done:true}.
- Deterministic lane gate: 11/12 in both consent configurations (bridge + local);
  hostile providers produce no hang and no fabricated answer; the 12-iteration cap
  trips with its honest message. The one FAIL is oa-tools-off and is NOT this port —
  see "Known, not fixed here".
- ANTHROPIC LANE UNCHANGED: gate9 32/32 on this build and on the pinned round-9
  brain; request bytes differ only within the noise band that two runs of the
  UNMODIFIED brain also produce (proven with a baseline-vs-baseline control), and
  the preload sections — the shared code touched here — are byte-identical.
  The rig discriminates: the round-8 brain scores 24/32 on it.
- verify-soul-contract.sh: PASS (27/27 routes, no hard-deletes).
- Unit: test_bridge_serialization 36/36 (incl. 8 new wire/field-order assertions),
  test_utf8_slice 18/18, test_agentic_tools 18 PASS / 0 FAIL / 3 documented skips.

KNOWN, NOT FIXED HERE (deliberate)
- Tools:Off on an OpenAI provider still fails: the non-agentic path goes through the
  el-runtime provider chain, which appends /v1/chat/completions to a base URL that
  already ends in /v1 -> /v1/v1/... 404. Runtime/plain-chat territory, untouched
  mid-beta. Note openai_chat_complete() has zero callers — that lane is served
  entirely by the runtime chain.
- The 12-iteration cap does not bound a chain of BRIDGED tools (iteration is
  per-invocation and resume starts fresh). Parity with the Anthropic lane.
- run_progress resets on each resume, so a client rendering cumulative steps across a
  consent pause sees earlier legs vanish. Parity with the Anthropic lane.
- verify-soul-contract.sh needs bash >= 4; under macOS's stock bash 3.2 it dies
  instantly with a FALSE red ("local: -n: invalid option").
- Groq-specific live E2E not run: no Groq key exists on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:41:18 -05:00

653 lines
30 KiB
Python
Executable File

#!/usr/bin/env python3
"""stub-openai.py - deterministic local stand-in for an OpenAI-format
/v1/chat/completions provider, for the soul-openai-tools-v2 gate
(docs/specs/SPEC-soul-openai-tools-v2-2026-08-06.md). No API key, no network,
no model.
Sibling of gate9's stub-llm.py (Anthropic dialect, _wt-beta-round9/scripts/
gate9/): same scenario mechanism (marker matching, assistant-count step
indexing, ground-truth JSONL log, prod-port refusal), different wire.
Staging home is tests/gate-openai/ in _wt-openai-tools; folds into
scripts/gate9/ after round 9 merges (see README.md).
WHAT IT DOES
* Serves POST /v1/chat/completions on 127.0.0.1 only (OpenAI dialect).
* VALIDATES every request - this is the gate's discriminator, built
BEFORE the brain-side El code exists so dialect leakage fails loudly:
- Anthropic tells are 400 code=gate_dialect_leak: `anthropic-version`
header; top-level `system` / `stop_sequences` / `max_tokens_to_sample`
/ `anthropic_version`; `input_schema` inside a tool entry; Anthropic
content blocks (tool_use / tool_result / server_tool_use / ...).
- tools[] must be OpenAI-shaped {type:"function", function:{name,
description, parameters}} with unique names -> 400 gate_tools_shape.
- assistant tool_calls echoes must be {id, type:"function",
function:{name, arguments:<JSON-encoded STRING>}}; a decoded-object
`arguments` is a wire bug -> 400 gate_tool_call_shape.
- every assistant tool_calls turn must be answered by role:"tool"
messages covering EVERY tool_call_id, immediately following;
unknown / duplicate / missing ids -> 400 gate_pairing.
- echoed `arguments` for gate-issued call ids (call_gate_*) are
recomputed from the script and compared after ONE json decode ->
400 gate_echo_mismatch. This is the two-escaper-trap discriminator
named in the spec's security model (section 6).
- scenario-level request expectations from scenarios-openai.json
(tools offered, OpenAI-shaped tool_choice, parallel_tool_calls
pinned false per ADR-0005) -> 400 gate_expect.
* Answers with SCRIPTED responses: plain text (finish_reason "stop"),
tool calls (finish_reason "tool_calls", arguments JSON-encoded, incl. a
nested-quote/escaping torture payload and a parallel two-call case), and
API-error injection (OpenAI error envelope). Scenario is selected by
scanning user-message text (newest first) for a registered marker
substring; the step index is the number of assistant messages already in
the request (stateless replay - resumes index correctly by construction).
* Writes a ground-truth JSONL log (--log): one record per request with the
validation verdict, matched scenario/step, and exactly which tool calls
were delivered. Gate assertions compare the brain's claims against THIS
log - truth, not narration.
* Unmatched requests (boot probes, awareness chatter) get a benign "ok"
text response, logged kind=background, never counted as ground truth.
* HOSTILE MODES (--mode) on the same file:
black-hole accept + read the request, never respond;
mid-body-drop send half a JSON body, then abort the socket;
tool-pending-forever every request gets a FRESH tool_call
(finish_reason "tool_calls"), forever - tests
the agentic loop's iteration cap; count the
brain's round-trips via GET /gate/stats.
usage: stub-openai.py --port P --scenarios scenarios-openai.json \
--log requests.jsonl [--mode MODE]
Listens on 127.0.0.1 only. Refuses production ports 7770/7779/17779.
"""
import argparse
import itertools
import json
import socket
import struct
import threading
import time
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
STATE = {"scenarios": None, "log_path": None, "lock": threading.Lock(),
"seq": 0, "mode": "normal", "chat_hits": 0}
_PENDING_SEQ = itertools.count(1)
ANTHROPIC_TOP_KEYS = ("system", "stop_sequences", "max_tokens_to_sample",
"anthropic_version")
ANTHROPIC_BLOCK_TYPES = {"tool_use", "tool_result", "server_tool_use",
"web_search_tool_result", "thinking",
"redacted_thinking"}
DEFAULT_EXPECT = {"require_tools": True, "require_tool_choice": True,
"parallel_tool_calls": False, "forbid_tools": False}
# ---------------------------------------------------------------- loading ----
def load_scenarios(path):
cfg = json.load(open(path))
defaults = dict(DEFAULT_EXPECT)
defaults.update(cfg.get("defaults", {}).get("expect_request", {}))
marker_map = [] # (marker_lower, cname, pid)
scripts = {} # cname or cname/pid -> expanded script
pid_map = {} # pid -> cname (for call_gate_* id -> script lookup)
expects = {} # cname -> merged expect_request
for cname, cls in cfg["classes"].items():
scripts[cname] = expand_script(cls.get("script", []))
exp = dict(defaults)
exp.update(cls.get("expect_request", {}))
expects[cname] = exp
for ph in cls["phrasings"]:
if ph.get("script") is not None:
scripts[cname + "/" + ph["id"]] = expand_script(ph["script"])
marker_map.append((ph["marker"].lower(), cname, ph["id"]))
pid_map[ph["id"]] = cname
return {"cfg": cfg, "marker_map": marker_map, "scripts": scripts,
"pid_map": pid_map, "expects": expects}
def expand_script(script):
"""Same repeat-expansion contract as gate9's stub-llm.py ({N}/{NN})."""
out = []
for step in script:
if "repeat" in step:
for n in range(1, step["repeat"] + 1):
t = {k: v for k, v in step.items() if k != "repeat"}
out.append(json.loads(json.dumps(t)
.replace("{NN}", "%02d" % n)
.replace("{N}", str(n))))
else:
out.append(step)
return out
# ------------------------------------------------------------- validation ----
def _rej(message, code):
return {"status": 400, "message": message, "code": code}
def validate_dialect(headers, req):
"""Universal checks - run on EVERY request, scenario-matched or not.
Anything Anthropic-shaped on this lane means the brain's translator
leaked; the whole point is that it fails loudly, here, with a reason."""
if headers.get("anthropic-version"):
return _rej("anthropic-version header on the OpenAI lane: this "
"request was built by the Anthropic dialect path",
"gate_dialect_leak")
for k in ANTHROPIC_TOP_KEYS:
if k in req:
return _rej("top-level `%s` is Anthropic dialect; the OpenAI "
"dialect has no such field (system prompt goes in "
"messages[0])" % k, "gate_dialect_leak")
tools = req.get("tools")
if tools is not None:
if not isinstance(tools, list):
return _rej("`tools` must be an array", "gate_tools_shape")
names = []
for i, t in enumerate(tools):
if not isinstance(t, dict):
return _rej("tools[%d] is not an object" % i,
"gate_tools_shape")
if "input_schema" in t or (isinstance(t.get("function"), dict)
and "input_schema" in t["function"]):
return _rej("tools[%d] carries `input_schema` (Anthropic "
"dialect); OpenAI dialect wants "
"function.parameters" % i, "gate_dialect_leak")
if t.get("type") != "function":
return _rej("tools[%d].type must be \"function\", got %r"
% (i, t.get("type")), "gate_tools_shape")
fn = t.get("function")
if not isinstance(fn, dict):
return _rej("tools[%d].function missing" % i,
"gate_tools_shape")
if not isinstance(fn.get("name"), str) or not fn["name"]:
return _rej("tools[%d].function.name missing/empty" % i,
"gate_tools_shape")
if not isinstance(fn.get("description"), str) or not fn["description"]:
return _rej("tools[%d].function.description missing/empty" % i,
"gate_tools_shape")
if not isinstance(fn.get("parameters"), dict):
return _rej("tools[%d].function.parameters missing (JSON "
"Schema object expected)" % i, "gate_tools_shape")
names.append(fn["name"])
if len(names) != len(set(names)):
return _rej("tools: tool names must be unique", "gate_tools_shape")
msgs = req.get("messages")
if not isinstance(msgs, list) or not msgs:
return _rej("`messages` must be a non-empty array",
"gate_messages_shape")
for i, m in enumerate(msgs):
if not isinstance(m, dict):
return _rej("messages[%d] is not an object" % i,
"gate_messages_shape")
c = m.get("content")
if isinstance(c, list):
for j, b in enumerate(c):
if isinstance(b, dict) and b.get("type") in ANTHROPIC_BLOCK_TYPES:
return _rej("messages[%d].content[%d] is an Anthropic "
"`%s` block; the OpenAI dialect uses "
"tool_calls / role:\"tool\" messages"
% (i, j, b.get("type")), "gate_dialect_leak")
if m.get("role") == "tool":
if not isinstance(m.get("tool_call_id"), str) or not m["tool_call_id"]:
return _rej("messages[%d]: role \"tool\" requires a "
"`tool_call_id`" % i, "gate_messages_shape")
if "content" not in m:
return _rej("messages[%d]: role \"tool\" requires `content`"
% i, "gate_messages_shape")
if m.get("role") == "assistant" and m.get("tool_calls") is not None:
tcs = m["tool_calls"]
if not isinstance(tcs, list) or not tcs:
return _rej("messages[%d].tool_calls must be a non-empty "
"array" % i, "gate_tool_call_shape")
for j, tc in enumerate(tcs):
if not isinstance(tc, dict) or tc.get("type") != "function":
return _rej("messages[%d].tool_calls[%d].type must be "
"\"function\"" % (i, j), "gate_tool_call_shape")
if not isinstance(tc.get("id"), str) or not tc["id"]:
return _rej("messages[%d].tool_calls[%d].id missing"
% (i, j), "gate_tool_call_shape")
fn = tc.get("function")
if not isinstance(fn, dict) or not isinstance(fn.get("name"), str):
return _rej("messages[%d].tool_calls[%d].function.name "
"missing" % (i, j), "gate_tool_call_shape")
if not isinstance(fn.get("arguments"), str):
return _rej("messages[%d].tool_calls[%d].function."
"arguments must be a JSON-encoded STRING, "
"got %s" % (i, j,
type(fn.get("arguments")).__name__),
"gate_tool_call_shape")
return None
def validate_pairing(msgs):
"""OpenAI pairing rule: every assistant tool_calls turn must be followed
immediately by role:"tool" messages answering every tool_call_id."""
open_ids, open_at = set(), None
for i, m in enumerate(msgs):
role = m.get("role")
if role == "tool":
tid = m.get("tool_call_id")
if open_at is None:
return _rej("messages[%d]: role \"tool\" message with no "
"preceding assistant tool_calls turn "
"(tool_call_id=%s)" % (i, tid), "gate_pairing")
if tid not in open_ids:
return _rej("messages[%d]: tool message answers unknown or "
"already-answered tool_call_id %s" % (i, tid),
"gate_pairing")
open_ids.discard(tid)
continue
if open_ids:
return _rej("messages[%d]: assistant tool_calls not fully "
"answered before messages[%d]; missing tool "
"responses for: %s" % (open_at, i, sorted(open_ids)),
"gate_pairing")
open_ids, open_at = set(), None
if role == "assistant" and m.get("tool_calls"):
ids = [tc.get("id") for tc in m["tool_calls"]]
open_ids, open_at = set(ids), i
if open_ids:
return _rej("messages[%d]: assistant tool_calls at end of thread "
"without tool responses for: %s"
% (open_at, sorted(open_ids)), "gate_pairing")
return None
def validate_echo_args(msgs, loaded):
"""Ground-truth round-trip check: for every echoed gate-issued call id,
recompute the arguments this stub originally sent from the script and
require one json decode to reproduce them exactly. Catches the
two-escaper trap (spec section 6) deterministically."""
if not loaded:
return None
for i, m in enumerate(msgs):
if m.get("role") != "assistant":
continue
for tc in m.get("tool_calls") or []:
tid = tc.get("id", "")
if not tid.startswith("call_gate_"):
continue
rest = tid[len("call_gate_"):]
try:
pid, s_part, k_part = rest.rsplit("_", 2)
step_idx, k = int(s_part[1:]), int(k_part)
except (ValueError, IndexError):
continue
cname = loaded["pid_map"].get(pid)
if cname is None:
continue
script = (loaded["scripts"].get(cname + "/" + pid)
or loaded["scripts"].get(cname) or [])
if step_idx >= len(script):
continue
calls = script[step_idx].get("tool_calls") or []
if k >= len(calls):
continue
expected = calls[k]
fn = tc.get("function") or {}
if fn.get("name") != expected["name"]:
return _rej("messages[%d]: echoed tool name %r != issued %r "
"for %s" % (i, fn.get("name"), expected["name"],
tid), "gate_echo_mismatch")
try:
got = json.loads(fn.get("arguments", ""))
except ValueError:
return _rej("messages[%d]: echoed arguments for %s are not "
"valid JSON after one decode (truncated or "
"half-escaped?)" % (i, tid), "gate_echo_mismatch")
if got != expected["arguments"]:
hint = (" (decoded to a string, not an object: "
"double-encoded - the two-escaper trap)"
if isinstance(got, str) else "")
return _rej("messages[%d]: echoed arguments for %s do not "
"round-trip to the issued payload%s"
% (i, tid, hint), "gate_echo_mismatch")
return None
def validate_expect(req, exp):
"""Scenario-level request expectations (scenarios-openai.json)."""
tools = req.get("tools") or []
if exp.get("forbid_tools") and tools:
return _rej("this scenario is chat-only: no `tools` may be offered "
"on it", "gate_expect")
if exp.get("require_tools") and not tools:
return _rej("scenario expects a `tools` array to be offered (the "
"agentic lane must advertise its tools)", "gate_expect")
if exp.get("require_tool_choice"):
tc = req.get("tool_choice")
ok = tc in ("auto", "none", "required") or (
isinstance(tc, dict) and tc.get("type") == "function"
and isinstance(tc.get("function"), dict)
and tc["function"].get("name"))
if not ok:
return _rej("scenario expects an OpenAI-shaped `tool_choice`, "
"got %r" % (tc,), "gate_expect")
want_ptc = exp.get("parallel_tool_calls", None)
if want_ptc is not None:
if "parallel_tool_calls" not in req:
return _rej("scenario expects explicit `parallel_tool_calls` "
"(ADR-0005: must be pinned false on the wire)",
"gate_expect")
if req["parallel_tool_calls"] != want_ptc:
return _rej("scenario expects parallel_tool_calls=%s, got %s"
% (json.dumps(want_ptc),
json.dumps(req["parallel_tool_calls"])),
"gate_expect")
return None
# --------------------------------------------------------- scenario match ----
def extract_user_texts_newest_first(msgs):
texts = []
for m in reversed(msgs):
if not isinstance(m, dict) or m.get("role") != "user":
continue
c = m.get("content")
if isinstance(c, str):
texts.append(c)
elif isinstance(c, list):
for b in c:
if isinstance(b, dict) and b.get("type") == "text":
texts.append(b.get("text", ""))
return texts
def match_scenario(loaded, msgs):
for text in extract_user_texts_newest_first(msgs):
tl = text.lower()
for marker, cname, pid in loaded["marker_map"]:
if marker in tl:
return cname, pid
return None, None
# ------------------------------------------------------------- rendering ----
def completion_envelope(msg, finish, model, usage=(100, 100)):
return {"id": "chatcmpl-gate-" + uuid.uuid4().hex[:12],
"object": "chat.completion", "created": int(time.time()),
"model": model,
"choices": [{"index": 0, "message": msg,
"finish_reason": finish, "logprobs": None}],
"usage": {"prompt_tokens": usage[0],
"completion_tokens": usage[1],
"total_tokens": usage[0] + usage[1]}}
def text_completion(text, model):
return completion_envelope({"role": "assistant", "content": text},
"stop", model, usage=(1, 1))
def pending_body(seq, model):
args = json.dumps({"path": "never-%04d.md" % seq,
"content": "this run never completes"})
msg = {"role": "assistant", "content": None,
"tool_calls": [{"id": "call_hostile_pending_%04d" % seq,
"type": "function",
"function": {"name": "write_file",
"arguments": args}}]}
return completion_envelope(msg, "tool_calls", model, usage=(1, 1))
def render_step(step, cname, pid, step_idx, model):
"""Returns (http_status, body_dict, delivered) - delivered is ground
truth for the JSONL log."""
delivered = {"tool_calls": [], "finish_reason": None, "api_error": None}
if "api_error" in step:
e = step["api_error"]
delivered["api_error"] = e["status"]
return (e["status"],
{"error": {"message": e["message"],
"type": e.get("type", "server_error"),
"param": None, "code": e.get("code")}},
delivered)
msg = {"role": "assistant"}
finish = "stop"
if step.get("tool_calls"):
tcs = []
for k, call in enumerate(step["tool_calls"]):
tid = "call_gate_%s_s%d_%d" % (pid, step_idx, k)
tcs.append({"id": tid, "type": "function",
"function": {"name": call["name"],
"arguments": json.dumps(
call["arguments"],
ensure_ascii=False)}})
delivered["tool_calls"].append(call["name"])
msg["tool_calls"] = tcs
msg["content"] = step.get("text") # null when no narration, like real
finish = "tool_calls"
else:
msg["content"] = step["text"]
delivered["finish_reason"] = finish
return 200, completion_envelope(msg, finish, model), delivered
# ------------------------------------------------------------------ log ------
def log_record(rec):
with STATE["lock"]:
STATE["seq"] += 1
rec["seq"] = STATE["seq"]
with open(STATE["log_path"], "a") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
# ---------------------------------------------------------------- server -----
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send_json(self, status, obj):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_error(self, verdict):
self._send_json(verdict["status"],
{"error": {"message": verdict["message"],
"type": "invalid_request_error",
"param": None, "code": verdict["code"]}})
def _drop_mid_body(self):
"""Valid 200 headers, half the promised body, then a socket abort
(same SO_LINGER teardown as gate9's mid-body-drop-brain.py)."""
full = json.dumps(text_completion(
"This reply will never finish arriving because the connection "
"dies in the middle of the body, which is exactly the point of "
"this hostile fixture.", "hostile-mid-drop")).encode("utf-8")
half = full[: len(full) // 2]
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(full))) # promises more
self.end_headers()
self.wfile.write(half)
self.wfile.flush()
try:
self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
struct.pack("ii", 1, 0))
self.connection.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.close_connection = True
def do_GET(self):
path = self.path.split("?")[0]
if path == "/gate/health":
self._send_json(200, {"ok": True, "mode": STATE["mode"]})
elif path == "/gate/stats":
with STATE["lock"]:
self._send_json(200, {"mode": STATE["mode"],
"chat_hits": STATE["chat_hits"]})
else:
self._send_json(404, {"error": {"message": "not found",
"type": "invalid_request_error",
"param": None,
"code": "unknown_route"}})
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n)
mode = STATE["mode"]
rec = {"ts": time.time(), "path": self.path, "mode": mode,
"kind": "background", "scenario_class": None, "phrasing": None,
"step": None, "n_messages": 0, "n_assistant": 0,
"validation": "ok", "validation_detail": None,
"delivered": {"tool_calls": [], "finish_reason": None,
"api_error": None},
"http_status": 200}
if self.path.split("?")[0] != "/v1/chat/completions":
rec.update(kind="wrong_path", http_status=404)
log_record(rec)
self._send_json(404, {"error": {
"message": "no such route: %s" % self.path,
"type": "invalid_request_error", "param": None,
"code": "unknown_route"}})
return
with STATE["lock"]:
STATE["chat_hits"] += 1
# ---- hostile modes: behavior first, no validation ----------------
if mode == "black-hole":
rec.update(kind="hostile", http_status=None)
log_record(rec)
threading.Event().wait() # hold the socket open forever
return
if mode == "mid-body-drop":
rec.update(kind="hostile", http_status=200)
log_record(rec)
self._drop_mid_body()
return
if mode == "tool-pending-forever":
seq = next(_PENDING_SEQ)
rec.update(kind="hostile",
delivered={"tool_calls": ["write_file"],
"finish_reason": "tool_calls",
"api_error": None})
log_record(rec)
self._send_json(200, pending_body(seq, "gate-openai-model"))
return
# ---- normal mode -------------------------------------------------
try:
req = json.loads(raw)
except ValueError as exc:
# DIAGNOSTIC CAPTURE (2026-08-06): an unparseable body used to be recorded as
# a bare "bad_json" with the bytes thrown away, which made an intermittent
# failure impossible to root-cause — you cannot fix what you did not keep.
# Dump the raw body next to the log, and record exactly where the parser gave
# up plus the offending byte, so one occurrence is enough to diagnose.
dump_path = "%s.badbody.%s" % (STATE.get("log_path", "/tmp/stub-openai"),
rec.get("seq", "x"))
try:
data = raw if isinstance(raw, (bytes, bytearray)) else str(raw).encode()
with open(dump_path, "wb") as fh:
fh.write(data)
except Exception as dump_exc:
dump_path = "(dump failed: %s)" % dump_exc
pos = getattr(exc, "pos", None)
near = ""
byte_repr = ""
if isinstance(pos, int):
blob = raw if isinstance(raw, (bytes, bytearray)) else str(raw).encode()
near = blob[max(0, pos - 60):pos + 60].decode("utf-8", "replace")
if 0 <= pos < len(blob):
byte_repr = "0x%02x" % blob[pos]
rec.update(kind="bad_json", validation="rejected",
validation_detail="request body is not valid JSON: %s" % exc,
http_status=400, raw_len=len(raw), raw_dump=dump_path,
err_pos=pos, err_byte=byte_repr, err_near=near)
log_record(rec)
self._send_error(_rej("request body is not valid JSON",
"bad_json"))
return
msgs = req.get("messages") or []
rec["n_messages"] = len(msgs)
rec["n_assistant"] = sum(1 for m in msgs if isinstance(m, dict)
and m.get("role") == "assistant")
loaded = STATE["scenarios"]
cname, pid = match_scenario(loaded, msgs)
if cname:
rec.update(kind="scenario", scenario_class=cname, phrasing=pid)
# Wire-level validation runs for EVERY request, scenario or not.
verdict = (validate_dialect(self.headers, req)
or validate_pairing([m for m in msgs
if isinstance(m, dict)])
or validate_echo_args(msgs, loaded))
if verdict:
rec.update(validation="rejected",
validation_detail=verdict["message"],
http_status=verdict["status"])
log_record(rec)
self._send_error(verdict)
return
model = req.get("model", "gate-openai-model")
if not cname:
log_record(rec)
self._send_json(200, text_completion("ok", model))
return
script = (loaded["scripts"].get(cname + "/" + pid)
or loaded["scripts"][cname])
step_idx = rec["n_assistant"]
if step_idx >= len(script):
rec.update(kind="overrun", step=step_idx)
log_record(rec)
self._send_json(200, text_completion(
"GATE-SCRIPT-EXHAUSTED %s step %d" % (pid, step_idx), model))
return
step = script[step_idx]
exp = dict(loaded["expects"][cname])
exp.update(step.get("expect_request", {}))
verdict = validate_expect(req, exp)
if verdict:
rec.update(step=step_idx, validation="rejected",
validation_detail=verdict["message"],
http_status=verdict["status"])
log_record(rec)
self._send_error(verdict)
return
status, body, delivered = render_step(step, cname, pid, step_idx,
model)
rec.update(step=step_idx, delivered=delivered, http_status=status)
log_record(rec)
self._send_json(status, body)
def log_message(self, *a):
pass
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, required=True)
ap.add_argument("--scenarios",
help="scenarios-openai.json (required in normal mode)")
ap.add_argument("--log", required=True)
ap.add_argument("--mode", default="normal",
choices=["normal", "black-hole", "mid-body-drop",
"tool-pending-forever"])
args = ap.parse_args()
if args.port in (7770, 7779, 17779):
raise SystemExit("stub-openai: refusing production Neuron port")
if args.mode == "normal" and not args.scenarios:
raise SystemExit("stub-openai: --scenarios is required in normal mode")
STATE["mode"] = args.mode
STATE["scenarios"] = (load_scenarios(args.scenarios)
if args.scenarios else None)
STATE["log_path"] = args.log
open(args.log, "w").close()
n_markers = (len(STATE["scenarios"]["marker_map"])
if STATE["scenarios"] else 0)
print("stub-openai [%s]: 127.0.0.1:%d /v1/chat/completions "
"(%d markers registered, log=%s)"
% (args.mode, args.port, n_markers, args.log), flush=True)
ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever()
if __name__ == "__main__":
main()