Stream Gate 준비 실패에서도 요청 terminal 관측 계약을 지키고, direct provider identity 마이그레이션 누락으로 기존 검증과 운영 설정이 깨지지 않게 한다.
297 lines
9.6 KiB
Bash
Executable file
297 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"
|
|
provider_id: "lemonade-smoke-provider"
|
|
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)."
|