Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/model-smoke-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@
# CPU-only inference). The CI budget is the documented budget multiplied by
# a hardware-tier factor that accounts for the slower runner:
#
# CI budget = documented budget × CI_HARDWARE_FACTOR (default 6)
# CI budget = documented budget × CI_HARDWARE_FACTOR (currently 20)
#
# i.e. on a 2-vCPU runner the full-response budget is 10 s × 6 = 60 s. Any
# nightly run faster than this passes; a run exceeding 120 % of the CI budget
# fails.
# The factor is calibrated empirically: a full behavioral-interview turn on
# this runner measures ~116 s (two consecutive runs at 117.0 s and 115.3 s —
# large grammar-constrained prompt, 4B-Q4 model, CPU-only), i.e. ~11.6× the
# 10 s documented budget. 20× gives a 10 s × 20 = 200 s CI budget and, with
# the script's 1.20 regression tolerance, a 240 s ceiling — roughly 2× headroom
# over the observed latency so normal runner-to-runner variance does not flap
# the nightly, while a genuine >2× regression still fails. This factor only
# models CI-hardware slowness; the product's 10 s target-hardware SLO is
# unchanged. Re-measure and re-tune if the runner class or starter model
# changes.

name: Model smoke nightly

Expand Down Expand Up @@ -106,7 +113,7 @@ jobs:
run: |
python scripts/nightly-model-smoke.py \
--model-id "${{ steps.registry.outputs.model_id }}" \
--ci-hardware-factor 6 \
--ci-hardware-factor 20 \
--report-path /tmp/smoke-report.json

- name: Upload smoke report
Expand Down
37 changes: 31 additions & 6 deletions scripts/nightly-model-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import time
import urllib.error
import urllib.request
from collections import deque
from pathlib import Path
from typing import Optional

Expand Down Expand Up @@ -232,11 +233,19 @@ def _npc_turn_content(events: list) -> str:
# ── Smoke run ─────────────────────────────────────────────────────────────────


def _drain(stream: object) -> None:
"""Consume a subprocess pipe in the background to avoid a full-buffer deadlock."""
def _drain(stream: object, sink: Optional["deque"] = None) -> None:
"""Consume a subprocess pipe in the background to avoid a full-buffer deadlock.

When ``sink`` (a bounded deque) is given, the most recent lines are retained
so they can be surfaced if the smoke fails. The child's stderr is otherwise
discarded, which makes a server-side 500 undiagnosable from the CI logs — the
client only ever sees ``HTTPError: 500`` with no server traceback.
"""
try:
for _ in stream: # type: ignore[attr-defined]
pass
for raw in stream: # type: ignore[attr-defined]
if sink is not None:
line = raw.decode(errors="replace") if isinstance(raw, bytes) else raw
sink.append(line.rstrip("\n"))
except Exception:
pass

Expand Down Expand Up @@ -267,9 +276,14 @@ def run_smoke(

with tempfile.TemporaryDirectory(prefix="convsim-smoke-") as tmp:
data_dir = Path(tmp)
# Bounded tails of each child's stderr, surfaced only if the smoke fails
# so a server-side error is diagnosable from the CI logs.
llama_stderr_tail: deque = deque(maxlen=200)
core_stderr_tail: deque = deque(maxlen=200)

print(f"\n[smoke] Starting llama-server on port {LLAMA_SERVER_PORT} with model: {model_path.name}")
llama_proc = _start_llama_server(model_path, LLAMA_SERVER_PORT)
threading.Thread(target=_drain, args=(llama_proc.stderr,), daemon=True).start()
threading.Thread(target=_drain, args=(llama_proc.stderr, llama_stderr_tail), daemon=True).start()
threading.Thread(target=_drain, args=(llama_proc.stdout,), daemon=True).start()

core_proc: Optional[subprocess.Popen] = None
Expand All @@ -284,7 +298,7 @@ def run_smoke(

print(f"[smoke] Starting convsim-core on port {CORE_PORT}…")
core_proc = _start_core(data_dir, CORE_PORT, LLAMA_SERVER_PORT, llama_timeout_s)
threading.Thread(target=_drain, args=(core_proc.stderr,), daemon=True).start()
threading.Thread(target=_drain, args=(core_proc.stderr, core_stderr_tail), daemon=True).start()
threading.Thread(target=_drain, args=(core_proc.stdout,), daemon=True).start()

_wait_for_http(
Expand Down Expand Up @@ -361,6 +375,17 @@ def run_smoke(
results["failures"] = failures
results["verdict"] = "pass" if not failures else "fail"

except BaseException as exc:
# The client-side error (e.g. HTTP 500 from a turn) says nothing about
# what went wrong inside convsim-core. Surface the captured stderr
# tails so the real traceback is visible in the CI logs, then re-raise.
print(f"\n[smoke] ERROR during run: {exc!r}", file=sys.stderr)
for label, tail in (("convsim-core", core_stderr_tail), ("llama-server", llama_stderr_tail)):
if tail:
print(f"\n[smoke] ── {label} stderr (last {len(tail)} lines) ──", file=sys.stderr)
for line in tail:
print(f" {label[:4]}| {line}", file=sys.stderr)
raise
finally:
for proc in (core_proc, llama_proc):
if proc is None:
Expand Down
21 changes: 15 additions & 6 deletions services/convsim-core/convsim_core/runtime/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,22 @@ async def _stream(
}

if self._json_schema_enabled and request.json_schema is not None:
# Constrain output to the JSON schema via the ``json_object`` +
# top-level ``schema`` form. This is llama.cpp's original
# schema-constraint mechanism and is accepted by both servers we
# target: the bundled llama.cpp ``llama-server`` and
# llama-cpp-python's OpenAI server (used by the nightly smoke).
#
# We deliberately do NOT use the OpenAI ``{"type": "json_schema",
# "json_schema": {...}}`` form: llama-cpp-python's server rejects an
# unknown ``type`` with a 422 (which this adapter surfaces as a hard
# error), and even on llama.cpp's own server the ``json_schema`` type
# is unreliable on /v1/chat/completions (ggml-org/llama.cpp#11988).
# The ``json_object`` + ``schema`` form is the reliable common
# denominator.
payload["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"schema": request.json_schema,
"strict": False,
},
"type": "json_object",
"schema": request.json_schema,
}

full_text = ""
Expand Down
6 changes: 4 additions & 2 deletions services/convsim-core/tests/test_llama_cpp_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,10 @@ async def test_chat_stream_sends_response_format_hint_when_schema_provided(runti
sent_payload = client.stream.call_args.kwargs["json"]
assert "response_format" in sent_payload
rf = sent_payload["response_format"]
assert rf["type"] == "json_schema"
assert rf["json_schema"]["schema"] == schema
# llama.cpp's ``json_object`` + top-level ``schema`` form — the shape
# accepted by both the bundled llama-server and llama-cpp-python's server.
assert rf["type"] == "json_object"
assert rf["schema"] == schema


@pytest.mark.asyncio
Expand Down