iop/scripts/e2e-openai-lemonade.sh
toki cc87db264b feat: sync edge module validation and E2E script updates
- Add mock resource activation for G06 review followup alignment
- Expand edgevalidate tests and refactor validation logic
- Update node mapper with improved handling
- Add integration and server test fixes
- Update Go config with new options
- Fix E2E scripts (cli-workspace, lemonade, ollama, vllm)
2026-07-07 04:38:31 +09:00

296 lines
9.6 KiB
Bash
Executable file

#!/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_DIR=$(mktemp -d)
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; fi
if [ -n "$LEMONADE_PID" ]; then kill "$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"
"log"
"net/http"
"os"
)
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) < 3 {
log.Fatal("usage: fake_lemonade <listen-addr> <model>")
}
model := os.Args[2]
mux := http.NewServeMux()
mux.HandleFunc("/v1/models", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"` + model + `"}]}`))
})
mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
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
}
if !req.Stream {
http.Error(w, "expected stream=true from openai_compat adapter", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
flush := func() {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
_, _ = 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()
})
log.Fatal(http.ListenAndServe(os.Args[1], mux))
}
EOF
go run "$FAKE_LEMONADE" "127.0.0.1:$LEMONADE_PORT" "$MODEL" > "$TMP_DIR/fake_lemonade.out" 2>&1 &
LEMONADE_PID=$!
wait_port 127.0.0.1 "$LEMONADE_PORT" "fake lemonade"
fi
# Build the node openai_compat adapter block. Auth headers, when provided, are
# written only into the generated temp config (never echoed).
ADAPTER_BLOCK="$TMP_DIR/adapter_block.yaml"
{
echo " openai_compat_instances:"
echo " - name: lemonade-local"
echo " enabled: true"
echo " provider: lemonade"
echo " endpoint: \"$NODE_ENDPOINT\""
echo " capacity: 4"
echo " queue_timeout_ms: 5000"
if [ -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
} > "$ADAPTER_BLOCK"
EDGE_CONFIG="$TMP_DIR/edge.yaml"
NODE_CONFIG="$TMP_DIR/node.yaml"
cat > "$EDGE_CONFIG" <<EOF
server:
listen: "127.0.0.1:$PORT"
metrics:
port: $EDGE_METRICS_PORT
bootstrap:
listen: "127.0.0.1:$BOOTSTRAP_PORT"
artifact_dir: "$TMP_DIR/artifacts"
openai:
enabled: true
listen: "127.0.0.1:$OPENAI_PORT"
adapter: "openai_compat"
session_id: "cline"
timeout_sec: 30
strict_output: false
model_routes:
- model: "client-request-model"
adapter: "openai_compat"
target: "$MODEL"
node: "test-node"
- model: "$MODEL"
adapter: "openai_compat"
target: "$MODEL"
node: "test-node"
console:
adapter: openai_compat
target: "$MODEL"
session_id: default
nodes:
- id: test-node
alias: test-node
token: test-token
adapters:
$(cat "$ADAPTER_BLOCK")
EOF
cat > "$NODE_CONFIG" <<EOF
transport:
edge_addr: "127.0.0.1:$PORT"
token: test-token
reconnect_interval: 1s
metrics:
port: $NODE_METRICS_PORT
node:
id: test-node
alias: test-node
EOF
EDGE_OUT="$TMP_DIR/edge.out"
NODE_OUT="$TMP_DIR/node.out"
mkfifo "$TMP_DIR/edge_fifo"
IOP_EDGE_CONFIG="$EDGE_CONFIG" "$REPO_ROOT/scripts/dev/edge.sh" < "$TMP_DIR/edge_fifo" > "$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"
# 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 grep -i -E "node reported error|error run_id=|\[[^]]+-evt\] error|panic:" "$EDGE_OUT" "$NODE_OUT" >/dev/null; then
echo "[openai-lemonade] detected failure marker"
echo "=== EDGE OUTPUT ==="; cat "$EDGE_OUT"
echo "=== NODE OUTPUT ==="; cat "$NODE_OUT"
exit 1
fi
echo "[openai-lemonade] OpenAI-compatible Lemonade serving test PASSED (mode=$MODE)."