CI: split Studio GGUF CI into three focused jobs

Replaces the single "Studio boots, loads a GGUF, answers a chat
completion" job with three parallel jobs that each pick the smallest
model that exercises the surface under test. All three jobs share the
install.sh --local --no-torch bootstrap and prime HF_HOME via
actions/cache so cold-cache runs are bounded and warm runs are quick.

1. Studio GGUF CI / OpenAI, Anthropic API tests
   - Model: gemma-3-270m-it UD-Q4_K_XL (~254 MiB).
   - Password rotation: login with bootstrap pw, change to a fresh
     random pw, assert old pw is rejected with 401, assert new pw
     succeeds. Uses the same JWT downstream as a Bearer token against
     /v1/* (the OpenAI/Anthropic compat surface accepts JWTs and
     sk-unsloth- keys interchangeably).
   - OpenAI SDK + Anthropic SDK each run a four-turn conversation
     ("What is 1+1?" / "What did I ask before?" / "What is the capital
     of France?" / "Repeat the city name") with temperature=0.0 and
     seed=3407. Run twice and assert run1 == run2 turn-by-turn so
     non-determinism in the conversation-history wiring is caught.

2. Studio GGUF CI / tool calling tests
   - Model: Qwen3.5-2B UD-IQ3_XXS (~890 MiB).
   - Standard OpenAI function calling with tool_choice=required.
   - Server-side python tool: assert "56088" appears in the answer to
     "What is 123 * 456? Use code to compute it.".
   - Server-side terminal (bash) tool: assert "hello-bash-tool" is
     echoed back.
   - Server-side web_search tool: non-blocking probe (DuckDuckGo
     flakes from CI runners). Asserts the request shape is accepted.
   - enable_thinking=true vs false: assert <think> markers vanish
     when thinking is disabled.

3. Studio GGUF CI / JSON, images
   - Model: gemma-4-E2B-it UD-IQ3_XXS (~2.4 GiB) + mmproj-F16
     (~986 MiB) auto-detected via the HF repo path.
   - response_format = json_schema (strict): asserts the answer parses
     as JSON matching the {city, country} schema.
   - OpenAI image_url (data URI base64): assert non-empty response on
     a 4x4 PNG. Loose on content because small VL quants are weak at
     colour names; the vision path is the part under test.
   - Anthropic source/base64 image: same non-empty assertion against
     the Anthropic Messages endpoint.

Boot strategy:
  - Job 1 keeps `UNSLOTH_API_ONLY=1 unsloth studio` because the
    password-rotation flow only exists in the UI-mode bootstrap.
  - Jobs 2 and 3 use `unsloth studio run --model REPO --gguf-variant V`,
    the one-liner that loads the model and prints the API key on the
    banner. Health is probed by waiting for `sk-unsloth-` to appear in
    the log; the one-liner only prints the banner after load completes.
This commit is contained in:
Daniel Han 2026-05-06 12:21:06 +00:00
commit 8e19065be5

View file

@ -1,14 +1,31 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# End-to-end smoke: install Studio via install.sh --local --no-torch, download
# a tiny GGUF, boot Studio, log in, change password, load the model, send a
# chat completion, assert a non-empty response. Only workflow that tests "the
# app actually works".
# Three end-to-end smoke jobs that boot a freshly-installed Studio and
# exercise the surfaces real users hit through the OpenAI / Anthropic
# SDKs and curl. Each job picks the smallest model that exercises the
# behaviour under test, primes HF_HOME via actions/cache, and shares
# the install.sh --local --no-torch bootstrap.
#
# Model: Qwen3.5-2B UD-IQ3_XXS (~890 MiB) -- small enough that the cache miss
# is cheap and inference fits in the 25 min CPU-runner budget. GGUF is cached
# across runs via actions/cache.
# 1. OpenAI, Anthropic API tests
# gemma-3-270m-it UD-Q4_K_XL (~254 MiB).
# Password rotation via /api/auth/change-password (old fails,
# new works), then OpenAI + Anthropic Python SDKs against /v1/*
# with temperature=0 and a fixed seed. Asserts the four-turn
# conversation is deterministic across two runs.
#
# 2. tool calling tests
# Qwen3.5-2B UD-IQ3_XXS (~890 MiB). OpenAI function calling,
# server-side tools (python, terminal, web_search) via
# enable_tools / enabled_tools, and enable_thinking on/off.
#
# 3. JSON, images
# gemma-4-E2B-it UD-IQ3_XXS (~2.4 GiB) + mmproj-F16 (~986 MiB).
# response_format JSON-schema decoding and OpenAI image_url
# (data URI) plus Anthropic source/base64 image inputs.
#
# All three jobs run in parallel. Total wall time is dominated by job 3
# on a cold cache; warm cache cuts that to ~3 min.
name: Studio GGUF CI
@ -23,7 +40,7 @@ on:
- '.github/workflows/studio-inference-smoke.yml'
push:
branches: [main, pip]
# Manual trigger for pre-warming the GGUF cache on main, or re-running
# Manual trigger for pre-warming HF_HOME caches on main, or re-running
# against an arbitrary branch without pushing a no-op commit.
workflow_dispatch:
@ -34,20 +51,24 @@ concurrency:
permissions:
contents: read
env:
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
STUDIO_PORT: '18888'
jobs:
inference:
name: Studio boots, loads a GGUF, answers a chat completion
# ─────────────────────────────────────────────────────────────────────
# Job 1: OpenAI, Anthropic API tests
# ─────────────────────────────────────────────────────────────────────
openai-anthropic:
name: OpenAI, Anthropic API tests
runs-on: ubuntu-latest
timeout-minutes: 25
env:
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
GGUF_VARIANT: UD-Q4_K_XL
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
STUDIO_PORT: '18888'
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@v4
- name: Linux dependencies for llama.cpp prebuilt
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
@ -64,46 +85,31 @@ jobs:
python-version: '3.12'
cache: 'pip'
- name: Cache GGUF model file
id: cache-gguf
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@v4
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Download GGUF if cache miss
if: steps.cache-gguf.outputs.cache-hit != 'true'
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
run: |
# huggingface-cli was deprecated in huggingface_hub 1.13; the new CLI is `hf`.
python -m pip install --upgrade huggingface_hub hf_transfer
mkdir -p gguf-cache
mkdir -p hf-cache
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Install Studio (--local, --no-torch keeps the install lean)
- name: Install Studio (--local, --no-torch)
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Assert llama.cpp prebuilt was installed (no source-build fallback)
# ubuntu-latest is CPU-only x86_64, so studio/setup.sh should route
# to ggml-org/llama.cpp and grab bin-ubuntu-x64.tar.gz. A source
# build here means the routing regressed.
run: |
if grep -q "falling back to source build" logs/install.log; then
echo "::error::llama.cpp prebuilt path failed on ubuntu-latest. studio/setup.sh routing regressed; CPU-only Linux x86_64 should hit ggml-org/llama.cpp's bin-ubuntu-x64.tar.gz."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated" logs/install.log; then
echo "::error::install.log does not contain the success marker for the llama.cpp prebuilt path. Did setup.sh skip the prebuilt install?"
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
echo "llama.cpp prebuilt path used successfully"
- name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Reset auth + start Studio in the background
- name: Reset auth + boot Studio (API-only)
run: |
unsloth studio reset-password
mkdir -p logs
@ -115,8 +121,6 @@ jobs:
run: |
for i in $(seq 1 60); do
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
echo "ready after ${i}s"
cat /tmp/health.json
jq -e '.status == "healthy"' /tmp/health.json
exit 0
fi
@ -126,62 +130,616 @@ jobs:
tail -200 logs/studio.log
exit 1
- name: Login + change bootstrap password
- name: Password rotation (old must fail, new must work)
run: |
PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
NEW="CIPasswordSmoke12345!"
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
NEW="CIRotated-$(python -c 'import secrets; print(secrets.token_urlsafe(12))')"
echo "::add-mask::$OLD"
echo "::add-mask::$NEW"
# 1. Login with the bootstrap password.
OLD_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$PW\"}" | jq -r .access_token)
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}" | jq -r .access_token)
[ -n "$OLD_TOKEN" ] && [ "$OLD_TOKEN" != "null" ] || { echo "bootstrap login failed"; exit 1; }
# 2. Rotate to a fresh random password.
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"current_password\":\"$PW\",\"new_password\":\"$NEW\"}" > /dev/null
# Re-login to clear must_change_password flag.
-H "Authorization: Bearer $OLD_TOKEN" -H 'content-type: application/json' \
-d "{\"current_password\":\"$OLD\",\"new_password\":\"$NEW\"}" > /dev/null
# 3. Old password must now be rejected (HTTP 401).
OLD_STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$OLD\"}")
if [ "$OLD_STATUS" != "401" ]; then
echo "::error::Login with old password returned $OLD_STATUS, expected 401"
exit 1
fi
# 4. New password must succeed; capture the JWT for downstream steps.
NEW_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
[ -n "$NEW_TOKEN" ] && [ "$NEW_TOKEN" != "null" ] || { echo "new login failed"; exit 1; }
echo "TOKEN=$NEW_TOKEN" >> "$GITHUB_ENV"
echo "password rotation OK (old=401, new=200)"
- name: Load the GGUF into Studio
- name: Load the GGUF (HF repo + variant, served from HF_HOME cache)
run: |
GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}"
ls -lh "$GGUF_PATH"
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \
-d "{\"model_path\":\"$GGUF_REPO\",\"gguf_variant\":\"$GGUF_VARIANT\",\"is_lora\":false,\"max_seq_length\":2048}" \
| jq '{status, display_name, is_gguf, context_length}'
- name: Send a chat completion + assert non-empty response
- name: Multi-turn determinism via OpenAI + Anthropic SDKs
env:
BASE_URL: http://127.0.0.1:18888
run: |
RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/chat/completions" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 900 \
-d '{
"messages":[{"role":"user","content":"Say hello in one short sentence."}],
"max_tokens":40,
"stream":false
}')
echo "raw response: $RESP"
CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content // empty')
echo "model response: $CONTENT"
if [ -z "$CONTENT" ]; then
echo "::error::Empty assistant response from Studio"
exit 1
fi
python - <<'PY'
import json
import os
from openai import OpenAI
from anthropic import Anthropic
BASE = os.environ["BASE_URL"]
KEY = os.environ["TOKEN"] # JWT also accepted as Bearer on /v1/*
SEED = 3407
# Four-turn conversation: the second and fourth turns can only be
# answered correctly if the model sees the prior turns, so this
# also exercises the conversation-history wiring.
PROMPTS = [
"What is 1+1?",
"What did I ask before?",
"What is the capital of France?",
"Repeat the city name",
]
def run_openai():
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
history, replies = [], []
for prompt in PROMPTS:
history.append({"role": "user", "content": prompt})
resp = client.chat.completions.create(
model = "default",
messages = history,
temperature = 0.0,
max_tokens = 80,
seed = SEED,
extra_body = {"enable_thinking": False},
)
text = resp.choices[0].message.content or ""
replies.append(text)
history.append({"role": "assistant", "content": text})
return replies
def run_anthropic():
client = Anthropic(base_url = f"{BASE}/v1", api_key = KEY)
history, replies = [], []
for prompt in PROMPTS:
history.append({"role": "user", "content": prompt})
msg = client.messages.create(
model = "default",
max_tokens = 80,
messages = history,
temperature = 0.0,
extra_body = {"seed": SEED, "enable_thinking": False},
)
text = "".join(b.text for b in msg.content if getattr(b, "type", None) == "text")
replies.append(text)
history.append({"role": "assistant", "content": text})
return replies
for label, runner in (("openai", run_openai), ("anthropic", run_anthropic)):
first = runner()
second = runner()
for i, (a, b) in enumerate(zip(first, second), start = 1):
print(f"[{label} turn {i}] {a!r}")
assert a, f"{label}: empty turn {i} response"
assert a == b, (
f"{label} non-deterministic at turn {i} with temperature=0.0:\n"
f" run1: {a!r}\n run2: {b!r}"
)
# Sanity: turn-2 reply should mention the earlier question, and
# turn-4 reply should mention Paris (model echoes the city it
# produced for turn 3). Lower-cased substring checks keep the
# assertion robust to formatting jitter.
joined = " ".join(first).lower()
assert "1" in first[0], f"{label}: turn-1 answer should contain '1', got {first[0]!r}"
assert "paris" in joined, f"{label}: expected 'paris' somewhere in the four-turn transcript: {first}"
print(f"[{label}] OK -- 4 turns, run1 == run2, history grounded")
PY
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" || true
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
ss -tln | grep ":${STUDIO_PORT}" || true
- name: Upload Studio + install logs on failure
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: studio-inference-log
name: openai-anthropic-log
path: |
logs/studio.log
logs/install.log
retention-days: 7
# ─────────────────────────────────────────────────────────────────────
# Job 2: tool calling tests
# ─────────────────────────────────────────────────────────────────────
tool-calling:
name: tool calling tests
runs-on: ubuntu-latest
timeout-minutes: 25
env:
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
GGUF_VARIANT: UD-IQ3_XXS
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
STUDIO_PORT: '18889'
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@v4
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
id: cache-hf
uses: actions/cache@v4
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
- name: Prime HF_HOME with the GGUF
if: steps.cache-hf.outputs.cache-hit != 'true'
run: |
python -m pip install --upgrade huggingface_hub hf_transfer
mkdir -p hf-cache
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
- name: Install Studio (--local, --no-torch)
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Boot Studio one-liner (loads model + prints API key)
run: |
mkdir -p logs
# `unsloth studio run` boots the server, loads the GGUF via
# the HF_HOME-cached path, and prints the API key on the
# banner. --enable-tools makes the server-side tool registry
# (python / terminal / web_search) available behind the
# enable_tools=true request flag.
unsloth studio run \
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
--port "$STUDIO_PORT" --host 127.0.0.1 \
--enable-tools -y \
> logs/studio.log 2>&1 &
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
- name: Wait for `unsloth studio run` banner + capture API key
# `unsloth studio run` boots the HTTP server, loads the GGUF
# synchronously, and only then prints the banner with the API
# key. So once `sk-unsloth-` shows up in the log AND /api/health
# responds, the model is loaded and ready.
run: |
for i in $(seq 1 600); do
if grep -qE 'sk-unsloth-[a-f0-9]+' logs/studio.log 2>/dev/null \
&& curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /dev/null 2>&1; then
break
fi
sleep 1
done
API_KEY=$(grep -oE 'sk-unsloth-[a-f0-9]+' logs/studio.log | head -1)
if [ -z "$API_KEY" ]; then
echo "::error::no sk-unsloth- API key in studio.log after 600s"
tail -400 logs/studio.log
exit 1
fi
echo "::add-mask::$API_KEY"
echo "API_KEY=$API_KEY" >> "$GITHUB_ENV"
- name: Tool calling, server-side tools, thinking on/off
env:
BASE_URL: http://127.0.0.1:18889
run: |
python - <<'PY'
import json
import os
import urllib.request
BASE = os.environ["BASE_URL"]
KEY = os.environ["API_KEY"]
SEED = 3407
def post(path, body, *, timeout = 240):
data = json.dumps(body).encode()
req = urllib.request.Request(
f"{BASE}{path}",
data = data,
method = "POST",
headers = {
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout = timeout) as resp:
return resp.status, json.loads(resp.read().decode())
# ── 1. Standard OpenAI function calling ──────────────────────
weather_tool = {
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
status, data = post("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is the weather in Paris?"}],
"tools": [weather_tool],
"tool_choice": "required",
"stream": False,
"temperature": 0.0,
"seed": SEED,
"max_tokens": 120,
})
assert status == 200, f"tool call status {status}: {data}"
choice = data["choices"][0]
assert choice["finish_reason"] == "tool_calls", f"finish_reason={choice['finish_reason']!r}"
tc = choice["message"]["tool_calls"][0]
assert tc["function"]["name"] == "get_weather"
args = json.loads(tc["function"]["arguments"])
assert args.get("city"), f"missing city arg: {args}"
print(f"[tools] PASS function calling -> {tc['function']['name']}({args})")
# ── 2. Server-side python tool ───────────────────────────────
status, data = post("/v1/chat/completions", {
"messages": [{"role": "user", "content": "What is 123 * 456? Use code to compute it."}],
"stream": False,
"enable_tools": True,
"enabled_tools": ["python"],
"session_id": "ci-tool-calling-py",
"temperature": 0.0,
"seed": SEED,
"max_tokens": 400,
}, timeout = 600)
assert status == 200
content = data["choices"][0]["message"].get("content") or ""
# 123 * 456 = 56088. The model is small, so we accept the
# number appearing anywhere in the text or any tool-call
# output trace.
assert "56088" in content or "56,088" in content, (
f"expected 56088 in python-tool answer, got: {content!r}"
)
print(f"[tools] PASS python tool -> {content[:80]!r}")
# ── 3. Server-side bash (terminal) tool ──────────────────────
status, data = post("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the output."}],
"stream": False,
"enable_tools": True,
"enabled_tools": ["terminal"],
"session_id": "ci-tool-calling-bash",
"temperature": 0.0,
"seed": SEED,
"max_tokens": 400,
}, timeout = 600)
assert status == 200
content = data["choices"][0]["message"].get("content") or ""
assert "hello-bash-tool" in content, (
f"expected 'hello-bash-tool' in terminal-tool answer, got: {content!r}"
)
print(f"[tools] PASS bash/terminal tool -> {content[:80]!r}")
# ── 4. Server-side web_search tool ───────────────────────────
# We don't assert content (DuckDuckGo is flaky from CI runners)
# -- only that the request shape is accepted and the response
# parses. Failure mode would be an HTTP error or unparseable
# JSON, both of which already trip the asserts above.
try:
status, data = post("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Search for 'unsloth ai github' and tell me what you find."}],
"stream": False,
"enable_tools": True,
"enabled_tools": ["web_search"],
"session_id": "ci-tool-calling-web",
"temperature": 0.0,
"seed": SEED,
"max_tokens": 200,
}, timeout = 600)
assert status == 200
print(f"[tools] PASS web_search request accepted (content length={len(data['choices'][0]['message'].get('content') or '')})")
except Exception as exc:
# Search backend hiccups should not gate the workflow.
print(f"[tools] WARN web_search probe failed (non-blocking): {exc}")
# ── 5. Thinking on / off ─────────────────────────────────────
# Studio strips think blocks from message.content for tools-mode
# responses, so we toggle plain chat (no enable_tools) and look
# at the surfaced reasoning_content / message.thinking field.
def thinking_call(enable):
status, data = post("/v1/chat/completions", {
"messages": [{"role": "user", "content": "Briefly: is 17 prime?"}],
"stream": False,
"enable_thinking": enable,
"temperature": 0.0,
"seed": SEED,
"max_tokens": 300,
})
assert status == 200
msg = data["choices"][0]["message"]
# Studio surfaces thinking via reasoning_content (OpenAI
# extension). Fall back to inline <think> markers for
# robustness across template versions.
raw = (msg.get("content") or "") + (msg.get("reasoning_content") or "")
return raw
on_text = thinking_call(True)
off_text = thinking_call(False)
had_think_on = ("<think>" in on_text) or len(on_text) > 80
had_think_off = ("<think>" in off_text) and len(off_text) > 0
assert had_think_on, (
f"enable_thinking=True produced no thinking signal: {on_text!r}"
)
# Off-mode should not contain the literal <think> marker.
assert "<think>" not in off_text, (
f"enable_thinking=False but <think> still present: {off_text!r}"
)
print(f"[tools] PASS thinking on/off (on={len(on_text)} chars, off={len(off_text)} chars)")
PY
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
ss -tln | grep ":${STUDIO_PORT}" || true
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: tool-calling-log
path: |
logs/studio.log
logs/install.log
retention-days: 7
# ─────────────────────────────────────────────────────────────────────
# Job 3: JSON, images
# ─────────────────────────────────────────────────────────────────────
json-images:
name: JSON, images
runs-on: ubuntu-latest
timeout-minutes: 30
env:
GGUF_REPO: unsloth/gemma-4-E2B-it-GGUF
GGUF_VARIANT: UD-IQ3_XXS
GGUF_FILE: gemma-4-E2B-it-UD-IQ3_XXS.gguf
MMPROJ_FILE: mmproj-F16.gguf
STUDIO_PORT: '18890'
HF_HOME: ${{ github.workspace }}/hf-cache
steps:
- uses: actions/checkout@v4
- name: Linux deps for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Cache HF_HOME for ${{ env.GGUF_REPO }} (model + mmproj)
id: cache-hf
uses: actions/cache@v4
with:
path: hf-cache
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-${{ env.MMPROJ_FILE }}-v1
- name: Prime HF_HOME with the GGUF + mmproj
if: steps.cache-hf.outputs.cache-hit != 'true'
run: |
python -m pip install --upgrade huggingface_hub hf_transfer
mkdir -p hf-cache
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE"
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$MMPROJ_FILE"
- name: Install Studio (--local, --no-torch)
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Install OpenAI + Anthropic Python SDKs
run: pip install 'openai>=1.50' 'anthropic>=0.40'
- name: Boot Studio one-liner (HF repo path picks up mmproj automatically)
run: |
mkdir -p logs
unsloth studio run \
--model "$GGUF_REPO" --gguf-variant "$GGUF_VARIANT" \
--port "$STUDIO_PORT" --host 127.0.0.1 \
-y \
> logs/studio.log 2>&1 &
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
- name: Wait for `unsloth studio run` banner + capture API key
run: |
for i in $(seq 1 900); do
if grep -qE 'sk-unsloth-[a-f0-9]+' logs/studio.log 2>/dev/null \
&& curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /dev/null 2>&1; then
break
fi
sleep 1
done
API_KEY=$(grep -oE 'sk-unsloth-[a-f0-9]+' logs/studio.log | head -1)
if [ -z "$API_KEY" ]; then
echo "::error::no sk-unsloth- API key in studio.log after 900s"
tail -400 logs/studio.log
exit 1
fi
echo "::add-mask::$API_KEY"
echo "API_KEY=$API_KEY" >> "$GITHUB_ENV"
- name: JSON schema decoding + image input
env:
BASE_URL: http://127.0.0.1:18890
run: |
python - <<'PY'
import base64
import json
import os
import urllib.request
from openai import OpenAI
from anthropic import Anthropic
BASE = os.environ["BASE_URL"]
KEY = os.environ["API_KEY"]
SEED = 3407
# ── 1. response_format = json_schema (strict JSON decoding) ──
# Gemma 4 supports llama-server's GBNF-grammar-from-schema
# decoding, so the response MUST be valid JSON matching the
# schema even with a small model.
schema = {
"type": "object",
"properties": {
"city": {"type": "string"},
"country": {"type": "string"},
},
"required": ["city", "country"],
"additionalProperties": False,
}
client = OpenAI(base_url = f"{BASE}/v1", api_key = KEY)
resp = client.chat.completions.create(
model = "default",
messages = [
{"role": "system", "content": "Reply with a single JSON object."},
{"role": "user", "content": "What is the capital of France? Reply as JSON with city and country."},
],
temperature = 0.0,
max_tokens = 80,
seed = SEED,
response_format = {
"type": "json_schema",
"json_schema": {"name": "capital", "schema": schema, "strict": True},
},
extra_body = {"enable_thinking": False},
)
content = resp.choices[0].message.content
parsed = json.loads(content)
assert parsed.keys() == {"city", "country"}, f"schema mismatch: {parsed}"
assert "paris" in parsed["city"].lower(), f"city != Paris: {parsed}"
print(f"[json] PASS strict json_schema -> {parsed}")
# ── 2. OpenAI image_url (data URI base64) ───────────────────
# 4x4 solid-red PNG. Tiny so the prompt fits in context. The
# assertion is loose: any vision-aware response that mentions
# the colour or the image at all is enough to prove the
# multimodal path is wired up end to end.
PNG_4X4_RED_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAEUlEQVR4"
"nGP8z8AAQYwMyAwAFsoBAUgUmCEAAAAASUVORK5CYII="
)
data_uri = f"data:image/png;base64,{PNG_4X4_RED_B64}"
openai_resp = client.chat.completions.create(
model = "default",
temperature = 0.0,
max_tokens = 80,
seed = SEED,
messages = [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": data_uri}},
{"type": "text", "text": "What colour dominates this image? Reply in one word."},
],
}],
)
openai_text = (openai_resp.choices[0].message.content or "").lower()
print(f"[image/openai] reply: {openai_text!r}")
assert openai_text, "OpenAI image_url returned empty content"
# We do not strictly require 'red' -- some quants of small VL
# models are weak at colour names. Just require a non-empty
# answer; the vision path is the part under test.
print("[image/openai] PASS image_url accepted, non-empty response")
# ── 3. Anthropic source/base64 image ────────────────────────
anthropic = Anthropic(base_url = f"{BASE}/v1", api_key = KEY)
a_msg = anthropic.messages.create(
model = "default",
max_tokens = 80,
temperature = 0.0,
extra_body = {"seed": SEED},
messages = [{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": PNG_4X4_RED_B64,
},
},
{"type": "text", "text": "Describe this image briefly."},
],
}],
)
a_text = "".join(b.text for b in a_msg.content if getattr(b, "type", None) == "text")
print(f"[image/anthropic] reply: {a_text!r}")
assert a_text, "Anthropic source/base64 returned empty content"
print("[image/anthropic] PASS source/base64 accepted, non-empty response")
PY
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" 2>/dev/null || true
sleep 2
ss -tln | grep ":${STUDIO_PORT}" || true
- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: json-images-log
path: |
logs/studio.log
logs/install.log