interaction-harness 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. interaction_harness/__init__.py +1 -0
  2. interaction_harness/__main__.py +3 -0
  3. interaction_harness/cli.py +140 -0
  4. interaction_harness/config.py +210 -0
  5. interaction_harness/doctor.py +171 -0
  6. interaction_harness/llm.py +174 -0
  7. interaction_harness/prompts.py +29 -0
  8. interaction_harness/selftest.py +289 -0
  9. interaction_harness/server.py +106 -0
  10. interaction_harness/session.py +848 -0
  11. interaction_harness/speech/__init__.py +93 -0
  12. interaction_harness/speech/assets/SILERO_VAD_LICENSE +21 -0
  13. interaction_harness/speech/assets/silero_vad.onnx +0 -0
  14. interaction_harness/speech/base.py +142 -0
  15. interaction_harness/speech/endpoint.py +112 -0
  16. interaction_harness/speech/mlx.py +143 -0
  17. interaction_harness/speech/onnx.py +88 -0
  18. interaction_harness/speech/silero.py +53 -0
  19. interaction_harness/speech/text.py +75 -0
  20. interaction_harness/static/app.js +277 -0
  21. interaction_harness/static/index.html +97 -0
  22. interaction_harness/static/mic-worklet.js +37 -0
  23. interaction_harness/static/style.css +151 -0
  24. interaction_harness/tools/__init__.py +5 -0
  25. interaction_harness/tools/builtin.py +161 -0
  26. interaction_harness/tools/registry.py +155 -0
  27. interaction_harness/tools/web.py +359 -0
  28. interaction_harness-0.1.0.dist-info/METADATA +269 -0
  29. interaction_harness-0.1.0.dist-info/RECORD +33 -0
  30. interaction_harness-0.1.0.dist-info/WHEEL +4 -0
  31. interaction_harness-0.1.0.dist-info/entry_points.txt +2 -0
  32. interaction_harness-0.1.0.dist-info/licenses/LICENSE +21 -0
  33. interaction_harness-0.1.0.dist-info/licenses/src/interaction_harness/speech/assets/SILERO_VAD_LICENSE +21 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1,140 @@
1
+ """interaction-harness command line.
2
+
3
+ interaction-harness serve the dashboard on http://localhost:8765
4
+ interaction-harness doctor check all four model roles and report each one's latency
5
+ interaction-harness selftest talk to the agent with synthesized speech and check every behaviour
6
+ interaction-harness warmup download and load the in-process speech models, then exit
7
+
8
+ Every subcommand takes the same settings flags; anything not given comes from the environment, .env, or
9
+ interaction-harness.toml (see .env.example). API keys come from the environment or files, never from flags,
10
+ so they stay out of shell history.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import sys
17
+ import threading
18
+ import time
19
+ import webbrowser
20
+
21
+ from . import __version__
22
+ from .config import LOCAL_BACKENDS, THINKING_MODES, Settings, load_settings
23
+
24
+ ROLES = ("foreground", "background", "asr", "tts")
25
+
26
+
27
+ def _add_settings_flags(p: argparse.ArgumentParser) -> None:
28
+ p.add_argument("--config", metavar="FILE", help="TOML settings (default: ./interaction-harness.toml if present)")
29
+ g = p.add_argument_group("model roles", "each role is any OpenAI-compatible endpoint; ASR/TTS run "
30
+ "in-process when their base URL is blank or 'local'")
31
+ for role in ROLES:
32
+ g.add_argument(f"--{role}-base-url", metavar="URL")
33
+ g.add_argument(f"--{role}-model", metavar="NAME")
34
+ g.add_argument("--foreground-thinking", choices=THINKING_MODES, help="how to switch thinking on that server")
35
+ g.add_argument("--background-thinking", choices=THINKING_MODES)
36
+ s = p.add_argument_group("speech")
37
+ s.add_argument("--tts-voice", metavar="NAME")
38
+ s.add_argument("--backend", choices=LOCAL_BACKENDS, help="in-process speech (default auto: mlx on Apple "
39
+ "Silicon, onnx elsewhere)")
40
+ s.add_argument("--device", choices=("cpu", "cuda"), help="onnx backend device (default cpu)")
41
+ p.add_argument("--tools", action="append", metavar="FILE", help="Python file of @tool functions (repeatable)")
42
+ p.add_argument("--host", help="bind address (default 127.0.0.1)")
43
+ p.add_argument("--port", type=int, help="port (default 8765)")
44
+
45
+
46
+ def _flags(args: argparse.Namespace) -> dict:
47
+ keys = [f"{r}_{f}" for r in ROLES for f in ("base_url", "model")] + [
48
+ "foreground_thinking", "background_thinking", "tts_voice", "backend", "device", "tools", "host", "port",
49
+ "ssl_certfile", "ssl_keyfile"]
50
+ return {k.upper(): getattr(args, k, None) for k in keys}
51
+
52
+
53
+ def _settings(args: argparse.Namespace, require_foreground: bool = True) -> Settings:
54
+ try:
55
+ return load_settings(_flags(args), config_path=args.config, require_foreground=require_foreground)
56
+ except (ValueError, FileNotFoundError) as e:
57
+ sys.exit(f"interaction-harness: {e}")
58
+
59
+
60
+ def _local_url(settings: Settings) -> str:
61
+ host = "localhost" if settings.host in ("127.0.0.1", "0.0.0.0", "::", "localhost") else settings.host
62
+ return f"{'https' if settings.ssl_certfile else 'http'}://{host}:{settings.port}"
63
+
64
+
65
+ def cmd_serve(args: argparse.Namespace) -> None:
66
+ import httpx
67
+ import uvicorn
68
+
69
+ from .server import create_app
70
+ settings = _settings(args)
71
+ url = _local_url(settings)
72
+ print(f"interaction-harness {__version__} -> {url}", flush=True)
73
+ if settings.host not in ("127.0.0.1", "localhost", "::1") and not settings.ssl_certfile:
74
+ print("note: browsers only allow the microphone on localhost or HTTPS. From another machine, use an SSH "
75
+ "tunnel (ssh -L 8765:localhost:8765 this-host) or pass --ssl-certfile/--ssl-keyfile.", flush=True)
76
+
77
+ if args.open:
78
+ def open_when_ready():
79
+ for _ in range(600):
80
+ try:
81
+ if httpx.get(f"{url}/api/status", timeout=1, verify=False).status_code == 200:
82
+ webbrowser.open(url)
83
+ return
84
+ except httpx.HTTPError:
85
+ pass
86
+ time.sleep(1)
87
+ threading.Thread(target=open_when_ready, daemon=True).start()
88
+
89
+ uvicorn.run(create_app(settings), host=settings.host, port=settings.port, log_level="warning",
90
+ ssl_certfile=settings.ssl_certfile, ssl_keyfile=settings.ssl_keyfile)
91
+
92
+
93
+ def cmd_doctor(args: argparse.Namespace) -> None:
94
+ from .doctor import run
95
+ sys.exit(asyncio.run(run(_settings(args))))
96
+
97
+
98
+ def cmd_selftest(args: argparse.Namespace) -> None:
99
+ from .selftest import run
100
+ sys.exit(asyncio.run(run(_settings(args), url=args.url)))
101
+
102
+
103
+ def cmd_warmup(args: argparse.Namespace) -> None:
104
+ from .doctor import warmup
105
+ sys.exit(asyncio.run(warmup(_settings(args, require_foreground=False))))
106
+
107
+
108
+ def main(argv: list[str] | None = None) -> None:
109
+ parser = argparse.ArgumentParser(prog="interaction-harness", description=__doc__.split("\n\n")[0],
110
+ formatter_class=argparse.RawDescriptionHelpFormatter)
111
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
112
+ sub = parser.add_subparsers(dest="cmd", required=True)
113
+
114
+ p = sub.add_parser("serve", help="run the dashboard and the voice session server")
115
+ _add_settings_flags(p)
116
+ p.add_argument("--ssl-certfile", metavar="FILE", help="serve HTTPS so a remote browser may use the mic")
117
+ p.add_argument("--ssl-keyfile", metavar="FILE")
118
+ p.add_argument("--open", action="store_true", help="open the dashboard in a browser once it is ready")
119
+ p.set_defaults(fn=cmd_serve)
120
+
121
+ p = sub.add_parser("doctor", help="check every model role and report latency")
122
+ _add_settings_flags(p)
123
+ p.set_defaults(fn=cmd_doctor)
124
+
125
+ p = sub.add_parser("selftest", help="end-to-end test with synthesized speech (starts a server if none is up)")
126
+ _add_settings_flags(p)
127
+ p.add_argument("--url", help="WebSocket of a running server (default: ws://127.0.0.1:<port>/ws, started "
128
+ "in-process if nothing is listening)")
129
+ p.set_defaults(fn=cmd_selftest)
130
+
131
+ p = sub.add_parser("warmup", help="download and load the in-process speech models, then exit")
132
+ _add_settings_flags(p)
133
+ p.set_defaults(fn=cmd_warmup)
134
+
135
+ args = parser.parse_args(argv)
136
+ args.fn(args)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -0,0 +1,210 @@
1
+ """Settings: four model roles, each an OpenAI-compatible endpoint, plus the knobs around them.
2
+
3
+ ASR speech -> text POST {base}/audio/transcriptions
4
+ TTS text -> speech POST {base}/audio/speech
5
+ FOREGROUND holds the conversation POST {base}/chat/completions (also judges end-of-turn)
6
+ BACKGROUND works on handed-off tasks POST {base}/chat/completions
7
+
8
+ Every role reads <ROLE>_BASE_URL, <ROLE>_API_KEY and <ROLE>_MODEL. ASR and TTS run in-process when their
9
+ base URL is blank or "local"; BACKGROUND falls back to FOREGROUND field by field; FOREGROUND falls back to
10
+ OPENAI_BASE_URL / OPENAI_API_KEY. VAD is not a role: it runs on every 32 ms frame, in-process.
11
+
12
+ Precedence, highest first: command-line flags, environment, .env in the working directory, the TOML file
13
+ (--config, or ./interaction-harness.toml when it exists).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import tomllib
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.parse import urlparse
23
+
24
+ from dotenv import dotenv_values
25
+
26
+ from . import prompts
27
+
28
+ THINKING_MODES = ("chat_template_kwargs", "reasoning_effort", "off")
29
+ LOCAL_BACKENDS = ("auto", "mlx", "onnx")
30
+ DEFAULT_TOML = "interaction-harness.toml"
31
+ NO_KEY = "not-needed"
32
+
33
+
34
+ @dataclass
35
+ class Endpoint:
36
+ """One model role. `base_url` None means in-process (ASR/TTS only)."""
37
+
38
+ role: str
39
+ base_url: str | None
40
+ api_key: str
41
+ model: str
42
+
43
+ @property
44
+ def local(self) -> bool:
45
+ return self.base_url is None
46
+
47
+ @property
48
+ def host(self) -> str:
49
+ """Where the role runs, without path or credentials, for status lines and the dashboard."""
50
+ if self.base_url is None:
51
+ return "in-process"
52
+ p = urlparse(self.base_url)
53
+ return p.hostname + (f":{p.port}" if p.port else "") if p.hostname else self.base_url
54
+
55
+
56
+ @dataclass
57
+ class Settings:
58
+ foreground: Endpoint
59
+ background: Endpoint
60
+ asr: Endpoint
61
+ tts: Endpoint
62
+ foreground_thinking: str = "chat_template_kwargs"
63
+ background_thinking: str = "chat_template_kwargs"
64
+ background_temperature: float = 0.7
65
+ background_rounds: int = 10 # model calls per background task
66
+ asr_language: str | None = None
67
+ asr_partial_ms: int = 640 # partial transcript cadence; 0 = only when the judge needs one
68
+ tts_voice: str | None = None # None: the backend's first voice
69
+ tts_voices: list[str] = field(default_factory=list)
70
+ tts_format: str = "wav" # endpoint TTS: wav or pcm
71
+ tts_sample_rate: int = 24_000 # endpoint TTS with pcm: the server's rate
72
+ tts_speed: float = 1.05
73
+ backend: str = "auto" # in-process speech: auto | mlx | onnx
74
+ device: str = "cpu" # onnx backend: cpu | cuda
75
+ parakeet_onnx_variant: str = "int8" # fp32 | int8 (same transcripts in our tests; a quarter the download)
76
+ kokoro_onnx_variant: str = "int8" # fp32 | fp16 | int8 (about twice as fast on CPU)
77
+ foreground_prompt: str = prompts.FOREGROUND_SYSTEM
78
+ background_prompt: str = prompts.BACKGROUND_SYSTEM
79
+ end_of_turn_prompt: str = prompts.END_OF_TURN_SYSTEM
80
+ tools_files: list[str] = field(default_factory=list)
81
+ save_utterances: str | None = None # directory: keep a WAV of every user turn the ASR heard
82
+ host: str = "127.0.0.1"
83
+ port: int = 8765
84
+ ssl_certfile: str | None = None
85
+ ssl_keyfile: str | None = None
86
+
87
+ def roles(self) -> dict[str, Endpoint]:
88
+ return {"asr": self.asr, "tts": self.tts, "foreground": self.foreground, "background": self.background}
89
+
90
+
91
+ def _same_host(a: str, b: str) -> bool:
92
+ pa, pb = urlparse(a), urlparse(b)
93
+ return bool(pa.hostname) and (pa.scheme, pa.hostname, pa.port) == (pb.scheme, pb.hostname, pb.port)
94
+
95
+
96
+ def _flatten_toml(data: dict[str, Any]) -> dict[str, str]:
97
+ """[foreground] base_url = ... -> FOREGROUND_BASE_URL; [prompts] foreground = ... -> FOREGROUND_PROMPT;
98
+ [server] port = ... (or a top-level port = ...) -> PORT."""
99
+ out: dict[str, str] = {}
100
+ for key, value in data.items():
101
+ if isinstance(value, dict):
102
+ for k, v in value.items():
103
+ name = f"{k}_PROMPT" if key == "prompts" else k if key == "server" else f"{key}_{k}"
104
+ out[name.upper()] = ",".join(map(str, v)) if isinstance(v, list) else str(v)
105
+ else:
106
+ out[key.upper()] = ",".join(map(str, value)) if isinstance(value, list) else str(value)
107
+ return out
108
+
109
+
110
+ def _sources(flags: dict[str, Any] | None, config_path: str | None, env: dict[str, str] | None,
111
+ dotenv_path: str | None) -> dict[str, str]:
112
+ merged: dict[str, str] = {}
113
+ toml_path = Path(config_path) if config_path else Path(DEFAULT_TOML)
114
+ if config_path and not toml_path.exists():
115
+ raise FileNotFoundError(f"config file not found: {toml_path}")
116
+ if toml_path.exists():
117
+ merged.update(_flatten_toml(tomllib.loads(toml_path.read_text())))
118
+ dotenv = Path(dotenv_path) if dotenv_path else Path(".env")
119
+ if dotenv.exists():
120
+ merged.update({k: v for k, v in dotenv_values(dotenv).items() if v is not None})
121
+ merged.update(os.environ if env is None else env)
122
+ for k, v in (flags or {}).items():
123
+ if v is not None:
124
+ merged[k.upper()] = ",".join(map(str, v)) if isinstance(v, list) else str(v)
125
+ return merged
126
+
127
+
128
+ def load_settings(flags: dict[str, Any] | None = None, *, config_path: str | None = None,
129
+ env: dict[str, str] | None = None, dotenv_path: str | None = None,
130
+ require_foreground: bool = True) -> Settings:
131
+ """Build Settings from flags, env, .env and TOML. `flags` uses env-style keys (FOREGROUND_MODEL=...)."""
132
+ src = _sources(flags, config_path, env, dotenv_path)
133
+
134
+ def get(*keys: str, default: str | None = None) -> str | None:
135
+ for k in keys:
136
+ v = src.get(k)
137
+ if v is not None and str(v).strip() != "":
138
+ return str(v).strip()
139
+ return default
140
+
141
+ def speech_url(role: str) -> str | None:
142
+ url = get(f"{role}_BASE_URL")
143
+ return None if url is None or url.lower() == "local" else url.rstrip("/")
144
+
145
+ fg_url = (get("FOREGROUND_BASE_URL", "OPENAI_BASE_URL") or "").rstrip("/")
146
+ fg_key = get("FOREGROUND_API_KEY", "OPENAI_API_KEY", default=NO_KEY)
147
+
148
+ def key_for(role: str, url: str | None) -> str:
149
+ # a key only goes to the host it was set for: reuse the foreground key on the same host, never elsewhere
150
+ own = get(f"{role}_API_KEY")
151
+ if own:
152
+ return own
153
+ return fg_key if url and _same_host(url, fg_url) else NO_KEY
154
+
155
+ foreground = Endpoint("foreground", fg_url, fg_key, get("FOREGROUND_MODEL", default=""))
156
+ bg_url = (get("BACKGROUND_BASE_URL") or fg_url).rstrip("/")
157
+ background = Endpoint("background", bg_url, key_for("BACKGROUND", bg_url),
158
+ get("BACKGROUND_MODEL", default="") or (foreground.model if bg_url == fg_url else ""))
159
+ asr_url, tts_url = speech_url("ASR"), speech_url("TTS")
160
+ asr = Endpoint("asr", asr_url, key_for("ASR", asr_url), get("ASR_MODEL", default=""))
161
+ tts = Endpoint("tts", tts_url, key_for("TTS", tts_url), get("TTS_MODEL", default=""))
162
+
163
+ s = Settings(foreground=foreground, background=background, asr=asr, tts=tts)
164
+ s.foreground_thinking = get("FOREGROUND_THINKING", default=s.foreground_thinking)
165
+ # same server as the foreground: same way of switching thinking, unless told otherwise
166
+ s.background_thinking = get("BACKGROUND_THINKING", default=s.foreground_thinking if bg_url == fg_url
167
+ else s.background_thinking)
168
+ s.background_temperature = float(get("BACKGROUND_TEMPERATURE", default=str(s.background_temperature)))
169
+ s.background_rounds = int(get("BACKGROUND_ROUNDS", default=str(s.background_rounds)))
170
+ s.asr_language = get("ASR_LANGUAGE")
171
+ s.asr_partial_ms = int(get("ASR_PARTIAL_MS", default=str(s.asr_partial_ms)))
172
+ s.tts_voice = get("TTS_VOICE")
173
+ s.tts_voices = [v.strip() for v in (get("TTS_VOICES") or "").split(",") if v.strip()]
174
+ s.tts_format = get("TTS_FORMAT", default=s.tts_format).lower()
175
+ s.tts_sample_rate = int(get("TTS_SAMPLE_RATE", default=str(s.tts_sample_rate)))
176
+ s.tts_speed = float(get("TTS_SPEED", default=str(s.tts_speed)))
177
+ s.backend = get("BACKEND", default=s.backend).lower()
178
+ s.device = get("DEVICE", default=s.device).lower()
179
+ s.parakeet_onnx_variant = get("PARAKEET_ONNX_VARIANT", default=s.parakeet_onnx_variant).lower()
180
+ s.kokoro_onnx_variant = get("KOKORO_ONNX_VARIANT", default=s.kokoro_onnx_variant).lower()
181
+ s.foreground_prompt = get("FOREGROUND_PROMPT", default=s.foreground_prompt)
182
+ s.background_prompt = get("BACKGROUND_PROMPT", default=s.background_prompt)
183
+ s.end_of_turn_prompt = get("END_OF_TURN_PROMPT", default=s.end_of_turn_prompt)
184
+ s.tools_files = [p.strip() for p in (get("TOOLS") or "").split(",") if p.strip()]
185
+ s.save_utterances = get("SAVE_UTTERANCES")
186
+ s.host = get("HOST", default=s.host)
187
+ s.port = int(get("PORT", default=str(s.port)))
188
+ s.ssl_certfile = get("SSL_CERTFILE")
189
+ s.ssl_keyfile = get("SSL_KEYFILE")
190
+ validate(s, require_foreground)
191
+ return s
192
+
193
+
194
+ def validate(s: Settings, require_foreground: bool = True) -> None:
195
+ for name in ("foreground_thinking", "background_thinking"):
196
+ if getattr(s, name) not in THINKING_MODES:
197
+ raise ValueError(f"{name.upper()} must be one of {', '.join(THINKING_MODES)}; got {getattr(s, name)!r}")
198
+ if s.backend not in LOCAL_BACKENDS:
199
+ raise ValueError(f"BACKEND must be one of {', '.join(LOCAL_BACKENDS)}; got {s.backend!r}")
200
+ if s.tts_format not in ("wav", "pcm"):
201
+ raise ValueError(f"TTS_FORMAT must be wav or pcm; got {s.tts_format!r}")
202
+ if s.device not in ("cpu", "cuda"):
203
+ raise ValueError(f"DEVICE must be cpu or cuda; got {s.device!r}")
204
+ if s.parakeet_onnx_variant not in ("fp32", "int8"):
205
+ raise ValueError(f"PARAKEET_ONNX_VARIANT must be fp32 or int8; got {s.parakeet_onnx_variant!r}")
206
+ if s.kokoro_onnx_variant not in ("fp32", "fp16", "int8"):
207
+ raise ValueError(f"KOKORO_ONNX_VARIANT must be fp32, fp16 or int8; got {s.kokoro_onnx_variant!r}")
208
+ if require_foreground and not s.foreground.base_url:
209
+ raise ValueError("No foreground model server. Set FOREGROUND_BASE_URL (or OPENAI_BASE_URL) to any "
210
+ "OpenAI-compatible endpoint, e.g. http://127.0.0.1:8080/v1")
@@ -0,0 +1,171 @@
1
+ """Check every model role on its own, so a bad setting shows up before you start talking.
2
+
3
+ foreground streams, calls a tool when asked, and answers the end-of-turn prompt in one word
4
+ background streams with thinking on and calls a tool
5
+ TTS -> ASR speech made by the TTS comes back from the ASR as the same words
6
+ VAD hears that speech and not silence
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import time
12
+
13
+ import numpy as np
14
+
15
+ from .config import Settings
16
+ from .llm import LLM, resolve_models
17
+ from .speech import MIC_SR, VAD_FRAME, build_speech
18
+ from .speech.base import resample
19
+ from .speech.silero import SileroVAD
20
+ from .tools import schemas
21
+
22
+ PHRASE = "The quick brown fox jumps over the lazy dog, and the interaction model decides who speaks next."
23
+
24
+ OK, WARN, FAIL = "ok", "warn", "FAIL"
25
+ _MARK = {OK: "✓", WARN: "!", FAIL: "✗"}
26
+
27
+
28
+ class Report:
29
+ def __init__(self):
30
+ self.rows: list[tuple[str, str, str, str]] = []
31
+
32
+ def add(self, check: str, status: str, detail: str, ms: float | None = None):
33
+ self.rows.append((check, status, detail, "" if ms is None else f"{ms:,.0f} ms"))
34
+ print(f" {_MARK[status]} {check:<26} {self.rows[-1][3]:>10} {detail}", flush=True)
35
+
36
+ @property
37
+ def failed(self) -> int:
38
+ return sum(1 for r in self.rows if r[1] == FAIL)
39
+
40
+
41
+ def _words(text: str) -> list[str]:
42
+ return re.findall(r"[a-z]+", text.lower())
43
+
44
+
45
+ def word_overlap(said: str, heard: str) -> float:
46
+ a, b = _words(said), set(_words(heard))
47
+ return sum(w in b for w in a) / max(1, len(a))
48
+
49
+
50
+ async def _chat_check(llm: LLM, report: Report, role: str, thinking: bool):
51
+ # the same system prompt and tools a real session sends, so the check measures what you will get
52
+ background = role == "background"
53
+ prompt = llm.s.background_prompt if background else llm.s.foreground_prompt
54
+ msgs = [{"role": "system", "content": prompt}, {"role": "user", "content": "What time is it right now?"}]
55
+ first = done = None
56
+ calls, reasoning, text = [], 0, []
57
+ t0 = time.perf_counter()
58
+ try:
59
+ async for ev in llm.stream_chat(msgs, tools=schemas(role), thinking=thinking, max_tokens=2000,
60
+ background=background):
61
+ if ev["type"] == "first_token":
62
+ first = ev["ms"]
63
+ elif ev["type"] == "reasoning":
64
+ reasoning += 1
65
+ elif ev["type"] == "content":
66
+ text.append(ev["delta"])
67
+ elif ev["type"] == "tool_calls":
68
+ calls = ev["calls"]
69
+ elif ev["type"] == "done":
70
+ done = ev
71
+ except Exception as e: # noqa: BLE001
72
+ report.add(f"{role}: chat", FAIL, f"{type(e).__name__}: {str(e)[:160]}", (time.perf_counter() - t0) * 1000)
73
+ return
74
+ report.add(f"{role}: first token", OK if first is not None else WARN,
75
+ "streamed" if first is not None else "no tokens streamed", first)
76
+ if any(c["name"] == "get_time" for c in calls):
77
+ report.add(f"{role}: tool call", OK, "called get_time", done["ms"] if done else None)
78
+ else:
79
+ said = "".join(text).strip()[:80]
80
+ report.add(f"{role}: tool call", WARN, f"did not call get_time for the time; it said {said!r}. If tools never "
81
+ "fire, check the server passes them to the model", done["ms"] if done else None)
82
+ if background:
83
+ report.add(f"{role}: reasoning", OK if reasoning else WARN,
84
+ f"{reasoning} reasoning deltas" if reasoning else
85
+ "no reasoning streamed; check BACKGROUND_THINKING for this server")
86
+
87
+
88
+ async def _judge_check(llm: LLM, report: Report):
89
+ for text, want in (("what time is it right now", "COMPLETE"), ("so I was wondering whether and", "INCOMPLETE")):
90
+ try:
91
+ complete, ms, raw = await llm.end_of_turn(text)
92
+ except Exception as e: # noqa: BLE001
93
+ report.add("foreground: end-of-turn", FAIL, f"{type(e).__name__}: {str(e)[:160]}")
94
+ return
95
+ got = "COMPLETE" if complete else "INCOMPLETE"
96
+ report.add(f"judge: {want.lower()}", OK if got == want else WARN,
97
+ f"{text!r} -> {raw or '(empty)'}" + ("" if got == want else f" (expected {want})"), ms)
98
+
99
+
100
+ async def speech_round_trip(settings: Settings, report: Report) -> None:
101
+ try:
102
+ speech = await build_speech(settings, log=lambda m: None)
103
+ except Exception as e: # noqa: BLE001
104
+ report.add("speech: load", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
105
+ return
106
+ asr, tts = speech.asr, speech.tts
107
+ report.add("asr: model", OK, f"{asr.model_id} ({asr.where})")
108
+ report.add("tts: model", OK, f"{tts.model_id} ({tts.where}, voice {settings.tts_voice})")
109
+ try:
110
+ pcm, sr, ms = await tts.synth(PHRASE, settings.tts_voice, 1.0)
111
+ except Exception as e: # noqa: BLE001
112
+ report.add("tts: synthesize", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
113
+ return
114
+ dur = len(pcm) / sr if sr else 0
115
+ report.add("tts: synthesize", OK if dur > 1 else FAIL, f"{dur:.2f}s of audio at {sr} Hz (RTF {ms / 1000 / max(dur, 1e-6):.2f})", ms)
116
+ audio16 = resample(pcm, sr, MIC_SR).astype(np.float32) / 32768.0
117
+ try:
118
+ text, ms = await asr.transcribe(audio16)
119
+ except Exception as e: # noqa: BLE001
120
+ report.add("asr: transcribe", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
121
+ return
122
+ overlap = word_overlap(PHRASE, text)
123
+ report.add("tts -> asr round trip", OK if overlap >= 0.8 else WARN if overlap >= 0.5 else FAIL,
124
+ f"{overlap:.0%} of words back: {text[:80]!r}", ms)
125
+ stream = await asr.stream()
126
+ step = MIC_SR // 2
127
+ t0 = time.perf_counter()
128
+ partial = ""
129
+ for i in range(0, len(audio16), step):
130
+ partial, _ = await stream.feed(audio16[i:i + step])
131
+ await stream.close()
132
+ report.add("asr: partials", OK if word_overlap(PHRASE, partial) >= 0.5 else WARN,
133
+ f"{len(range(0, len(audio16), step))} partials, last {partial[-50:]!r}", (time.perf_counter() - t0) * 1000)
134
+ vad = SileroVAD()
135
+ frames = (audio16 * 32767).astype(np.int16)
136
+ probs = [vad.prob(frames[i:i + VAD_FRAME]) for i in range(0, len(frames) - VAD_FRAME, VAD_FRAME)]
137
+ vad.reset()
138
+ quiet = max(vad.prob(np.zeros(VAD_FRAME, dtype=np.int16)) for _ in range(10))
139
+ speechy = float(np.mean(np.array(probs) > 0.5)) if probs else 0.0
140
+ report.add("vad", OK if speechy > 0.4 and quiet < 0.2 else WARN,
141
+ f"speech frames {speechy:.0%}, max p on silence {quiet:.2f}")
142
+
143
+
144
+ async def run(settings: Settings) -> int:
145
+ report = Report()
146
+ print("model roles", flush=True)
147
+ for name, ep in settings.roles().items():
148
+ print(f" {name:<11} {ep.model or '(server default)':<44} {ep.host}", flush=True)
149
+ print("\nforeground + background", flush=True)
150
+ try:
151
+ for note in await resolve_models(settings):
152
+ report.add("model", OK, note)
153
+ except Exception as e: # noqa: BLE001
154
+ report.add("model list", FAIL, f"{type(e).__name__}: {str(e)[:200]}")
155
+ if settings.foreground.model:
156
+ llm = LLM(settings)
157
+ await _chat_check(llm, report, "foreground", thinking=False)
158
+ await _judge_check(llm, report)
159
+ await _chat_check(llm, report, "background", thinking=True) # thinking on: its own check even on one server
160
+ print("\nspeech", flush=True)
161
+ await speech_round_trip(settings, report)
162
+ print(f"\n{'all checks passed' if not report.failed else f'{report.failed} check(s) failed'}"
163
+ f"{'' if not any(r[1] == WARN for r in report.rows) else ' (with warnings)'}", flush=True)
164
+ return 1 if report.failed else 0
165
+
166
+
167
+ async def warmup(settings: Settings) -> int:
168
+ """Download and load the in-process models, then check they work together."""
169
+ report = Report()
170
+ await speech_round_trip(settings, report)
171
+ return 1 if report.failed else 0