#!/usr/bin/env bash set -euo pipefail # OpenAI-compatible Lemonade serving smoke. # # Verifies that the Edge OpenAI-compatible input surface converges onto the Node # `openai_compat` adapter for a Lemonade-style provider: /v1/models lookup, # non-streaming and streaming /v1/chat/completions, and the `edge smoke openai` # command path. # # Modes (IOP_LEMONADE_MODE): # fake (default) - a temporary Go OpenAI-compatible server stands in for # Lemonade so the route can be checked with no external deps. # real - an external Lemonade server is used. Required env: # IOP_LEMONADE_ENDPOINT (root or trailing /v1 both allowed) # IOP_LEMONADE_MODEL # Optional auth (both must be set): # IOP_LEMONADE_AUTH_HEADER_NAME # IOP_LEMONADE_AUTH_HEADER_VALUE # Auth header values are written only to the generated temp # config and never printed to stdout/stderr. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" TMP_ROOT="${IOP_LEMONADE_TMP_ROOT:-$REPO_ROOT/.local}" mkdir -p "$TMP_ROOT" TMP_DIR=$(mktemp -d "$TMP_ROOT/e2e-openai-lemonade.XXXXXX") mkdir -p "$TMP_DIR/go-tmp" export GOTMPDIR="$TMP_DIR/go-tmp" export GOCACHE="${IOP_LEMONADE_GOCACHE:-$REPO_ROOT/.gocache}" mkdir -p "$GOCACHE" MODE="${IOP_LEMONADE_MODE:-fake}" EDGE_PID="" NODE_PID="" LEMONADE_PID="" EDGE_FD_OPEN=0 cleanup() { if [ "$EDGE_FD_OPEN" -eq 1 ]; then { echo "/exit" >&3; } 2>/dev/null || true exec 3>&- 2>/dev/null || true fi if [ -n "$EDGE_PID" ]; then wait "$EDGE_PID" 2>/dev/null || true; fi if [ -n "$NODE_PID" ]; then kill "$NODE_PID" 2>/dev/null || true wait "$NODE_PID" 2>/dev/null || true fi if [ -n "$LEMONADE_PID" ]; then kill "$LEMONADE_PID" 2>/dev/null || true wait "$LEMONADE_PID" 2>/dev/null || true fi if [ "${IOP_LEMONADE_KEEP_TMP:-0}" = "1" ]; then echo "[openai-lemonade] keeping temp dir: $TMP_DIR" return fi rm -rf "$TMP_DIR" 2>/dev/null || true } trap cleanup EXIT PORT=$((31000 + RANDOM % 5000)) BOOTSTRAP_PORT=$((21000 + RANDOM % 5000)) OPENAI_PORT=$((36000 + RANDOM % 5000)) LEMONADE_PORT=$((41000 + RANDOM % 5000)) EDGE_METRICS_PORT=$((46000 + RANDOM % 5000)) NODE_METRICS_PORT=$((51000 + RANDOM % 5000)) wait_port() { local host="$1" local port="$2" local label="$3" local deadline=$((SECONDS + 20)) while ! timeout 1 bash -c 'cat < /dev/null > /dev/tcp/"$1"/"$2"' _ "$host" "$port" 2>/dev/null; do if (( SECONDS >= deadline )); then echo "[openai-lemonade] $label did not open on $host:$port" return 1 fi sleep 0.2 done } # Resolve provider endpoint and model based on mode. if [ "$MODE" = "real" ]; then : "${IOP_LEMONADE_ENDPOINT:?real mode requires IOP_LEMONADE_ENDPOINT}" : "${IOP_LEMONADE_MODEL:?real mode requires IOP_LEMONADE_MODEL}" NODE_ENDPOINT="$IOP_LEMONADE_ENDPOINT" MODEL="$IOP_LEMONADE_MODEL" echo "[openai-lemonade] real mode against external Lemonade endpoint (model=$MODEL)" else MODEL="fake-lemonade-model" NODE_ENDPOINT="http://127.0.0.1:$LEMONADE_PORT" FAKE_LEMONADE="$TMP_DIR/fake_lemonade.go" cat > "$FAKE_LEMONADE" <<'EOF' package main import ( "encoding/json" "fmt" "log" "net/http" "os" "sync/atomic" ) type chatRequest struct { Model string `json:"model"` Stream bool `json:"stream"` Messages []struct { Role string `json:"role"` Content string `json:"content"` } `json:"messages"` } func main() { if len(os.Args) < 4 { log.Fatal("usage: fake_lemonade ") } model := os.Args[2] authorization := os.Args[3] var modelRequests atomic.Int64 var chatRequests atomic.Int64 var responseRequests atomic.Int64 var hangRequests atomic.Int64 var hangCancellations atomic.Int64 mux := http.NewServeMux() mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || r.RequestURI != "/v1/models" { http.Error(w, "unexpected models request", http.StatusBadRequest) return } if r.Header.Get("Authorization") != authorization { http.Error(w, "unexpected authorization", http.StatusUnauthorized) return } modelRequests.Add(1) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"` + model + `"},{"id":"lemonade-profile-alias"}]}`)) }) mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.RequestURI != "/v1/chat/completions" { http.Error(w, "unexpected chat request", http.StatusBadRequest) return } if r.Header.Get("Authorization") != authorization { http.Error(w, "unexpected authorization", http.StatusUnauthorized) return } var req chatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.Model != model { http.Error(w, "unexpected model: "+req.Model, http.StatusBadRequest) return } chatRequests.Add(1) if !req.Stream { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"chatcmpl-fake","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","reasoning_content":"thinking...","content":"IOP_OPENAI_LEMONADE_OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`)) return } w.Header().Set("Content-Type", "text/event-stream") flush := func() { if f, ok := w.(http.Flusher); ok { f.Flush() } } if len(req.Messages) == 1 && req.Messages[0].Content == "IOP_HANG_AFTER_FINISH" { hangRequests.Add(1) _, _ = w.Write([]byte("data: " + `{"choices":[{"delta":{"content":"IOP_HANG_VISIBLE_PREFIX"},"finish_reason":"stop"}]}` + "\n\n")) flush() <-r.Context().Done() hangCancellations.Add(1) log.Print("IOP_HANG_PROVIDER_RAW_DIAGNOSTIC") return } _, _ = w.Write([]byte("data: " + `{"choices":[{"delta":{"reasoning_content":"thinking..."}}]}` + "\n\n")) flush() _, _ = w.Write([]byte("data: " + `{"choices":[{"delta":{"content":"IOP_OPENAI_"}}]}` + "\n\n")) flush() _, _ = w.Write([]byte("data: " + `{"choices":[{"delta":{"content":"LEMONADE_OK"},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2}}` + "\n\n")) flush() _, _ = w.Write([]byte("data: [DONE]\n\n")) flush() }) mux.HandleFunc("/v1/responses", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.RequestURI != "/v1/responses" { http.Error(w, "unexpected responses request", http.StatusBadRequest) return } if r.Header.Get("Authorization") != authorization { http.Error(w, "unexpected authorization", http.StatusUnauthorized) return } var req struct { Model string `json:"model"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.Model != model { http.Error(w, "unexpected model: "+req.Model, http.StatusBadRequest) return } responseRequests.Add(1) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"id":"resp-fake","object":"response","model":"` + model + `","output_text":"IOP_OPENAI_LEMONADE_OK","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"IOP_OPENAI_LEMONADE_OK"}]}]}`)) }) mux.HandleFunc("/assert", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "unexpected assertion method", http.StatusMethodNotAllowed) return } models := modelRequests.Load() chats := chatRequests.Load() responses := responseRequests.Load() hangs := hangRequests.Load() cancellations := hangCancellations.Load() if models < 1 || chats != 4 || responses != 1 || hangs != 1 || cancellations != 1 { http.Error(w, fmt.Sprintf("unexpected request counts: models=%d chats=%d responses=%d hangs=%d cancellations=%d", models, chats, responses, hangs, cancellations), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprintf(w, `{"models":%d,"chats":%d,"responses":%d,"hangs":%d,"cancellations":%d}`, models, chats, responses, hangs, cancellations) }) log.Fatal(http.ListenAndServe(os.Args[1], mux)) } EOF FAKE_AUTHORIZATION="Bearer fake-lemonade-token" FAKE_LEMONADE_BIN="$TMP_DIR/fake-lemonade" go build -o "$FAKE_LEMONADE_BIN" "$FAKE_LEMONADE" "$FAKE_LEMONADE_BIN" "127.0.0.1:$LEMONADE_PORT" "$MODEL" "$FAKE_AUTHORIZATION" > "$TMP_DIR/fake_lemonade.out" 2>&1 & LEMONADE_PID=$! wait_port 127.0.0.1 "$LEMONADE_PORT" "fake lemonade" fi # Build the provider header block. Auth headers, when provided, are written # only into the generated temp config (never echoed). PROVIDER_HEADER_BLOCK="$TMP_DIR/provider_header_block.yaml" { if [ "$MODE" = "fake" ]; then echo " headers:" echo " Authorization: \"$FAKE_AUTHORIZATION\"" elif [ -n "${IOP_LEMONADE_AUTH_HEADER_NAME:-}" ] && [ -n "${IOP_LEMONADE_AUTH_HEADER_VALUE:-}" ]; then echo " headers:" echo " \"$IOP_LEMONADE_AUTH_HEADER_NAME\": \"$IOP_LEMONADE_AUTH_HEADER_VALUE\"" fi } > "$PROVIDER_HEADER_BLOCK" EDGE_CONFIG="$TMP_DIR/edge.yaml" NODE_CONFIG="$TMP_DIR/node.yaml" cat > "$EDGE_CONFIG" < "$NODE_CONFIG" < "$EDGE_OUT" 2>&1 & EDGE_PID=$! exec 3> "$TMP_DIR/edge_fifo" EDGE_FD_OPEN=1 wait_port 127.0.0.1 "$PORT" "edge node transport" wait_port 127.0.0.1 "$OPENAI_PORT" "edge openai api" IOP_NODE_CONFIG="$NODE_CONFIG" "$REPO_ROOT/scripts/dev/node.sh" > "$NODE_OUT" 2>&1 & NODE_PID=$! deadline=$((SECONDS + 30)) while ! grep -q '\[node0-evt\] connected reason="registered"' "$EDGE_OUT"; do if (( SECONDS >= deadline )); then echo "[openai-lemonade] node registration timed out" echo "=== EDGE OUTPUT ==="; cat "$EDGE_OUT" echo "=== NODE OUTPUT ==="; cat "$NODE_OUT" exit 1 fi sleep 0.2 done # Edge health. curl -fsS "http://127.0.0.1:$OPENAI_PORT/healthz" > /dev/null # /v1/models must surface both the client-facing model and provider target. MODELS_OUT="$TMP_DIR/models.json" curl -fsS "http://127.0.0.1:$OPENAI_PORT/v1/models" > "$MODELS_OUT" grep -q "client-request-model" "$MODELS_OUT" grep -q "$MODEL" "$MODELS_OUT" # Non-streaming chat completion. CHAT_OUT="$TMP_DIR/chat.json" curl -fsS \ -H "Content-Type: application/json" \ -d '{"model":"client-request-model","max_tokens":32,"messages":[{"role":"user","content":"Reply with exactly: smoke token"}]}' \ "http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$CHAT_OUT" if [ "$MODE" = "fake" ]; then grep -q "IOP_OPENAI_LEMONADE_OK" "$CHAT_OUT" grep -q "reasoning_content" "$CHAT_OUT" else # real mode: reasoning models can emit a reasoning-only assistant payload # when max_tokens is intentionally small for smoke speed. grep -Eq '"(content|reasoning_content)":"[^"]' "$CHAT_OUT" fi # Streaming chat completion: SSE content chunk(s) and a terminating [DONE]. STREAM_OUT="$TMP_DIR/stream.txt" curl -fsS -N \ -H "Content-Type: application/json" \ -d '{"model":"client-request-model","stream":true,"max_tokens":32,"messages":[{"role":"user","content":"Reply with exactly: stream token"}]}' \ "http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$STREAM_OUT" if [ "$MODE" = "fake" ]; then grep -q '"reasoning_content":"thinking..."' "$STREAM_OUT" grep -q '"content":"IOP_OPENAI_"' "$STREAM_OUT" else grep -Eq '"(content|reasoning_content)":"[^"]' "$STREAM_OUT" fi grep -q 'data: \[DONE\]' "$STREAM_OUT" if [ "$MODE" = "fake" ]; then # Flush a logical finish and deliberately keep the provider response open. # Node's 200ms typed watchdog owns the failure before Edge's 30s request # timeout. The committed stream must receive one sanitized terminal only. HANG_OUT="$TMP_DIR/hang-after-finish.txt" HANG_STARTED=$SECONDS curl -fsS -N \ -H "Content-Type: application/json" \ -d '{"model":"client-request-model","stream":true,"max_tokens":32,"messages":[{"role":"user","content":"IOP_HANG_AFTER_FINISH"}]}' \ "http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$HANG_OUT" HANG_ELAPSED=$((SECONDS - HANG_STARTED)) if (( HANG_ELAPSED >= 10 )); then echo "[openai-lemonade] hang fixture exceeded the bounded IOP liveness window" exit 1 fi HANG_PREFIX_COUNT=$(grep -c 'IOP_HANG_VISIBLE_PREFIX' "$HANG_OUT" || true) HANG_FINISH_COUNT=$(grep -c '"finish_reason":"stop"' "$HANG_OUT" || true) HANG_ERROR_COUNT=$(grep -c '"type":"run_error"' "$HANG_OUT" || true) HANG_DONE_COUNT=$(grep -c '^data: \[DONE\]$' "$HANG_OUT" || true) if [ "$HANG_PREFIX_COUNT" -ne 1 ] || [ "$HANG_FINISH_COUNT" -ne 1 ] || [ "$HANG_ERROR_COUNT" -ne 1 ] || [ "$HANG_DONE_COUNT" -ne 1 ]; then echo "[openai-lemonade] hang fixture terminal counts prefix=$HANG_PREFIX_COUNT finish=$HANG_FINISH_COUNT run_error=$HANG_ERROR_COUNT done=$HANG_DONE_COUNT" exit 1 fi if grep -q 'IOP_HANG_PROVIDER_RAW_DIAGNOSTIC' "$HANG_OUT"; then echo "[openai-lemonade] raw provider diagnostic leaked to the client" exit 1 fi # A normal request after cancellation proves the provider handler and the # Node/Edge path remain usable after the fenced attempt. POST_CANCEL_OUT="$TMP_DIR/post-cancel-stream.txt" curl -fsS -N \ -H "Content-Type: application/json" \ -d '{"model":"client-request-model","stream":true,"max_tokens":32,"messages":[{"role":"user","content":"Reply after cancellation"}]}' \ "http://127.0.0.1:$OPENAI_PORT/v1/chat/completions" > "$POST_CANCEL_OUT" grep -q '"content":"IOP_OPENAI_' "$POST_CANCEL_OUT" [ "$(grep -c '^data: \[DONE\]$' "$POST_CANCEL_OUT")" -eq 1 ] fi # Command-path smoke via the edge CLI. SMOKE_OUT="$TMP_DIR/iop-edge-smoke-openai.txt" (cd "$REPO_ROOT" && go run ./apps/edge/cmd/edge smoke openai \ --model "client-request-model" \ --base-url "http://127.0.0.1:$OPENAI_PORT" \ --prompt "Reply with exactly: command smoke token" \ --timeout 60s) > "$SMOKE_OUT" grep -q "IOP Edge OpenAI Smoke Test SUCCESS!" "$SMOKE_OUT" if [ "$MODE" = "fake" ]; then FAKE_ASSERT_OUT="$TMP_DIR/fake_assert.json" curl -fsS "http://127.0.0.1:$LEMONADE_PORT/assert" > "$FAKE_ASSERT_OUT" grep -q '"chats":4' "$FAKE_ASSERT_OUT" grep -q '"responses":1' "$FAKE_ASSERT_OUT" grep -q '"hangs":1' "$FAKE_ASSERT_OUT" grep -q '"cancellations":1' "$FAKE_ASSERT_OUT" fi FAILURE_MATCHES="$TMP_DIR/failure-matches.txt" grep -i -E "node reported error|error run_id=|\[[^]]+-evt\] error|panic:" "$EDGE_OUT" "$NODE_OUT" > "$FAILURE_MATCHES" || true if [ "$MODE" = "fake" ]; then UNEXPECTED_FAILURES="$TMP_DIR/unexpected-failures.txt" grep -vi -E "response_stalled|provider response stalled" "$FAILURE_MATCHES" > "$UNEXPECTED_FAILURES" || true else UNEXPECTED_FAILURES="$FAILURE_MATCHES" fi if [ -s "$UNEXPECTED_FAILURES" ]; then echo "[openai-lemonade] detected failure marker" echo "[openai-lemonade] raw runtime output retained only in the temporary artifact directory" exit 1 fi if [ "$MODE" = "fake" ]; then grep -qi -E "response_stalled|provider response stalled" "$EDGE_OUT" "$NODE_OUT" kill "$LEMONADE_PID" wait "$LEMONADE_PID" 2>/dev/null || true if kill -0 "$LEMONADE_PID" 2>/dev/null; then echo "[openai-lemonade] fake provider survived teardown" exit 1 fi LEMONADE_PID="" fi echo "[openai-lemonade] OpenAI-compatible Lemonade serving test PASSED (mode=$MODE)."