# gate-openai — deterministic OpenAI-dialect provider stub Staging home for the **soul-openai-tools-v2** gate scaffolding (`docs/specs/SPEC-soul-openai-tools-v2-2026-08-06.md`, test-plan rung 1: "stub first — discriminates before El code exists"). Sibling of gate9's Anthropic stub (`_wt-beta-round9/scripts/gate9/stub-llm.py`): same scenario mechanism, opposite wire dialect. Stdlib Python only, 127.0.0.1 only, refuses ports 7770/7779/17779. Run `./selftest.sh` — exit 0 is green. ## Files | File | Role | |---|---| | `stub-openai.py` | HTTP server: `POST /v1/chat/completions` (OpenAI dialect), scenario-scripted responses, request validation, ground-truth JSONL log, hostile modes via `--mode` | | `scenarios-openai.json` | Scenario contract: scripts + markers + per-class/per-step request assertions | | `selftest.sh` | curl-driven proof of every scenario, every rejection, all hostile modes (58 checks) | ## What each scenario proves (when the brain drives it) | Class | Proves | |---|---| | `oa-plain` | finish_reason `stop` ends the loop; tools + `tool_choice` + `parallel_tool_calls:false` were offered on the wire | | `oa-tools-off` | the chat-only lane sends NO tools (offering them there is a 400) | | `oa-single-tool` | full round-trip: `tool_calls` parsed, assistant echo + `role:"tool"` turn with matching `tool_call_id` sent back, final text reached | | `oa-torture` | `function.arguments` (JSON-encoded string with nested quotes, backslashes, newlines, tabs, unicode) survives exactly ONE decode — the stub recomputes the issued payload from the script and 400s on any drift (`gate_echo_mismatch`, the spec §6 two-escaper trap) | | `oa-parallel` | two `tool_calls` in one response: the brain either answers both (paired correctly) or rejects cleanly — an unpaired echo is a 400 | | `oa-mission` | multi-round loop continuation; step index = assistant-message count, so resume threads index correctly by construction | | `oa-api-error` | provider errors 400/429/500/503 in the OpenAI error envelope surface honestly, no retry storm | Universal (every request, any scenario): Anthropic dialect leakage fails loudly with 400 — `anthropic-version` header, top-level `system` / `stop_sequences` / `max_tokens_to_sample`, `input_schema` inside tools, Anthropic content blocks (`tool_use`/`tool_result`/...). Tools must be `{type:"function", function:{name, description, parameters}}`, unique names; echoed `arguments` must be a JSON-encoded STRING, never a decoded object. ## Hostile modes (`--mode`, same file) | Mode | Behavior | Brain invariant under test | |---|---|---| | `black-hole` | reads the request, never responds | HTTP timeout exists and surfaces; no silent hang | | `mid-body-drop` | 200 headers, half a JSON body, socket abort | truncated body = clean error, never a half-parsed reply shown as real | | `tool-pending-forever` | every request gets a fresh `tool_calls` response, forever | the loop's iteration cap trips (`max_loop_iterations: 16` in the contract); count actual round-trips via `GET /gate/stats` (`chat_hits`) | ## How the brain-side gate consumes this 1. Start: `stub-openai.py --port P --scenarios scenarios-openai.json --log run.jsonl` 2. Point the brain at it: `NEURON_LLM_0_URL=http://127.0.0.1:P` + `NEURON_LLM_0_FORMAT=openai` (spec step 0 must verify these actually export at runtime), scratch profile, free soul port. 3. Send each phrasing's `prompt` (the marker selects the script); assert the brain's claims (`tools_used`, reply, ledger) against the stub's JSONL log — truth, not narration — plus files on disk for write_file scenarios. 4. Any stub 400 = the brain sent a malformed/leaked request; the gate fails with the stub's reason string. 5. Re-run gate9's Anthropic matrix unchanged = proof the Anthropic lane is byte-untouched. ## Reconciliation into gate9 (app repo) — AFTER round 9 merges This dir is staging only; the merge is mechanical by design: - `stub-openai.py` + `scenarios-openai.json` move to `scripts/gate9/` alongside `stub-llm.py` + `scenarios.json` (shared conventions: marker matching, assistant-count step indexing, `--port/--scenarios/--log`, JSONL fields `seq/ts/kind/scenario_class/phrasing/step/validation/ delivered/http_status`, prod-port refusal, benign background responses, `GATE-SCRIPT-EXHAUSTED` overrun, `{N}/{NN}` repeat expansion). - `prompt-matrix-gate.sh` gains a dialect axis (anthropic|openai) choosing stub + scenario file; `matrix-asserts.py` reads the same log shape. - The `--mode` hostile flags here are PROVIDER-side (brain↔LLM boundary); gate9's `hostile/` servers are SOUL-side (app↔brain boundary). They are complementary, not duplicates — both stay. ## Open questions for the port author (stub asserts a position; confirm or change) 1. `parallel_tool_calls` must be **explicitly false** on every tool-bearing request (ADR-0005 pin). If the builder omits it instead, relax `defaults.expect_request.parallel_tool_calls` to `null`. 2. `tool_choice` must be present (`"auto"` expected). If the brain relies on the provider default, drop `require_tool_choice`. 3. Tool-result `content` is asserted only to be a string; if the brain sends structured JSON-in-string (like `{"ok":true,...}`), no change needed. 4. Groq compatibility: Groq's OpenAI-compat endpoint rejects some optional fields; whatever field set the brain settles on for live Groq E2E must be mirrored here so the deterministic gate and the live lane assert the SAME request shape. 5. The stub treats a `role:"tool"` turn answering an already-answered id as 400; if the resume path can legitimately replay tool results, that rule needs a resume-aware carve-out (gate9's Anthropic stub faced the same issue — see its PASS 1 comment).