385 lines
13 KiB
Bash
Executable file
385 lines
13 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_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; 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"
|
|
"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 <listen-addr> <model> <authorization>")
|
|
}
|
|
model := os.Args[2]
|
|
authorization := os.Args[3]
|
|
var modelRequests atomic.Int64
|
|
var chatRequests atomic.Int64
|
|
var responseRequests 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 + `"}]}`))
|
|
})
|
|
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()
|
|
}
|
|
}
|
|
_, _ = 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()
|
|
if models < 1 || chats != 2 || responses != 1 {
|
|
http.Error(w, fmt.Sprintf("unexpected request counts: models=%d chats=%d responses=%d", models, chats, responses), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = fmt.Fprintf(w, `{"models":%d,"chats":%d,"responses":%d}`, models, chats, responses)
|
|
})
|
|
log.Fatal(http.ListenAndServe(os.Args[1], mux))
|
|
}
|
|
EOF
|
|
|
|
FAKE_AUTHORIZATION="Bearer fake-lemonade-token"
|
|
go run "$FAKE_LEMONADE" "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" <<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"
|
|
session_id: "cline"
|
|
timeout_sec: 30
|
|
strict_output: false
|
|
protocol_profiles:
|
|
lemonade_smoke:
|
|
base: openai
|
|
model_mapping:
|
|
lemonade-profile-alias: "$MODEL"
|
|
models:
|
|
- id: "client-request-model"
|
|
providers:
|
|
lemonade-smoke-provider: "lemonade-profile-alias"
|
|
- id: "$MODEL"
|
|
providers:
|
|
lemonade-smoke-provider: "lemonade-profile-alias"
|
|
console:
|
|
session_id: default
|
|
nodes:
|
|
- id: test-node
|
|
alias: test-node
|
|
token: test-token
|
|
adapters:
|
|
openai_compat_instances:
|
|
- name: "lemonade-smoke-backing"
|
|
enabled: true
|
|
provider: lemonade
|
|
endpoint: "$NODE_ENDPOINT"
|
|
capacity: 4
|
|
$(cat "$PROVIDER_HEADER_BLOCK")
|
|
providers:
|
|
- id: "lemonade-smoke-provider"
|
|
type: lemonade
|
|
category: api
|
|
adapter: "lemonade-smoke-backing"
|
|
profile: lemonade_smoke
|
|
models:
|
|
- "lemonade-profile-alias"
|
|
health: available
|
|
capacity: 4
|
|
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 [ "$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":2' "$FAKE_ASSERT_OUT"
|
|
grep -q '"responses":1' "$FAKE_ASSERT_OUT"
|
|
fi
|
|
|
|
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)."
|