Pantheon/tests/serve_smoke.sh

115 lines
5.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 David (Corey) Herrera
#
# serve_smoke.sh — end-to-end smoke test for `pantheon serve`.
#
# Starts the OpenAI-compatible server on the host CPU path, then exercises:
# 1. GET /v1/models -> OpenAI model list shape
# 2. POST /v1/chat/completions -> non-streaming chat.completion
# 3. POST /v1/chat/completions (stream) -> SSE chunks + [DONE] framing
# 4. error handling -> OpenAI-shaped error JSON
#
# Usage:
# tests/serve_smoke.sh [MODEL_GGUF] [PORT]
# With no MODEL_GGUF, uses $PANTHEON_TEST_MODEL, else the first .gguf in models/.
# Default port 8081.
#
# Requires: a built ./pantheon, curl, python3 (for JSON assertions).
set -u
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Model: first argument, else $PANTHEON_TEST_MODEL, else the first .gguf in models/.
MODEL="${1:-${PANTHEON_TEST_MODEL:-$(ls "$REPO_DIR"/models/*.gguf 2>/dev/null | head -1)}}"
PORT="${2:-8081}"
HOST="127.0.0.1"
BASE="http://${HOST}:${PORT}"
BIN="${REPO_DIR}/pantheon"
fail() { echo "FAIL: $*" >&2; cleanup; exit 1; }
pass() { echo "PASS: $*"; }
SERVER_PID=""
cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null; }
trap cleanup EXIT
[ -x "$BIN" ] || fail "pantheon binary not built (run: make USE_BLAS=1 pantheon)"
[ -f "$MODEL" ] || fail "model not found: $MODEL"
echo "Starting server: $BIN serve --model $MODEL --port $PORT"
"$BIN" serve --model "$MODEL" --host "$HOST" --port "$PORT" >/tmp/serve_smoke.log 2>&1 &
SERVER_PID=$!
# Wait for the listen line (model load can take a while).
for _ in $(seq 1 120); do
grep -q "listening on" /tmp/serve_smoke.log && break
kill -0 "$SERVER_PID" 2>/dev/null || fail "server exited early; see /tmp/serve_smoke.log"
sleep 1
done
grep -q "listening on" /tmp/serve_smoke.log || fail "server did not come up in time"
pass "server is listening"
# 1. GET /v1/models
MODELS="$(curl -s "${BASE}/v1/models")"
echo "$MODELS" | python3 -c 'import sys,json; d=json.load(sys.stdin); assert d["object"]=="list"; assert d["data"][0]["id"]; print(" model id:",d["data"][0]["id"])' \
|| fail "/v1/models did not return a valid OpenAI model list"
pass "/v1/models"
# 2. Non-streaming completion
RESP="$(curl -s "${BASE}/v1/chat/completions" -H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"What is 2+2? Reply with just the number."}],"temperature":0,"max_tokens":200}')"
echo "$RESP" | python3 -c '
import sys,json
d=json.load(sys.stdin)
assert d["object"]=="chat.completion", d
msg=d["choices"][0]["message"]
assert msg["role"]=="assistant"
assert isinstance(msg["content"],str) and msg["content"].strip(), "empty content"
assert d["usage"]["completion_tokens"]>0
print(" content:",repr(msg["content"][:80]))
print(" finish:",d["choices"][0]["finish_reason"])
' || fail "non-streaming completion invalid"
pass "non-streaming /v1/chat/completions"
# 3. Streaming completion (SSE)
STREAM="$(curl -sN "${BASE}/v1/chat/completions" -H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"Say hello in 3 words."}],"temperature":0,"max_tokens":120,"stream":true}')"
echo "$STREAM" | grep -q '"object":"chat.completion.chunk"' || fail "no SSE chunk objects"
echo "$STREAM" | grep -q '"delta":{"role":"assistant"}' || fail "no opening role delta"
# Any terminal reason is fine here — this asserts SSE framing, not model
# behaviour. A reasoning model can legitimately hit "length" inside <think>.
echo "$STREAM" | grep -qE '"finish_reason":"(stop|length)"' || fail "no terminal finish_reason"
echo "$STREAM" | grep -q '^data: \[DONE\]' || fail "missing [DONE] terminator"
# A reasoning model streams reasoning_content deltas until </think>; either
# kind proves deltas are flowing, which is what this test is checking.
echo "$STREAM" | grep -qE '"delta":\{"(reasoning_)?content":' || fail "no content deltas"
pass "streaming /v1/chat/completions (SSE + [DONE])"
# 4. Error handling
CODE="$(curl -s -o /tmp/serve_err.json -w '%{http_code}' "${BASE}/v1/chat/completions" \
-H 'Content-Type: application/json' -d '{bad json')"
[ "$CODE" = "400" ] || fail "malformed JSON should be 400 (got $CODE)"
python3 -c 'import json; d=json.load(open("/tmp/serve_err.json")); assert d["error"]["type"]' \
|| fail "error response not OpenAI-shaped"
pass "error handling (400 + OpenAI error shape)"
echo
echo "ALL SMOKE TESTS PASSED"
# ----------------------------------------------------------------------------
# opencode provider config — point opencode at this server. In opencode.json:
#
# {
# "provider": {
# "pantheon": {
# "npm": "@ai-sdk/openai-compatible",
# "name": "Pantheon (local)",
# "options": { "baseURL": "http://127.0.0.1:8080/v1" },
# "models": { "nex-agi_Nex-N2-mini-Q3_K_M.gguf": { "name": "Nex-N2-mini" } }
# }
# }
# }
#
# The model id must match GET /v1/models (the gguf file name, or --served-name).
# ----------------------------------------------------------------------------