Pantheon/tests/serve_tools_smoke.sh

154 lines
6.7 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2026 David (Corey) Herrera
#
# serve_tools_smoke.sh — end-to-end smoke test for OpenAI tool-calling in
# `pantheon serve`. Runs on the host CPU path (no K80). Exercises:
# 1. non-stream tool call -> message.tool_calls + finish_reason tool_calls
# 2. multi-step tool loop -> feed the tool result back, get a final answer
# 3. streaming tool call -> OpenAI tool-call delta sequence + [DONE]
#
# Usage:
# tests/serve_tools_smoke.sh [MODEL_GGUF] [PORT]
# With no MODEL_GGUF, uses $PANTHEON_TEST_MODEL, else the first .gguf in models/.
# Default port 8082.
#
# Requires: a built ./pantheon, curl, python3.
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:-8082}"
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"
# A single-function tool the model can obviously use.
TOOLS='[{"type":"function","function":{"name":"get_weather","description":"Get the current weather for a city.","parameters":{"type":"object","properties":{"location":{"type":"string","description":"City name, e.g. Paris"},"unit":{"type":"string","enum":["celsius","fahrenheit"],"description":"Temperature unit"}},"required":["location"]}}}]'
echo "Starting server: $BIN serve --model $MODEL --port $PORT"
"$BIN" serve --model "$MODEL" --host "$HOST" --port "$PORT" >/tmp/serve_tools_smoke.log 2>&1 &
SERVER_PID=$!
for _ in $(seq 1 180); do
grep -q "listening on" /tmp/serve_tools_smoke.log && break
kill -0 "$SERVER_PID" 2>/dev/null || fail "server exited early; see /tmp/serve_tools_smoke.log"
sleep 1
done
grep -q "listening on" /tmp/serve_tools_smoke.log || fail "server did not come up in time"
pass "server is listening"
# ---------------------------------------------------------------------------
# 1. Non-streaming tool call
# ---------------------------------------------------------------------------
REQ1="$(python3 -c '
import json,sys
tools=json.loads(sys.argv[1])
print(json.dumps({
"messages":[
{"role":"system","content":"You are a helpful assistant. Use tools when appropriate."},
{"role":"user","content":"What is the weather in Paris in celsius? Use the get_weather tool."}
],
"tools":tools,"tool_choice":"auto","temperature":0,"max_tokens":512
}))' "$TOOLS")"
RESP1="$(curl -s "${BASE}/v1/chat/completions" -H 'Content-Type: application/json' -d "$REQ1")"
echo "--- non-stream response ---"
echo "$RESP1" | python3 -m json.tool 2>/dev/null || echo "$RESP1"
echo "$RESP1" | python3 -c '
import sys,json
d=json.load(sys.stdin)
ch=d["choices"][0]
msg=ch["message"]
tc=msg.get("tool_calls")
assert tc, "no tool_calls in message: %r"%msg
call=tc[0]
assert call["type"]=="function"
assert call["id"].startswith("call_"), call["id"]
fn=call["function"]
assert fn["name"]=="get_weather", fn["name"]
args=json.loads(fn["arguments"]) # arguments MUST be a JSON string
assert "location" in args, args
assert "paris" in args["location"].lower(), args
assert ch["finish_reason"]=="tool_calls", ch["finish_reason"]
print(" tool_call id:", call["id"])
print(" function :", fn["name"])
print(" arguments :", fn["arguments"])
print(" finish :", ch["finish_reason"])
' || fail "non-stream tool call not emitted correctly"
pass "non-stream tool call (finish_reason=tool_calls)"
# Grab the emitted call to feed back.
CALL_JSON="$(echo "$RESP1" | python3 -c 'import sys,json; print(json.dumps(json.load(sys.stdin)["choices"][0]["message"]["tool_calls"][0]))')"
# ---------------------------------------------------------------------------
# 2. Multi-step: feed the tool result back as role:"tool"
# ---------------------------------------------------------------------------
REQ2="$(python3 -c '
import json,sys
tools=json.loads(sys.argv[1]); call=json.loads(sys.argv[2])
print(json.dumps({
"messages":[
{"role":"system","content":"You are a helpful assistant. Use tools when appropriate."},
{"role":"user","content":"What is the weather in Paris in celsius? Use the get_weather tool."},
{"role":"assistant","content":"","tool_calls":[call]},
{"role":"tool","tool_call_id":call["id"],"content":"{\"temperature\":\"18\",\"unit\":\"celsius\",\"conditions\":\"partly cloudy\"}"}
],
"tools":tools,"temperature":0,"max_tokens":512
}))' "$TOOLS" "$CALL_JSON")"
RESP2="$(curl -s "${BASE}/v1/chat/completions" -H 'Content-Type: application/json' -d "$REQ2")"
echo "--- multi-step (post tool-result) response ---"
echo "$RESP2" | python3 -m json.tool 2>/dev/null || echo "$RESP2"
echo "$RESP2" | python3 -c '
import sys,json
d=json.load(sys.stdin)
msg=d["choices"][0]["message"]
content=msg.get("content") or ""
assert content.strip(), "empty final content: %r"%msg
assert "18" in content or "cloud" in content.lower(), "answer should reflect tool result: %r"%content
print(" final answer:", repr(content[:160]))
' || fail "multi-step tool-result continuation failed"
pass "multi-step tool loop (tool result -> final answer)"
# ---------------------------------------------------------------------------
# 3. Streaming tool call
# ---------------------------------------------------------------------------
REQ3="$(python3 -c '
import json,sys
tools=json.loads(sys.argv[1])
print(json.dumps({
"messages":[
{"role":"user","content":"What is the weather in Tokyo? Use the get_weather tool."}
],
"tools":tools,"temperature":0,"max_tokens":512,"stream":True
}))' "$TOOLS")"
STREAM="$(curl -sN "${BASE}/v1/chat/completions" -H 'Content-Type: application/json' -d "$REQ3")"
echo "--- streaming tool-call chunks ---"
echo "$STREAM" | grep -F '"tool_calls"'
echo "$STREAM" | grep -q '"delta":{"role":"assistant"}' || fail "no opening role delta"
echo "$STREAM" | grep -q '"tool_calls":\[{"index":0,"id":"call_' || fail "no opening tool_call delta (index/id/type/name)"
echo "$STREAM" | grep -q '"function":{"name":"get_weather","arguments":""}' || fail "opening tool_call delta missing name / empty arguments"
echo "$STREAM" | grep -q '"function":{"arguments":' || fail "no streamed arguments fragment"
echo "$STREAM" | grep -q '"finish_reason":"tool_calls"' || fail "no tool_calls finish_reason"
echo "$STREAM" | grep -q '^data: \[DONE\]' || fail "missing [DONE] terminator"
pass "streaming tool call (delta sequence + [DONE])"
echo
echo "ALL TOOL SMOKE TESTS PASSED"