Files
turnstone/tests/test_openapi.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

214 lines
8.2 KiB
Python

"""Tests for OpenAPI spec generation."""
import json
class TestServerSpec:
"""Validate the generated server OpenAPI spec."""
def test_valid_openapi_version(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert spec["openapi"] == "3.1.0"
def test_has_info(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert "title" in spec["info"]
assert "version" in spec["info"]
def test_has_all_api_endpoints(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/workstreams",
"/v1/api/workstreams/{ws_id}",
"/v1/api/workstreams/{ws_id}/history",
"/v1/api/workstreams/{ws_id}/send",
"/v1/api/workstreams/{ws_id}/approve",
"/v1/api/workstreams/{ws_id}/cancel",
"/v1/api/workstreams/{ws_id}/rewind",
"/v1/api/workstreams/{ws_id}/retry",
"/v1/api/workstreams/{ws_id}/close",
"/v1/api/workstreams/{ws_id}/events",
"/v1/api/dashboard",
"/v1/api/workstreams/saved",
"/v1/api/plan",
"/v1/api/command",
"/v1/api/events/global",
"/v1/api/workstreams/new",
"/v1/api/workstreams/{ws_id}/speech-to-text",
"/v1/api/tts",
"/v1/api/auth/login",
"/v1/api/auth/logout",
"/health",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_voice_endpoints_documented(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
stt = spec["paths"]["/v1/api/workstreams/{ws_id}/speech-to-text"]["post"]
tts = spec["paths"]["/v1/api/tts"]["post"]
assert "responses" in stt
assert "requestBody" in tts
assert "application/json" in tts["requestBody"]["content"]
schemas = spec["components"]["schemas"]
assert "capabilities" in schemas["AvailableModelInfo"]["properties"]
models_props = schemas["ListAvailableModelsResponse"]["properties"]
assert "stt_default_alias" in models_props
assert "tts_default_alias" in models_props
def test_workstream_history_has_limit_query_param(self):
"""Mirror of the coord-side history limit param test — server now
exposes the same endpoint via the lifted factory."""
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
op = spec["paths"]["/v1/api/workstreams/{ws_id}/history"]["get"]
param_names = [p["name"] for p in op.get("parameters", [])]
assert "ws_id" in param_names
assert "limit" in param_names
def test_schemas_not_empty(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert len(spec["components"]["schemas"]) > 0
def test_json_serializable(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
result = json.dumps(spec)
assert len(result) > 100
def test_send_endpoint_has_request_body(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
send = spec["paths"]["/v1/api/workstreams/{ws_id}/send"]["post"]
assert "requestBody" in send
assert "application/json" in send["requestBody"]["content"]
def test_health_endpoint_not_versioned(self):
from turnstone.api.server_spec import build_server_spec
spec = build_server_spec()
assert "/health" in spec["paths"]
assert "/v1/health" not in spec["paths"]
class TestConsoleSpec:
"""Validate the generated console OpenAPI spec."""
def test_valid_openapi_version(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
assert spec["openapi"] == "3.1.0"
def test_has_cluster_endpoints(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/cluster/overview",
"/v1/api/cluster/nodes",
"/v1/api/cluster/workstreams",
"/v1/api/cluster/node/{node_id}",
"/v1/api/cluster/workstreams/new",
"/v1/api/cluster/events",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_json_serializable(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
result = json.dumps(spec)
assert len(result) > 100
def test_nodes_endpoint_has_query_params(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
nodes = spec["paths"]["/v1/api/cluster/nodes"]["get"]
assert "parameters" in nodes
param_names = [p["name"] for p in nodes["parameters"]]
assert "sort" in param_names
assert "limit" in param_names
def test_has_coordinator_endpoints(self):
"""Phase 1-3 coordinator routes must appear in the OpenAPI catalog —
the spec was missing every coordinator endpoint except ``/open``,
so SDK consumers and operators couldn't discover the surface
from /docs. Pin the full set so a future regression that drops
one fails loudly."""
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
paths = set(spec["paths"].keys())
expected = {
"/v1/api/workstreams/new",
"/v1/api/workstreams",
"/v1/api/workstreams/{ws_id}",
"/v1/api/workstreams/{ws_id}/open",
"/v1/api/workstreams/{ws_id}/send",
"/v1/api/workstreams/{ws_id}/approve",
"/v1/api/workstreams/{ws_id}/cancel",
"/v1/api/workstreams/{ws_id}/rewind",
"/v1/api/workstreams/{ws_id}/retry",
"/v1/api/workstreams/{ws_id}/close",
"/v1/api/workstreams/{ws_id}/events",
"/v1/api/workstreams/{ws_id}/history",
"/v1/api/workstreams/{ws_id}/children",
"/v1/api/workstreams/{ws_id}/tasks",
"/v1/api/cluster/ws/{ws_id}/detail",
}
assert expected.issubset(paths), f"Missing: {expected - paths}"
def test_coordinator_create_has_request_body_and_200(self):
"""Coordinator create returns 200 and accepts a body.
Pre-1.5.0 this returned 201 (REST-strict for create); the lifted
``make_create_handler`` factory converges on 200 across both
kinds for response-shape parity with every other shared verb.
"""
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
op = spec["paths"]["/v1/api/workstreams/new"]["post"]
assert "requestBody" in op
assert "application/json" in op["requestBody"]["content"]
assert "200" in op["responses"]
def test_coordinator_history_has_limit_query_param(self):
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
op = spec["paths"]["/v1/api/workstreams/{ws_id}/history"]["get"]
param_names = [p["name"] for p in op.get("parameters", [])]
assert "ws_id" in param_names # auto-added from path
assert "limit" in param_names
def test_coordinator_endpoints_share_tag(self):
"""All coordinator endpoints (including the cluster-inspect one)
live under the same OpenAPI tag so /docs groups them together."""
from turnstone.api.console_spec import build_console_spec
spec = build_console_spec()
coord_paths = [p for p in spec["paths"] if "/coordinator" in p]
coord_paths.append("/v1/api/cluster/ws/{ws_id}/detail")
for path in coord_paths:
for op in spec["paths"][path].values():
assert "Coordinator" in op.get("tags", []), (
f"{path} missing Coordinator tag (tags={op.get('tags')})"
)