#!/usr/bin/env bash # chatd smoke — Phase 4 acceptance gate. # # Validates: # - chatd boots and reports providers via GET /v1/chat/providers # - bare model name routes to local Ollama (default provider) # - explicit "ollama/" prefix also routes to local Ollama # - :cloud suffix without ollama_cloud key registered → 404 # - unknown/foo prefix falls through to Ollama default # - POST /v1/chat returns provider-stamped response with token counts # # Requires: Ollama on :11434 with qwen3.5:latest (or any chat-capable # model — override via SMOKE_MODEL env). Skips (exit 0) if Ollama is # absent so this can run on CI boxes without local Ollama. # # Usage: ./scripts/chatd_smoke.sh set -euo pipefail cd "$(dirname "$0")/.." export PATH="$PATH:/usr/local/go/bin" SMOKE_MODEL="${SMOKE_MODEL:-qwen3.5:latest}" if ! curl -sS --max-time 3 http://localhost:11434/api/tags >/dev/null 2>&1; then echo "[chatd-smoke] Ollama not reachable on :11434 — skipping" exit 0 fi if ! curl -sS http://localhost:11434/api/tags | jq -e --arg m "$SMOKE_MODEL" \ '.models[] | select(.name == $m)' >/dev/null 2>&1; then echo "[chatd-smoke] $SMOKE_MODEL not loaded in Ollama — skipping" exit 0 fi echo "[chatd-smoke] building chatd + gateway..." go build -o bin/ ./cmd/chatd ./cmd/gateway pkill -f "bin/(chatd|gateway)" 2>/dev/null || true sleep 0.3 PIDS=() TMP="$(mktemp -d)" CFG="$TMP/chatd.toml" cleanup() { echo "[chatd-smoke] cleanup" for p in "${PIDS[@]:-}"; do [ -n "${p:-}" ] && kill "$p" 2>/dev/null || true; done rm -rf "$TMP" } trap cleanup EXIT INT TERM # Test config: chatd talks only to local Ollama (no cloud keys). # This proves the bare-name + prefix-routing + :cloud-without-cloud # behaviors without needing API keys in CI. cat > "$CFG" </dev/null 2>&1; then return 0; fi sleep 0.05 done return 1 } echo "[chatd-smoke] launching chatd → gateway..." ./bin/chatd -config "$CFG" > /tmp/chatd.log 2>&1 & PIDS+=($!) poll_health 3220 || { echo "chatd failed"; tail /tmp/chatd.log; exit 1; } ./bin/gateway -config "$CFG" > /tmp/gateway.log 2>&1 & PIDS+=($!) poll_health 3110 || { echo "gateway failed"; tail /tmp/gateway.log; exit 1; } # 1. providers listing — only ollama registered when other keys absent echo "[chatd-smoke] /v1/chat/providers — only ollama registered:" RESP="$(curl -sS http://127.0.0.1:3110/v1/chat/providers)" PROVIDERS_COUNT="$(echo "$RESP" | jq -r '.providers | length')" OLLAMA_AVAIL="$(echo "$RESP" | jq -r '.providers.ollama')" if [ "$PROVIDERS_COUNT" != "1" ] || [ "$OLLAMA_AVAIL" != "true" ]; then echo " ✗ wanted only ollama=true; got $RESP" exit 1 fi echo " ✓ exactly 1 provider (ollama, available=true)" # 2. bare model name → ollama default echo "[chatd-smoke] POST /v1/chat with bare model name:" RESP="$(curl -sS -X POST http://127.0.0.1:3110/v1/chat \ -H 'Content-Type: application/json' \ -d "{\"model\":\"$SMOKE_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"reply with the word ok and nothing else\"}],\"max_tokens\":10}")" PROV="$(echo "$RESP" | jq -r '.provider')" CONTENT="$(echo "$RESP" | jq -r '.content')" LATENCY="$(echo "$RESP" | jq -r '.latency_ms')" if [ "$PROV" != "ollama" ] || [ -z "$CONTENT" ] || [ "$LATENCY" -lt 0 ] 2>/dev/null; then echo " ✗ expected provider=ollama, non-empty content, positive latency; got $RESP" exit 1 fi echo " ✓ provider=ollama, latency=${LATENCY}ms, content=$(echo "$CONTENT" | head -c 60 | tr -d '\n')…" # 3. explicit ollama/ prefix (prefix stripped before upstream call) echo "[chatd-smoke] POST /v1/chat with explicit ollama/ prefix:" RESP="$(curl -sS -X POST http://127.0.0.1:3110/v1/chat \ -H 'Content-Type: application/json' \ -d "{\"model\":\"ollama/$SMOKE_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"reply ok\"}],\"max_tokens\":5}")" PROV="$(echo "$RESP" | jq -r '.provider')" MODEL="$(echo "$RESP" | jq -r '.model')" if [ "$PROV" != "ollama" ] || [ "$MODEL" != "$SMOKE_MODEL" ]; then echo " ✗ expected provider=ollama, model=$SMOKE_MODEL (prefix stripped); got prov=$PROV model=$MODEL" exit 1 fi echo " ✓ ollama/$SMOKE_MODEL → provider=ollama, model=$SMOKE_MODEL (prefix stripped)" # 4. :cloud suffix without ollama_cloud registered → 404 echo "[chatd-smoke] POST /v1/chat with :cloud suffix (no cloud provider):" STATUS="$(curl -sS -o /tmp/cloud404.json -w '%{http_code}' -X POST http://127.0.0.1:3110/v1/chat \ -H 'Content-Type: application/json' \ -d '{"model":"kimi-k2.6:cloud","messages":[{"role":"user","content":"hi"}]}')" if [ "$STATUS" != "404" ]; then echo " ✗ expected 404; got $STATUS body=$(cat /tmp/cloud404.json)" exit 1 fi echo " ✓ kimi-k2.6:cloud → 404 (ollama_cloud not registered, no silent fall-through to local)" # 5. unknown prefix falls through to ollama default; upstream 502s # because the ollama provider doesn't strip unknown prefixes # (it would silently rewrite operator-supplied names). Expected # behavior: route to default provider, let upstream reject the # literal model name. echo "[chatd-smoke] POST /v1/chat with unknown/ prefix (falls through, upstream 502s):" STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:3110/v1/chat \ -H 'Content-Type: application/json' \ -d "{\"model\":\"unknown/$SMOKE_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":5}")" if [ "$STATUS" != "502" ]; then echo " ✗ expected 502 (upstream rejects literal 'unknown/...'); got $STATUS" exit 1 fi echo " ✓ unknown/ → ollama default → upstream 502 (no silent prefix-strip)" # 6. missing model field → 400 echo "[chatd-smoke] POST /v1/chat with missing model field:" STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:3110/v1/chat \ -H 'Content-Type: application/json' \ -d '{"messages":[{"role":"user","content":"hi"}]}')" if [ "$STATUS" != "400" ]; then echo " ✗ expected 400 for missing model; got $STATUS" exit 1 fi echo " ✓ missing model → 400" echo "[chatd-smoke] chatd acceptance gate: PASSED (6/6)"