diff --git a/.dockerignore b/.dockerignore
index 33e4966..6826e5c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -17,3 +17,8 @@
**/uploads/
**/sessions/
debug_files/
+tmp/
+**/.mypy_cache/
+**/.pytest_cache/
+**/.ruff_cache/
+**/*.egg-info/
diff --git a/README.md b/README.md
index a149127..9046a04 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ documentation, source identifiers, comments, and internal instructions use Engli
Start with [client setup and usage](docs/client/usage.md), then read the
[architecture](docs/client/architecture.md) and [documentation index](docs/README.md).
The speech backend needs its own installed model and runtime dependencies;
-`s2s-client/requirements.txt` installs only the UI server dependencies.
+The uv project in `s2s-client/` installs only the UI server dependencies.
## Run the browser client
@@ -25,13 +25,13 @@ S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime ./start_ui.sh
```
Open and allow microphone access. The launcher creates
-`.venv`, installs missing Python requirements and locked frontend dependencies,
+`s2s-client/.venv`, syncs locked Python and frontend dependencies,
and loads the root `.env` if present. Vite listens on `UI_PORT` (default `8420`)
and forwards HTTP/WebSocket backend routes to FastAPI on `127.0.0.1:8421`.
Both servers reload during development; stopping the launcher stops both.
Leave `SPEECH_TO_SPEECH_URL` unset to use the same-origin speech proxy.
-The launcher requires Python 3 with venv support, Node.js 22.18+ on the 22.x
+The launcher requires uv 0.12.5 (which provisions Python 3.12), Node.js 22.18+ on the 22.x
line or Node.js 24+, npm, Bash 4.4+, `setsid`, and `curl`. For staging hostnames,
set `S2S_DEV_ALLOWED_HOSTS` and forward the Vite HMR WebSocket through the existing
HTTPS reverse proxy. See [development setup](s2s-frontend/README.md).
@@ -52,6 +52,7 @@ proxy deployment, supply a backend address reachable from the container instead.
## Project references
- [Change history](s2s-client/CHANGELOG.md)
+- [Backend development and compatibility](docs/client/development.md)
- [Realtime protocol and pipeline](docs/speech/realtime-api.md)
- [Context injection](docs/client/context-injection.md)
- [Tool calling](docs/client/tool-calling.md)
diff --git a/docs/README.md b/docs/README.md
index 2a6cbdb..c667713 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -8,6 +8,7 @@ do not define the current implementation.
- [Usage and configuration](client/usage.md)
- [Architecture and source map](client/architecture.md)
+- [Backend development and compatibility](client/development.md)
- [Reasoning modes](client/reasoning.md)
- [Context injection](client/context-injection.md)
- [Tool calling](client/tool-calling.md)
diff --git a/docs/client/architecture.md b/docs/client/architecture.md
index a3b1141..a385020 100644
--- a/docs/client/architecture.md
+++ b/docs/client/architecture.md
@@ -42,7 +42,14 @@ audio. It is not an unconditional byte-for-byte tunnel.
| Source | Responsibility |
|---|---|
-| [server.py](../../s2s-client/server.py) | Configuration, documentation serving, WebSocket relay, context loading, ApiPilot, reasoning, search and queue routes |
+| [server.py](../../s2s-client/server.py) | Compatibility entry point for existing `server:app` commands |
+| [main.py](../../s2s-client/s2s_client/main.py) | FastAPI assembly, documentation serving and redirects |
+| [api](../../s2s-client/s2s_client/api) | HTTP route registration, handlers and compatible response validation |
+| [models](../../s2s-client/s2s_client/models) | Pydantic request and response contracts |
+| [core](../../s2s-client/s2s_client/core) | Existing environment parsing, logging, URL helpers and diagnostics |
+| [providers](../../s2s-client/s2s_client/providers) | Caller context and ApiPilot HTTP integrations |
+| [services](../../s2s-client/s2s_client/services) | Context construction, reasoning and tool-context rendering |
+| [realtime](../../s2s-client/s2s_client/realtime) | WebSocket relay, events, markers and audio handling |
| [start_ui.sh](../../start_ui.sh) | Dependency setup, ports, readiness, reload processes, and shutdown |
| [vite.config.ts](../../s2s-frontend/vite.config.ts) | Public development endpoint, HMR, allowed hosts, and backend routing |
| [App.tsx](../../s2s-frontend/src/App.tsx) | React layout, voice controls, dialogs, profile and reports sidebar |
@@ -75,6 +82,10 @@ source text, and directories without `index.html` are not browsable pages.
FastAPI serves backend and documentation routes only. The frontend source and
assets live in `s2s-frontend` and are served by Vite.
+The uv project and its Python tools are scoped to `s2s-client/`. Response models
+describe service-owned structure and retain loose upstream values. See
+[development and compatibility](development.md) before tightening a contract.
+
## Audio path
1. The browser captures microphone audio with echo cancellation, noise
diff --git a/docs/client/context-injection.md b/docs/client/context-injection.md
index 2a5105b..8a6e62e 100644
--- a/docs/client/context-injection.md
+++ b/docs/client/context-injection.md
@@ -2,7 +2,8 @@
Context reaches the assistant through browser-built instructions, server-patched
realtime session instructions, and HTTP reasoning context. The implementation is
-in [server.py](../../s2s-client/server.py),
+in the server's [context services](../../s2s-client/s2s_client/services/context.py),
+[realtime proxy](../../s2s-client/s2s_client/realtime/proxy.py),
[context.ts](../../s2s-frontend/src/lib/context.ts), and the
[WebSocket client](../../s2s-frontend/src/realtime/client.ts).
diff --git a/docs/client/development.md b/docs/client/development.md
new file mode 100644
index 0000000..0120b4e
--- /dev/null
+++ b/docs/client/development.md
@@ -0,0 +1,77 @@
+# Backend development and compatibility
+
+The FastAPI project lives entirely in `s2s-client/`. It uses uv 0.12.5, Python
+3.12, and a committed `uv.lock`. The frontend and speech pipeline keep their
+own tooling. Runtime dependency versions match the environment used to capture
+the pre-migration server; framework upgrades require a separate compatibility
+review.
+
+## Commands
+
+From the repository root:
+
+```bash
+make -C s2s-client install # uv sync --locked, including development tools
+make -C s2s-client format # Ruff safe fixes and formatting
+make -C s2s-client check # lock freshness, format, lint, mypy, pytest
+make -C s2s-client test-launcher # Bash supervisor integration fixtures
+make -C s2s-client test-image # Docker build and isolated runtime smoke test
+```
+
+Run `uv lock` inside `s2s-client/` when intentionally changing dependencies.
+Do not install server packages into a shared root environment. The launcher
+syncs `s2s-client/.venv` and retains root `.env` loading, port defaults,
+readiness checks, and process-group shutdown. Offline starts require the
+locked packages and Python runtime to have been installed or cached already.
+
+Optional hooks use `make -C s2s-client hooks-install`; the configuration stays
+in `s2s-client/.pre-commit-config.yaml`. `make -C s2s-client hooks-run` runs the
+same hooks without installing them. Hooks and Python quality checks apply
+only to the client service. The server entry points are `s2s_client.main:app`
+and the retained `server:app` shim; use `--app-dir s2s-client` from the root.
+
+## Schema boundaries
+
+`models/requests.py` retains the existing search, reasoning, and ApiPilot
+request models, including their defaults, coercion, and ignored extra fields.
+The queue beacon still accepts malformed JSON and non-object bodies as before;
+its Pydantic view is applied only after the original object check, and
+`queueId` remains unconstrained.
+
+`models/responses.py` validates structure constructed by this server. Fields
+owned by external services remain `Any` where the old parser imposed no type
+constraint. Object models allow extra fields. Caller profiles can be arbitrary
+JSON; load-balancer fields remain optional; execution results and error details
+remain open. Existing provider parsing, authentication precedence, argument
+filtering, environment parsing, and WebSocket frames are unchanged.
+
+HTTP handlers call `validated_json_response`: Pydantic validates the payload,
+then `JSONResponse` serializes the original object. Returning a dumped model
+would insert defaults or change values/key order, so do not replace this helper
+with model serialization without a contract review. Route `response_model`
+declarations publish the new response schemas in OpenAPI. Existing request
+schemas and validation errors remain unchanged. HTTP exceptions retain their
+existing error shapes; there is no new common error envelope.
+
+## Compatibility evidence
+
+`tests/fixtures/legacy-contracts.json` was captured from the original monolithic
+server at commit `3d42df5`, before refactoring. Tests compare 86 HTTP scenarios,
+including exact response text, statuses, selected headers, and outgoing mock
+HTTP requests. They also compare request OpenAPI definitions and representative
+realtime frames. Additional tests cover response validation and permissive
+upstream values; the original local HTTP/WebSocket tests remain in the suite.
+
+The captures deliberately preserve unknown fields, omitted fields, unusual JSON
+values, queue beacon edge cases, permissive tool arguments, and known failures.
+Examples include reasoning's existing uninitialized local variables in certain
+configuration combinations, API-key-only tool precheck behavior, the catalog
+cache's existing key, and relay termination after a suppressed invalid
+`session.update` error. Fix those in separately reviewed behavior changes;
+do not regenerate golden captures to make a refactor pass.
+
+These checks use local fixtures and do not claim exhaustive coverage of all
+provider responses, live speech models, or production configuration. No deployed
+services or real credentials are needed. The Docker check verifies the actual
+runtime image, API configuration, documentation, OpenAPI, and the backend's
+existing root 404; it removes its temporary container and image afterward.
diff --git a/docs/client/reasoning.md b/docs/client/reasoning.md
index c336734..2e0bbed 100644
--- a/docs/client/reasoning.md
+++ b/docs/client/reasoning.md
@@ -99,7 +99,7 @@ realtime backend instead.
|---|---|
| Change the base agent prompt | `S2S_PRIMARY_PROMPT` or browser settings when unpinned |
| Change browser context assembly | `buildInstructions()` in `s2s-frontend/src/lib/context.ts` |
-| Change HTTP provider messages | `reasoning()` in `server.py` |
+| Change HTTP provider messages | `reasoning()` in `s2s-client/s2s_client/services/reasoning.py` |
| Change external-answer rendering | `VoiceController.reason()` and `RealtimeClient.renderExternalAnswer()` |
| Change Node-RED processing | `s2s-reasoning-engine/flows.json` |
| Change native model/tool generation | `speech_to_speech/LLM` and realtime handlers |
diff --git a/docs/client/troubleshooting.md b/docs/client/troubleshooting.md
index 39ba6fc..cf6434e 100644
--- a/docs/client/troubleshooting.md
+++ b/docs/client/troubleshooting.md
@@ -35,9 +35,10 @@ launcher. If the page loads but edits do not appear, check the browser's
`/__vite_hmr` WebSocket and the reverse proxy's upgrade handling. Speech uses
the separate `/v1/realtime` WebSocket on the same public origin.
-For a startup dependency error, check Node/npm on the process manager's PATH,
-Python venv support, and `setsid`/`curl`. The first start installs dependencies;
-later starts reuse them until frontend manifests change. Changes to root
+For a startup dependency error, check uv 0.12.5 and Node/npm on the process
+manager's PATH, plus `setsid`/`curl`. uv manages Python 3.12 in
+`s2s-client/.venv` and checks the committed lock on every start. Frontend
+dependencies are reused until their manifests change. Changes to root
`.env` or launcher settings require a restart.
## The assistant promises a tool but nothing happens
@@ -126,7 +127,7 @@ From the repository root, run the frontend checks and local backend fixtures:
```bash
npm --prefix s2s-frontend run check
npm --prefix s2s-frontend run build
-.venv/bin/python -m unittest discover -s s2s-client/tests -v
+make -C s2s-client check
```
These checks cover frontend contracts and local session/queue/WebSocket
diff --git a/docs/client/usage.md b/docs/client/usage.md
index 6cafee3..933a29d 100644
--- a/docs/client/usage.md
+++ b/docs/client/usage.md
@@ -12,8 +12,8 @@ From the repository root:
S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime ./start_ui.sh
```
-The launcher loads the root `.env`, creates `.venv` if needed, installs the
-client requirements when the dependency check fails, and installs the locked
+The launcher loads the root `.env`, syncs `s2s-client/.venv` from the committed
+`s2s-client/uv.lock`, and installs the locked
frontend dependencies when needed. Vite serves the React frontend on port
8420 and proxies the backend routes to Uvicorn on `127.0.0.1:8421`.
`UI_PORT` overrides the public port; `S2S_BACKEND_PORT` overrides the internal
@@ -21,7 +21,7 @@ port. Both servers reload source edits, and stopping the launcher stops both.
The speech pipeline remains a separate service. Because `.env` is sourced by
the script, values in that file can replace variables supplied on the command line.
-Use Node.js 22.18+ on the 22.x line or Node.js 24+, npm, Python 3 with venv,
+Use Node.js 22.18+ on the 22.x line or Node.js 24+, npm, uv 0.12.5,
Bash 4.4+, `setsid`, and `curl`. Remote staging access also needs the exact
hostname in `S2S_DEV_ALLOWED_HOSTS`, HTTPS, and WebSocket forwarding for speech
and Vite HMR. See [frontend development setup](../../s2s-frontend/README.md).
@@ -29,15 +29,18 @@ and Vite HMR. See [frontend development setup](../../s2s-frontend/README.md).
To install and start the backend manually:
```bash
-python3 -m venv .venv
-.venv/bin/python -m pip install -r s2s-client/requirements.txt
+uv sync --project s2s-client --locked
S2S_PROXY_TARGET=ws://localhost:8765/v1/realtime \
- .venv/bin/python -m uvicorn --app-dir s2s-client server:app \
+ uv run --project s2s-client --locked uvicorn --app-dir s2s-client s2s_client.main:app \
--host 127.0.0.1 --port 8421 --reload --reload-dir s2s-client
```
Then, in a second terminal, run `cd s2s-frontend`, `npm ci`, and `npm run dev`.
+uv provisions Python 3.12 when needed. The Python environment belongs only to
+`s2s-client`; an existing root `.venv` for speech dependencies is left alone.
+See [development and compatibility](development.md) for checks and schema rules.
+
Open , tap the orb/microphone control, grant microphone
access, and speak. Microphone access requires HTTPS or a local browser origin;
use HTTPS when accessing the UI from another machine.
diff --git a/s2s-client/CHANGELOG.md b/s2s-client/CHANGELOG.md
index 8d574dd..f9b1abd 100644
--- a/s2s-client/CHANGELOG.md
+++ b/s2s-client/CHANGELOG.md
@@ -10,8 +10,12 @@ the implementation at the recorded date; current operating instructions live in
### Changed
- Added a uv-managed Python 3.12 environment and Ruff, mypy, and pytest quality
- checks. Split the FastAPI service into configuration, route, provider,
+ checks, all contained in `s2s-client/`. Startup and Docker use the committed
+ lockfile. Split the FastAPI service into configuration, route, provider,
context/reasoning, and realtime modules while retaining `server:app` startup.
+- Added Pydantic response contracts with permissive upstream fields and original
+ payload serialization. Captured HTTP, request-schema, and realtime regression
+ fixtures preserve existing API behavior, including known failures.
- Removed the legacy browser site in favor of the React application in
`s2s-frontend`. FastAPI now serves only backend and documentation routes.
- Retired daily talk-time accounting and the associated Hugging Face account
diff --git a/s2s-client/Dockerfile b/s2s-client/Dockerfile
index 5e7729c..4d43a41 100644
--- a/s2s-client/Dockerfile
+++ b/s2s-client/Dockerfile
@@ -2,16 +2,24 @@
# Serve backend APIs, the WebSocket proxy, and shared project documentation.
# Run the React/Vite frontend separately; this image contains no browser UI.
# HF Spaces (sdk: docker) routes traffic to $PORT, default 7860.
-FROM python:3.11-slim
+FROM python:3.12.14-slim
+
+COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /uvx /bin/
+
+ENV UV_LINK_MODE=copy \
+ UV_PYTHON_DOWNLOADS=never \
+ PATH="/app/s2s-client/.venv/bin:$PATH"
WORKDIR /app/s2s-client
-COPY s2s-client/requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
+COPY s2s-client/pyproject.toml s2s-client/uv.lock ./
+RUN uv sync --locked --no-dev --no-install-project
-COPY s2s-client/ .
+COPY s2s-client/README.md s2s-client/server.py ./
+COPY s2s-client/s2s_client/ ./s2s_client/
COPY docs/ /app/docs/
+RUN uv sync --locked --no-dev
EXPOSE 7860
-CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
+CMD ["uvicorn", "s2s_client.main:app", "--host", "0.0.0.0", "--port", "7860"]
diff --git a/s2s-client/README.md b/s2s-client/README.md
index 142e979..2d7cf86 100644
--- a/s2s-client/README.md
+++ b/s2s-client/README.md
@@ -31,17 +31,24 @@ Build from the repository root so the container includes the documentation:
docker build -f s2s-client/Dockerfile -t s2s-client .
```
-Run the backend regression tests from the repository root after installing
-`requirements.txt` in `.venv`:
+The Python project is contained in this directory: `pyproject.toml`, `uv.lock`,
+`.python-version`, `.venv`, quality tools, and tests. Use uv 0.12.5 and Python
+3.12 (uv can provision Python). From the repository root:
```bash
-.venv/bin/python -m unittest discover -s s2s-client/tests -v
+make -C s2s-client install
+make -C s2s-client check
```
Tests isolate environment configuration, use HTTP fixtures for allocation and
caller authentication, and start a loopback WebSocket service to verify audio
frames, keepalive, and teardown. No deployed services or database are needed.
+See [backend development and compatibility](../docs/client/development.md) for
+formatting, typing, schema policy, optional Git hooks, and deployment checks.
+`uvicorn server:app --app-dir s2s-client` remains a supported entry point;
+the implementation now lives in `s2s_client/`.
+
FastAPI's API explorer remains at `/docs`. Project documents are served as files below
`/docs/client/`, `/docs/speech/`, `/docs/adr/`, `/docs/examples/`, and
`/docs/proposals/`. The historical `/project/` and `/public/docs.html` URLs
diff --git a/s2s-client/requirements.txt b/s2s-client/requirements.txt
deleted file mode 100644
index 9cb3e8e..0000000
--- a/s2s-client/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-fastapi==0.115.6
-uvicorn[standard]==0.34.0
-httpx==0.28.1
-python-dotenv==1.2.2
diff --git a/s2s-client/scripts/test_docker_image.sh b/s2s-client/scripts/test_docker_image.sh
new file mode 100644
index 0000000..ec0e598
--- /dev/null
+++ b/s2s-client/scripts/test_docker_image.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Build the actual runtime and check HTTP behavior without external services.
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+IMAGE="s2s-client-smoke:$(date +%s)-$$"
+container_id=""
+cleanup() {
+ if [ -n "$container_id" ]; then
+ docker rm --force "$container_id" >/dev/null
+ fi
+ docker image rm "$IMAGE" >/dev/null 2>&1 || true
+}
+trap cleanup EXIT
+
+docker build --file "$ROOT_DIR/s2s-client/Dockerfile" --tag "$IMAGE" "$ROOT_DIR"
+container_id="$(docker run --detach --publish 127.0.0.1::7860 "$IMAGE")"
+port="$(docker port "$container_id" 7860/tcp)"
+ready=false
+for ((attempt = 0; attempt < 60; attempt++)); do
+ if curl --noproxy '*' --silent --fail --max-time 1 "http://$port/api/config" >/dev/null; then
+ ready=true
+ break
+ fi
+ sleep 0.5
+done
+if [ "$ready" != true ]; then
+ docker logs "$container_id"
+ exit 1
+fi
+docker exec -i "$container_id" python - <<'PY'
+import json
+import urllib.error
+import urllib.request
+
+base = "http://127.0.0.1:7860"
+with urllib.request.urlopen(base + "/api/config") as response:
+ assert json.load(response) == {
+ "search": False, "lb": False, "allowDirect": True, "s2sUrl": "",
+ "primaryPrompt": "", "reasoningType": "PROXY", "injectProfile": False,
+ "debugPrompt": False, "startSessionMode": "", "theme": "", "apipilot": False,
+ }
+for path in ["/docs", "/docs/README.md", "/openapi.json"]:
+ with urllib.request.urlopen(base + path) as response:
+ assert response.status == 200
+try:
+ urllib.request.urlopen(base + "/")
+except urllib.error.HTTPError as error:
+ assert error.code == 404
+else:
+ raise AssertionError("The backend root must remain 404")
+print("Docker runtime smoke checks passed")
+PY
diff --git a/s2s-frontend/config/launcher.integration.ts b/s2s-frontend/config/launcher.integration.ts
index cce8d11..314e1b1 100644
--- a/s2s-frontend/config/launcher.integration.ts
+++ b/s2s-frontend/config/launcher.integration.ts
@@ -66,14 +66,19 @@ async function launcher(context: TestContext, env: Record = {})
const root = await mkdtemp(join(tmpdir(), "s2s-launcher-"));
const frontend = join(root, "s2s-frontend");
const modules = join(frontend, "node_modules");
- await mkdir(join(root, ".venv/bin"), { recursive: true });
- await mkdir(join(root, "s2s-client"));
+ await mkdir(join(root, "s2s-client/.venv/bin"), { recursive: true });
+ await mkdir(join(root, "bin"));
await mkdir(join(modules, "vite/bin"), { recursive: true });
await copyFile(new URL("../../start_ui.sh", import.meta.url), join(root, "start_ui.sh"));
- await writeFile(join(root, "s2s-client/requirements.txt"), "");
- await writeFile(join(root, ".venv/bin/activate"), 'export PATH="$S2S_TEST_ROOT/.venv/bin:$PATH"\n');
+ await writeFile(join(root, "s2s-client/pyproject.toml"), "");
+ await writeFile(join(root, "s2s-client/uv.lock"), "");
await writeFile(
- join(root, ".venv/bin/python"),
+ join(root, "bin/uv"),
+ '#!/bin/bash\nprintf "%s\\n" "$@" > "$S2S_TEST_ROOT/uv-args"\nprintf "%s" "$UV_PROJECT_ENVIRONMENT" > "$S2S_TEST_ROOT/uv-environment"\nexit "${S2S_TEST_UV_EXIT:-0}"\n',
+ { mode: 0o755 },
+ );
+ await writeFile(
+ join(root, "s2s-client/.venv/bin/python"),
'#!/bin/bash\nif [ "$1" = "-c" ]; then exit 0; fi\nexec node "$S2S_TEST_ROOT/child.mjs" backend "$@"\n',
{ mode: 0o755 },
);
@@ -94,6 +99,7 @@ async function launcher(context: TestContext, env: Record = {})
cwd: tmpdir(),
env: {
...process.env,
+ PATH: `${join(root, "bin")}:${process.env.PATH ?? ""}`,
UI_PORT: String(publicPort),
UI_HOST: "0.0.0.0",
NODE_ENV: "production",
@@ -138,6 +144,11 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) {
nodeEnv: "development",
});
assert.equal(run.output().includes("Installing"), false);
+ assert.equal(
+ await readFile(join(run.root, "uv-args"), "utf8"),
+ `sync\n--project\n${run.root}/s2s-client\n--locked\n`,
+ );
+ assert.equal(await readFile(join(run.root, "uv-environment"), "utf8"), `${run.root}/s2s-client/.venv`);
run.child.kill(signal);
assert.equal(await run.exited, signal === "SIGINT" ? 130 : 143);
for (const role of ["backend", "frontend"]) {
@@ -168,6 +179,13 @@ await test("does not start Vite when the backend fails readiness", { timeout: 10
assert.match(run.output(), /FastAPI exited before it was ready/);
});
+await test("does not start either server when uv sync fails", { timeout: 10_000 }, async (context) => {
+ const run = await launcher(context, { S2S_TEST_UV_EXIT: "23" });
+ assert.equal(await run.exited, 23, run.output());
+ await assert.rejects(readFile(join(run.root, "backend.json")));
+ await assert.rejects(readFile(join(run.root, "frontend.json")));
+});
+
await test("stops Vite if the backend exits after startup", { timeout: 10_000 }, async (context) => {
const run = await launcher(context);
await waitForFile(join(run.root, "frontend-worker-ready"));
diff --git a/start_ui.sh b/start_ui.sh
index 7bc4076..2554da4 100755
--- a/start_ui.sh
+++ b/start_ui.sh
@@ -2,8 +2,8 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-VENV_DIR="$ROOT_DIR/.venv"
-REQ_FILE="$ROOT_DIR/s2s-client/requirements.txt"
+CLIENT_DIR="$ROOT_DIR/s2s-client"
+VENV_DIR="$CLIENT_DIR/.venv"
FRONTEND_DIR="$ROOT_DIR/s2s-frontend"
if [ -f "$ROOT_DIR/.env" ]; then
set -a
@@ -20,7 +20,7 @@ if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4) ))
exit 1
fi
-for required_command in python3 node npm setsid curl; do
+for required_command in uv node npm setsid curl; do
if ! command -v "$required_command" >/dev/null 2>&1; then
echo "Error: $required_command was not found."
exit 1
@@ -46,22 +46,13 @@ if [ "$UI_PORT" = "$S2S_BACKEND_PORT" ]; then
exit 1
fi
-if [ ! -d "$VENV_DIR" ]; then
- echo "Creating the virtual environment in .venv..."
- python3 -m venv "$VENV_DIR"
-fi
-
-source "$VENV_DIR/bin/activate"
-
-if [ ! -f "$REQ_FILE" ]; then
- echo "Error: requirements file was not found: $REQ_FILE"
+if [ ! -f "$CLIENT_DIR/pyproject.toml" ] || [ ! -f "$CLIENT_DIR/uv.lock" ]; then
+ echo "Error: s2s-client/pyproject.toml and s2s-client/uv.lock are required."
exit 1
fi
-if ! python -c "import fastapi, uvicorn, httpx, dotenv, websockets" >/dev/null 2>&1; then
- echo "Installing dependencies from s2s-client/requirements.txt..."
- python -m pip install -r "$REQ_FILE"
-fi
+# uv manages only the client environment; an existing root speech venv is separate.
+UV_PROJECT_ENVIRONMENT="$VENV_DIR" uv sync --project "$CLIENT_DIR" --locked
# Keep restarts offline when the installed frontend manifests have not changed.
if [ ! -f "$FRONTEND_DIR/node_modules/vite/bin/vite.js" ] || \
@@ -118,10 +109,10 @@ echo " Press Ctrl+C in this terminal to stop both servers."
echo "----------------------------------------------------"
cd "$ROOT_DIR"
-setsid "$VENV_DIR/bin/python" -m uvicorn server:app \
- --app-dir "$ROOT_DIR/s2s-client" \
+setsid "$VENV_DIR/bin/python" -m uvicorn s2s_client.main:app \
+ --app-dir "$CLIENT_DIR" \
--host 127.0.0.1 --port "$S2S_BACKEND_PORT" \
- --reload --reload-dir "$ROOT_DIR/s2s-client" &
+ --reload --reload-dir "$CLIENT_DIR" &
backend_pid=$!
child_pids+=("$backend_pid")