Files
turnstone/tests/test_server_available_models.py
T
Patrick Buckley 3d12798315 feat(audio): voice I/O — speech-to-text + text-to-speech via model roles (#618)
* feat(audio): voice I/O — speech-to-text + text-to-speech via model roles

Browser voice input/output over the OpenAI audio wire protocol, selected
through the existing model-roles system so the same code path serves OpenAI,
vLLM/vLLM-Omni, or any compatible backend — pure registry config, no new
in-process deps. Anthropic has no audio API, so it is capability-gated out of
the audio roles while remaining valid as the agent model.

Backend
- core/audio.py: role resolution + capability gating + transcribe()/synthesize()
  over a registry-resolved client. Typed AudioUnavailableError (503) /
  AudioBackendError (502 — body masked, SDK detail logged). Optional STT prompt.
- Endpoints POST /v1/api/workstreams/{ws_id}/speech-to-text and POST /v1/api/tts,
  registered in v1_routes, write-scoped (direct + proxied), offloaded with
  asyncio.to_thread. Silence -> 422; configured-but-failed backend -> masked 502.
- Model roles: audio.stt_model_alias / audio.tts_model_alias / audio.tts_voice /
  audio.stt_prompt settings; Models -> Roles entries (capability-gated dropdowns,
  "(disabled — voice off)" when unset). /v1/api/models exposes resolved
  stt_default_alias / tts_default_alias + per-model capabilities.
- Capabilities: supports_transcription / supports_speech_synthesis on
  ModelCapabilities; current OpenAI audio lineup (whisper-1, gpt-4o[-mini]-
  transcribe, tts-1[-hd], gpt-4o-mini-tts) registered as known models, with a
  name-inference backstop for local/openai-compatible aliases.

Frontend (interactive UI)
- Mic dictation (record -> transcribe -> fill composer for review) and
  per-message playback, shown only when the role is configured.
- CSS-mask icon set, aria-pressed + live-region announcements, recording timer,
  reduced-motion cue, error-typed toasts + persistent denial, mic disabled while
  busy, code/math stripped before TTS.

Tests: new test_audio.py plus STT/TTS endpoint, settings, openapi, available-
models, and OpenAI-lineup capability coverage. ruff + mypy + node --check clean.

* fix(audio): use const for AUDIO_MODEL_HINTS (var-sweep invariant)
2026-05-30 14:03:39 -07:00

185 lines
6.8 KiB
Python

"""``GET /v1/api/models`` (server) default-alias resolution.
The server handler surfaces effective defaults for the web UI's dashboard
composer + the channel gateway. The dashboard's Options panel renders
each one as a "Default — alias (model)" placeholder, so the resolution
chain has to stay honest:
* ``default_alias`` ← ``model.default_alias`` when it names an enabled
alias, otherwise ``registry.default`` (mirrors session_factory's
``_effective_default_alias`` — the model a new workstream actually launches
on). Only blanked when even ``registry.default`` is unresolvable.
* ``channel_default_alias`` ← ``channels.default_model_alias``.
* ``judge_default_alias`` ← ``judge.model``, but *only* when it names an
enabled alias. An unset / whitespace / unknown / disabled value stays
blank: at runtime the judge then inherits the per-workstream agent model
(``session_factory``: ``judge_config.model or model``), which the UI
renders as "Default (agent model)". Surfacing a fixed alias there would
mislabel the common follow-the-agent case.
These tests pin the judge branch (added so the server dashboard matches
the coordinator launcher) alongside the pre-existing model default.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from starlette.testclient import TestClient
from tests._coord_test_helpers import _AuthMiddleware, _FakeConfigStore
from turnstone.server import list_available_models
class _StubRegistry:
"""Mimics the ``ModelRegistry`` surface ``list_available_models``
reads: ``list_aliases()`` / ``get_config(alias)`` / ``.default``."""
def __init__(self, *, aliases: dict[str, str], default: str = "") -> None:
# alias -> underlying model id
self._aliases = aliases
self.default = default
def list_aliases(self) -> list[str]:
return list(self._aliases)
def get_config(self, alias: str) -> SimpleNamespace:
return SimpleNamespace(
alias=alias,
model=self._aliases[alias],
provider="openai-compatible",
capabilities={},
)
def _make_client(
*,
aliases: dict[str, str] | None = None,
settings: dict[str, str] | None = None,
registry_default: str = "",
) -> TestClient:
app = Starlette(
routes=[Route("/v1/api/models", list_available_models)],
middleware=[Middleware(_AuthMiddleware)],
)
app.state.registry = _StubRegistry(aliases=aliases or {}, default=registry_default)
app.state.config_store = _FakeConfigStore(dict(settings or {}))
client = TestClient(app)
client.headers.update({"X-Test-User": "admin", "X-Test-Perms": ""})
return client
def _get_models(client: TestClient) -> dict[str, Any]:
resp = client.get("/v1/api/models")
assert resp.status_code == 200, resp.text
return resp.json()
# ---------------------------------------------------------------------------
# Judge resolution (new — drives the dashboard "Judge Model" placeholder)
# ---------------------------------------------------------------------------
def test_judge_unset_stays_blank_for_agent_model_fallback() -> None:
"""No ``judge.model`` configured → blank, so the dashboard keeps the
"Default (agent model)" wording rather than advertising a fixed alias
the judge won't actually use."""
body = _get_models(
_make_client(
aliases={"primary": "vendor/primary"},
settings={"model.default_alias": "primary"},
)
)
assert body["default_alias"] == "primary"
assert body["judge_default_alias"] == ""
def test_judge_explicit_enabled_alias_passes_through() -> None:
body = _get_models(
_make_client(
aliases={"primary": "vendor/primary", "judge-fast": "vendor/judge-fast"},
settings={
"model.default_alias": "primary",
"judge.model": "judge-fast",
},
)
)
assert body["judge_default_alias"] == "judge-fast"
def test_judge_set_to_unknown_alias_stays_blank() -> None:
"""``judge.model`` naming a non-enabled alias falls back to the agent
model at runtime, so the field is blanked rather than echoing a value
workstream creation can't honour."""
body = _get_models(
_make_client(
aliases={"primary": "vendor/primary"},
settings={
"model.default_alias": "primary",
"judge.model": "ghost",
},
)
)
assert body["judge_default_alias"] == ""
def test_judge_whitespace_only_value_stays_blank() -> None:
"""``judge.model`` is ``.strip()``-ed — a whitespace-only setting is
treated as unset, not as an (always-unknown) alias."""
body = _get_models(
_make_client(
aliases={"primary": "vendor/primary"},
settings={"model.default_alias": "primary", "judge.model": " "},
)
)
assert body["judge_default_alias"] == ""
# ---------------------------------------------------------------------------
# Pre-existing model default stays correct under the new resolution code
# ---------------------------------------------------------------------------
def test_model_default_falls_back_to_registry_default() -> None:
"""Unset ``model.default_alias`` → the registry default is surfaced so
the placeholder reports the alias sessions actually launch on."""
body = _get_models(
_make_client(aliases={"primary": "vendor/primary"}, registry_default="primary")
)
assert body["default_alias"] == "primary"
assert body["judge_default_alias"] == ""
def test_model_default_foreign_alias_falls_back_to_registry_default() -> None:
"""``model.default_alias`` naming an alias absent from THIS server's
registry (e.g. a console-only alias leaking through a shared ConfigStore)
falls back to ``registry.default`` — the model creation actually uses —
rather than being blanked. Blanking made the dashboard show a bare
"Default model" placeholder even though a workstream would launch on a
concrete model."""
body = _get_models(
_make_client(
aliases={"primary": "vendor/primary"},
registry_default="primary",
settings={"model.default_alias": "ghost"},
)
)
assert body["default_alias"] == "primary"
def test_model_default_blanks_only_when_registry_default_also_unresolvable() -> None:
"""The defensive blank still applies when neither the configured alias
nor ``registry.default`` resolves to an enabled alias."""
body = _get_models(
_make_client(
aliases={"primary": "vendor/primary"},
registry_default="",
settings={"model.default_alias": "ghost"},
)
)
assert body["default_alias"] == ""