A-Mahla commited on
Commit
1cfad64
Β·
1 Parent(s): 70b6010

Add SPEECH_TO_SPEECH_URL: deploy-pinned direct URL that overrides the LB

Browse files

When set, the browser connects straight to it (shown read-only in
Settings) and all load-balancer logic β€” session proxy, queue, metering,
sign-in β€” is disabled. Also gitignore the local-only deploy_replica.py.

Files changed (5) hide show
  1. .gitignore +5 -0
  2. README.md +22 -9
  3. main.js +24 -7
  4. server.py +16 -1
  5. style.css +9 -0
.gitignore CHANGED
@@ -14,3 +14,8 @@ __pycache__/
14
  *.pyc
15
  .venv/
16
  venv/
 
 
 
 
 
 
14
  *.pyc
15
  .venv/
16
  venv/
17
+
18
+ # Local-only replica deploy script (never tracked)
19
+ deploy_replica.py
20
+
21
+ .env
README.md CHANGED
@@ -88,14 +88,26 @@ button, top-right):
88
 
89
  ## Connecting to a backend
90
 
91
- The app connects **directly** to a speech-to-speech server's realtime WebSocket β€”
92
- no load balancer, no `/session` step. Set the URL in two ways:
93
-
94
- - **`LOAD_BALANCER_URL` env** (served via `/api/config`) provides the default URL
95
- shown in Settings β€” handy for the deployed Space.
96
- - **Settings β†’ Speech-to-speech server URL** lets you override it: paste a full
 
 
 
 
 
97
  `connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
98
- (the app adds `/v1/realtime`).
 
 
 
 
 
 
 
99
 
100
  **Settings β†’ Restart** reconnects with the current voice, instructions and URL.
101
 
@@ -126,8 +138,9 @@ proxy from one container).
126
 
127
  ```bash
128
  pip install -r requirements.txt
129
- export SERPER_API_KEY=... # optional; web search is disabled without it
130
- export LOAD_BALANCER_URL=... # optional; default s2s server URL (set it in Settings otherwise)
 
131
  uvicorn server:app --reload --port 7860
132
  # or, matching production: docker build -t s2s . && docker run -p 7860:7860 -e SERPER_API_KEY=... -e LOAD_BALANCER_URL=... s2s
133
  ```
 
88
 
89
  ## Connecting to a backend
90
 
91
+ Three modes, picked by env (`/api/config` tells the client which one is active):
92
+
93
+ - **`SPEECH_TO_SPEECH_URL` env** β€” highest priority. The browser connects
94
+ **directly** to this realtime WebSocket URL; it's shown read-only in Settings.
95
+ Setting it disables the load-balancer logic entirely (no `/api/session` proxy,
96
+ no queue, no metering, no sign-in). Unlike the LB address it is not a secret.
97
+ - **`LOAD_BALANCER_URL` env** β€” the original flow: the browser POSTs the
98
+ same-origin `/api/session` proxy, the server forwards to the LB, and the
99
+ browser dials the per-session compute URL the LB hands back. The LB address
100
+ never reaches the browser; the Settings URL field is hidden.
101
+ - **Neither** β€” **Settings β†’ Speech-to-speech server URL**: paste a full
102
  `connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
103
+ (the app adds `/v1/realtime`), and the browser connects to it directly.
104
+
105
+ | `SPEECH_TO_SPEECH_URL` | `LOAD_BALANCER_URL` | `SPACE_ID` | Connection | URL field | Metering |
106
+ |:---:|:---:|:---:|---|---|---|
107
+ | βœ… | any | any | direct β†’ pinned URL | visible, locked | off |
108
+ | – | βœ… | βœ… | LB proxy | hidden | **on** |
109
+ | – | βœ… | – | LB proxy | hidden | off |
110
+ | – | – | any | direct β†’ user URL | editable | off |
111
 
112
  **Settings β†’ Restart** reconnects with the current voice, instructions and URL.
113
 
 
138
 
139
  ```bash
140
  pip install -r requirements.txt
141
+ export SERPER_API_KEY=... # optional; web search is disabled without it
142
+ export SPEECH_TO_SPEECH_URL=... # optional; pin a direct s2s server URL (overrides the LB)
143
+ export LOAD_BALANCER_URL=... # optional; session-proxy flow (set a URL in Settings otherwise)
144
  uvicorn server:app --reload --port 7860
145
  # or, matching production: docker build -t s2s . && docker run -p 7860:7860 -e SERPER_API_KEY=... -e LOAD_BALANCER_URL=... s2s
146
  ```
main.js CHANGED
@@ -264,16 +264,22 @@ let currentState = "idle";
264
  let settings = loadSettings();
265
 
266
  // ── Connection target ────────────────────────────────────────────────────────
267
- // Two modes, decided by the deploy via /api/config:
 
 
 
268
  // β€’ LOAD_BALANCER_URL set -> original flow: POST the same-origin /api/session
269
  // proxy (the server forwards to the LB; the LB address is never sent here).
270
- // β€’ unset (allowDirect) -> the user sets a speech-to-speech server URL and
271
  // the browser connects to it directly (no load balancer, no /session).
272
  let lbMode = false;
273
  // Fail open: direct entry is allowed unless /api/config reports an LB URL. This
274
  // way a missing/unreachable config (e.g. static hosting) leaves the field
275
  // usable rather than locked.
276
  let allowDirect = true;
 
 
 
277
 
278
  // ── Tool state ──────────────────────────────────────────────────────────────
279
  let toolsEnabled = loadTools();
@@ -829,6 +835,8 @@ async function fetchConfig() {
829
  lbMode = !!json.lb;
830
  // Lock to LB mode only when the deploy reports a load balancer.
831
  allowDirect = json.allowDirect ?? !lbMode;
 
 
832
  // The conversation-time limiter rides on the LB being present.
833
  limiterOn = lbMode;
834
  }
@@ -855,7 +863,7 @@ function connectionTarget() {
855
  if (!allowDirect) {
856
  return { sessionUrl: "api/session" };
857
  }
858
- const directUrl = buildDirectWsUrl(settings.directUrl);
859
  if (!directUrl) {
860
  throw new Error("Enter a speech-to-speech server URL in Settings.");
861
  }
@@ -904,10 +912,11 @@ function createResumedAudioContext() {
904
  }
905
 
906
  /** Read the editable settings out of the form. The URL field is only honoured
907
- * in direct mode (in LB mode it's locked and server-owned). */
 
908
  function readSettingsFromForm() {
909
  return {
910
- directUrl: allowDirect ? inputLbUrl.value.trim() : settings.directUrl,
911
  voice: inputVoice.value || DEFAULT_VOICE,
912
  instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
913
  noiseGate: readGateThreshold(),
@@ -923,10 +932,18 @@ function readGateThreshold() {
923
 
924
  /** Adapt the connection field to the mode learned from /api/config. */
925
  function syncConnectionUi() {
926
- if (allowDirect) {
 
 
 
 
 
 
 
927
  // Direct mode: the user sets their own s2s server URL.
928
  connField.hidden = false;
929
  inputLbUrl.value = settings.directUrl;
 
930
  inputLbUrl.placeholder = "http://localhost:port";
931
  connHint.classList.remove("error");
932
  connHint.textContent =
@@ -941,7 +958,7 @@ function syncConnectionUi() {
941
  /** True when the user must supply a server URL before connecting (direct mode
942
  * with nothing set). */
943
  function missingServerUrl() {
944
- return allowDirect && !buildDirectWsUrl(settings.directUrl);
945
  }
946
 
947
  /** Open Settings and point the user at the empty server-URL field. */
 
264
  let settings = loadSettings();
265
 
266
  // ── Connection target ────────────────────────────────────────────────────────
267
+ // Three modes, decided by the deploy via /api/config:
268
+ // β€’ SPEECH_TO_SPEECH_URL set -> direct mode pinned by the deploy: the browser
269
+ // connects straight to that URL, shown read-only in Settings. Overrides the
270
+ // load balancer entirely.
271
  // β€’ LOAD_BALANCER_URL set -> original flow: POST the same-origin /api/session
272
  // proxy (the server forwards to the LB; the LB address is never sent here).
273
+ // β€’ neither (allowDirect) -> the user sets a speech-to-speech server URL and
274
  // the browser connects to it directly (no load balancer, no /session).
275
  let lbMode = false;
276
  // Fail open: direct entry is allowed unless /api/config reports an LB URL. This
277
  // way a missing/unreachable config (e.g. static hosting) leaves the field
278
  // usable rather than locked.
279
  let allowDirect = true;
280
+ // Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
281
+ // mode: the field displays it read-only and the saved user URL is untouched.
282
+ let pinnedUrl = "";
283
 
284
  // ── Tool state ──────────────────────────────────────────────────────────────
285
  let toolsEnabled = loadTools();
 
835
  lbMode = !!json.lb;
836
  // Lock to LB mode only when the deploy reports a load balancer.
837
  allowDirect = json.allowDirect ?? !lbMode;
838
+ // Deploy-pinned direct URL (overrides the LB server-side already).
839
+ pinnedUrl = (json.s2sUrl || "").trim();
840
  // The conversation-time limiter rides on the LB being present.
841
  limiterOn = lbMode;
842
  }
 
863
  if (!allowDirect) {
864
  return { sessionUrl: "api/session" };
865
  }
866
+ const directUrl = buildDirectWsUrl(pinnedUrl || settings.directUrl);
867
  if (!directUrl) {
868
  throw new Error("Enter a speech-to-speech server URL in Settings.");
869
  }
 
912
  }
913
 
914
  /** Read the editable settings out of the form. The URL field is only honoured
915
+ * in free direct mode β€” in LB mode it's hidden, and when the deploy pins a
916
+ * URL it's read-only, so the user's saved URL survives either way. */
917
  function readSettingsFromForm() {
918
  return {
919
+ directUrl: allowDirect && !pinnedUrl ? inputLbUrl.value.trim() : settings.directUrl,
920
  voice: inputVoice.value || DEFAULT_VOICE,
921
  instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
922
  noiseGate: readGateThreshold(),
 
932
 
933
  /** Adapt the connection field to the mode learned from /api/config. */
934
  function syncConnectionUi() {
935
+ if (pinnedUrl) {
936
+ // Deploy-pinned URL: show it, but locked β€” the deployment owns it.
937
+ connField.hidden = false;
938
+ inputLbUrl.value = pinnedUrl;
939
+ inputLbUrl.readOnly = true;
940
+ connHint.classList.remove("error");
941
+ connHint.textContent = "Speech-to-speech server URL pinned by this deployment.";
942
+ } else if (allowDirect) {
943
  // Direct mode: the user sets their own s2s server URL.
944
  connField.hidden = false;
945
  inputLbUrl.value = settings.directUrl;
946
+ inputLbUrl.readOnly = false;
947
  inputLbUrl.placeholder = "http://localhost:port";
948
  connHint.classList.remove("error");
949
  connHint.textContent =
 
958
  /** True when the user must supply a server URL before connecting (direct mode
959
  * with nothing set). */
960
  function missingServerUrl() {
961
+ return allowDirect && !pinnedUrl && !buildDirectWsUrl(settings.directUrl);
962
  }
963
 
964
  /** Open Settings and point the user at the empty server-URL field. */
server.py CHANGED
@@ -18,8 +18,12 @@ On the deployed Space the server also meters conversation time by HF login tier
18
  is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on
19
  the live Space, never locally (even with the LB exported for testing).
20
 
 
 
 
 
21
  Endpoints:
22
- GET /api/config -> { search, lb, allowDirect, auth }
23
  GET /api/me -> login + tier + remaining budget (LB mode only)
24
  POST /api/search -> { results, answer } Google via Serper.dev
25
  POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
@@ -58,6 +62,14 @@ SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip()
58
  # When empty, the user may instead set a direct s2s server URL in Settings and the
59
  # browser connects to it straight (no load balancer).
60
  LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
 
 
 
 
 
 
 
 
61
  # HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
62
  # locally and on a plain `docker run`. We meter conversation time ONLY on the
63
  # deployed Space β€” i.e. when BOTH the LB is configured AND we're on a Space.
@@ -112,6 +124,9 @@ def config():
112
  "search": bool(SERPER_KEY),
113
  "lb": bool(LOAD_BALANCER_URL),
114
  "allowDirect": not LOAD_BALANCER_URL,
 
 
 
115
  "auth": AUTH_ENABLED,
116
  }
117
 
 
18
  is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on
19
  the live Space, never locally (even with the LB exported for testing).
20
 
21
+ `SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is
22
+ disabled entirely (no session proxy, no queue, no metering, no sign-in) and the
23
+ browser connects directly to that URL, shown read-only in Settings.
24
+
25
  Endpoints:
26
+ GET /api/config -> { search, lb, allowDirect, s2sUrl, auth }
27
  GET /api/me -> login + tier + remaining budget (LB mode only)
28
  POST /api/search -> { results, answer } Google via Serper.dev
29
  POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
 
62
  # When empty, the user may instead set a direct s2s server URL in Settings and the
63
  # browser connects to it straight (no load balancer).
64
  LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
65
+ # Direct s2s server URL pinned by the deploy. Takes priority over the load
66
+ # balancer: when set, ALL LB logic is disabled (no /api/session proxy, no queue,
67
+ # no limiter, no sign-in) and the browser connects to this URL directly. Unlike
68
+ # the LB address it is NOT a secret β€” /api/config sends it to the client, which
69
+ # shows it read-only in Settings.
70
+ SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
71
+ if SPEECH_TO_SPEECH_URL:
72
+ LOAD_BALANCER_URL = ""
73
  # HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
74
  # locally and on a plain `docker run`. We meter conversation time ONLY on the
75
  # deployed Space β€” i.e. when BOTH the LB is configured AND we're on a Space.
 
124
  "search": bool(SERPER_KEY),
125
  "lb": bool(LOAD_BALANCER_URL),
126
  "allowDirect": not LOAD_BALANCER_URL,
127
+ # Deploy-pinned direct s2s URL (empty when unset). Not a secret: the
128
+ # browser dials it itself, and Settings shows it locked.
129
+ "s2sUrl": SPEECH_TO_SPEECH_URL,
130
  "auth": AUTH_ENABLED,
131
  }
132
 
style.css CHANGED
@@ -1419,6 +1419,15 @@ body.cam-on .footer {
1419
  .field textarea:focus {
1420
  border-color: var(--text-dim);
1421
  }
 
 
 
 
 
 
 
 
 
1422
  .field small {
1423
  color: var(--text-faint);
1424
  font-size: 12px;
 
1419
  .field textarea:focus {
1420
  border-color: var(--text-dim);
1421
  }
1422
+ /* Deploy-pinned server URL: shown for transparency, not editable. */
1423
+ .field input[readonly] {
1424
+ color: var(--text-dim);
1425
+ border-style: dashed;
1426
+ cursor: default;
1427
+ }
1428
+ .field input[readonly]:focus {
1429
+ border-color: var(--border);
1430
+ }
1431
  .field small {
1432
  color: var(--text-faint);
1433
  font-size: 12px;