3v324v23 commited on
Commit
b51442b
·
1 Parent(s): 2af280d

Fix authenticated Bextts Space calls

Browse files
HF_SPACE_CONNECTION.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space Connection Notes
2
+
3
+ Space: https://huggingface.co/spaces/archivartaunik/NotebookLM
4
+
5
+ Runtime URL: https://archivartaunik-notebooklm.hf.space
6
+
7
+ Repo: https://huggingface.co/spaces/archivartaunik/NotebookLM
8
+
9
+ Local workspace: `D:\CodexPRJ\OpenNotebook`
10
+
11
+ Hugging Face account currently used by `hf` CLI: `archivartaunik`
12
+
13
+ ## SSH / Dev Mode
14
+
15
+ Dev Mode SSH command:
16
+
17
+ ```powershell
18
+ ssh -i C:\Users\admin\.ssh\hagginfacekey archivartaunik-notebooklm@ssh.hf.space
19
+ ```
20
+
21
+ Equivalent Hugging Face CLI command to print the SSH command:
22
+
23
+ ```powershell
24
+ hf spaces ssh archivartaunik/NotebookLM --dry-run -i C:\Users\admin\.ssh\hagginfacekey
25
+ ```
26
+
27
+ Enable Dev Mode:
28
+
29
+ ```powershell
30
+ hf spaces dev-mode archivartaunik/NotebookLM
31
+ ```
32
+
33
+ Disable Dev Mode:
34
+
35
+ ```powershell
36
+ hf spaces dev-mode archivartaunik/NotebookLM --stop
37
+ ```
38
+
39
+ Check Space status:
40
+
41
+ ```powershell
42
+ hf spaces info archivartaunik/NotebookLM
43
+ ```
44
+
45
+ Restart Space:
46
+
47
+ ```powershell
48
+ hf spaces restart archivartaunik/NotebookLM
49
+ ```
50
+
51
+ Factory rebuild if the normal restart keeps an old container:
52
+
53
+ ```powershell
54
+ hf spaces restart archivartaunik/NotebookLM --factory-reboot
55
+ ```
56
+
57
+ ## Repo Workflow
58
+
59
+ Local repo remote:
60
+
61
+ ```powershell
62
+ git remote -v
63
+ ```
64
+
65
+ Expected remote:
66
+
67
+ ```text
68
+ origin https://huggingface.co/spaces/archivartaunik/NotebookLM (fetch)
69
+ origin https://huggingface.co/spaces/archivartaunik/NotebookLM (push)
70
+ ```
71
+
72
+ Before pushing, sync with remote:
73
+
74
+ ```powershell
75
+ git fetch origin
76
+ git rebase origin/main
77
+ ```
78
+
79
+ Commit and push:
80
+
81
+ ```powershell
82
+ git add <files>
83
+ git commit -m "Your commit message"
84
+ git push origin main
85
+ ```
86
+
87
+ Important: changes made directly inside Dev Mode SSH are not safely preserved unless committed and pushed from inside the Space or copied back to the local repo and pushed.
88
+
89
+ ## Useful Runtime Checks
90
+
91
+ Check that SSH reaches the Space:
92
+
93
+ ```powershell
94
+ ssh -o StrictHostKeyChecking=accept-new -i C:\Users\admin\.ssh\hagginfacekey archivartaunik-notebooklm@ssh.hf.space "pwd && echo SSH_OK"
95
+ ```
96
+
97
+ Check API health from inside the Space:
98
+
99
+ ```powershell
100
+ ssh -i C:\Users\admin\.ssh\hagginfacekey archivartaunik-notebooklm@ssh.hf.space "curl -fsS http://localhost:5055/health"
101
+ ```
102
+
103
+ Check frontend from inside the Space:
104
+
105
+ ```powershell
106
+ ssh -i C:\Users\admin\.ssh\hagginfacekey archivartaunik-notebooklm@ssh.hf.space "curl -fsS -o /dev/null -w 'frontend_http=%{http_code}\n' http://localhost:8502"
107
+ ```
108
+
109
+ Check important processes:
110
+
111
+ ```powershell
112
+ ssh -i C:\Users\admin\.ssh\hagginfacekey archivartaunik-notebooklm@ssh.hf.space "ps -eo pid,stat,comm,args | grep -E 'supervisord|uvicorn|next-server|surreal-command|surreal start' | grep -v grep"
113
+ ```
114
+
115
+ Fetch run logs:
116
+
117
+ ```powershell
118
+ $env:PYTHONIOENCODING='utf-8'
119
+ hf spaces logs archivartaunik/NotebookLM --tail 120
120
+ ```
121
+
122
+ Fetch build logs:
123
+
124
+ ```powershell
125
+ $env:PYTHONIOENCODING='utf-8'
126
+ hf spaces logs archivartaunik/NotebookLM --build
127
+ ```
128
+
129
+ ## Bextts / ZeroGPU Token
130
+
131
+ NotebookLM uses the public Bextts Space for Belarusian TTS:
132
+
133
+ ```powershell
134
+ hf spaces info archivartaunik/Bextts --expand runtime
135
+ ```
136
+
137
+ The Bextts Space runs on ZeroGPU, so NotebookLM must call it with an authenticated
138
+ Hugging Face token. Store the token as a NotebookLM Space secret:
139
+
140
+ ```powershell
141
+ $token = hf auth token
142
+ hf spaces secrets add archivartaunik/NotebookLM -s "HF_TOKEN=$token"
143
+ ```
144
+
145
+ At startup, `start-hf.sh` copies `HF_TOKEN` into the Bextts model credential config
146
+ as the provider API key. Without this, Bextts calls are anonymous and can fail with
147
+ `Bextts generation failed` while Bextts logs show `ZeroGPU quota exceeded`.
148
+
149
+ ## Current Notes
150
+
151
+ - Dev Mode requires the Docker image to include `git`. The Dockerfile currently installs `bash`, `curl`, `git`, `git-lfs`, `procps`, and `wget`.
152
+ - The previous startup crash was caused by a directory named `/app/frontend/node_modules/next/dist/compiled/ipaddr.js`. `start-hf.sh` now skips non-files before calling `read_text()`.
153
+ - Current known good commit after that fix: `fb2282d`.
154
+ - Do not store Hugging Face access tokens or service account secrets in this document.
start-hf.sh CHANGED
@@ -41,6 +41,7 @@ fi
41
  export TTS_BATCH_SIZE="${TTS_BATCH_SIZE:-1}"
42
  export TTS_BATCH_DELAY_SECONDS="${TTS_BATCH_DELAY_SECONDS:-5}"
43
  export BEXTTS_BASE_URL="${BEXTTS_BASE_URL:-https://archivartaunik-bextts.hf.space}"
 
44
 
45
  (
46
  /app/.venv/bin/python - <<'PY'
@@ -62,6 +63,7 @@ BEXTTS_BASE_URL = os.getenv(
62
  "BEXTTS_BASE_URL",
63
  "https://archivartaunik-bextts.hf.space",
64
  )
 
65
 
66
 
67
  def wait_for_api() -> None:
@@ -109,6 +111,7 @@ async def upsert_vertex_credential() -> str:
109
 
110
  async def upsert_bextts_credential() -> str:
111
  from open_notebook.domain.credential import Credential
 
112
 
113
  credentials = await Credential.get_by_provider("bextts")
114
  credential = next((item for item in credentials if item.name == "Bextts"), None)
@@ -125,6 +128,7 @@ async def upsert_bextts_credential() -> str:
125
  credential.provider = "bextts"
126
  credential.modalities = ["text_to_speech"]
127
  credential.base_url = BEXTTS_BASE_URL
 
128
  await credential.save()
129
  return str(credential.id)
130
 
 
41
  export TTS_BATCH_SIZE="${TTS_BATCH_SIZE:-1}"
42
  export TTS_BATCH_DELAY_SECONDS="${TTS_BATCH_DELAY_SECONDS:-5}"
43
  export BEXTTS_BASE_URL="${BEXTTS_BASE_URL:-https://archivartaunik-bextts.hf.space}"
44
+ export BEXTTS_API_KEY="${BEXTTS_API_KEY:-${HF_TOKEN:-}}"
45
 
46
  (
47
  /app/.venv/bin/python - <<'PY'
 
63
  "BEXTTS_BASE_URL",
64
  "https://archivartaunik-bextts.hf.space",
65
  )
66
+ BEXTTS_API_KEY = os.getenv("BEXTTS_API_KEY") or os.getenv("HF_TOKEN")
67
 
68
 
69
  def wait_for_api() -> None:
 
111
 
112
  async def upsert_bextts_credential() -> str:
113
  from open_notebook.domain.credential import Credential
114
+ from pydantic import SecretStr
115
 
116
  credentials = await Credential.get_by_provider("bextts")
117
  credential = next((item for item in credentials if item.name == "Bextts"), None)
 
128
  credential.provider = "bextts"
129
  credential.modalities = ["text_to_speech"]
130
  credential.base_url = BEXTTS_BASE_URL
131
+ credential.api_key = SecretStr(BEXTTS_API_KEY) if BEXTTS_API_KEY else None
132
  await credential.save()
133
  return str(credential.id)
134
 
upstream-esperanto/src/esperanto/providers/tts/bextts.py CHANGED
@@ -86,9 +86,20 @@ class BexttsTextToSpeechModel(TextToSpeechModel):
86
  base_url: Optional[str] = None,
87
  **kwargs: Any,
88
  ):
 
 
 
 
 
 
 
 
 
 
 
89
  super().__init__(
90
  model_name=model_name or BEXTTS_DEFAULT_MODEL,
91
- api_key=api_key,
92
  base_url=(
93
  base_url
94
  or os.getenv("BEXTTS_BASE_URL")
@@ -121,6 +132,9 @@ class BexttsTextToSpeechModel(TextToSpeechModel):
121
  def _file_url(self, file_path: str) -> str:
122
  return f"{self.base_url}/gradio_api/file={file_path}"
123
 
 
 
 
124
  def _build_reference_file(self, kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
125
  reference_audio = (
126
  kwargs.get("reference_audio")
@@ -253,7 +267,10 @@ class BexttsTextToSpeechModel(TextToSpeechModel):
253
  file_path = file_data.get("path")
254
  if not file_path:
255
  raise RuntimeError("Bextts response did not include an audio path")
256
- response = self.client.get(self._file_url(str(file_path)))
 
 
 
257
  if response.status_code >= 400:
258
  raise RuntimeError(f"Bextts audio download failed: HTTP {response.status_code}")
259
  return response.content
@@ -262,7 +279,10 @@ class BexttsTextToSpeechModel(TextToSpeechModel):
262
  file_path = file_data.get("path")
263
  if not file_path:
264
  raise RuntimeError("Bextts response did not include an audio path")
265
- response = await self.async_client.get(self._file_url(str(file_path)))
 
 
 
266
  if response.status_code >= 400:
267
  raise RuntimeError(f"Bextts audio download failed: HTTP {response.status_code}")
268
  return response.content
@@ -307,11 +327,17 @@ class BexttsTextToSpeechModel(TextToSpeechModel):
307
  payload = self._build_payload(normalized_text, voice, kwargs)
308
  response = self.client.post(
309
  self._predict_url(),
310
- headers={"Content-Type": "application/json"},
 
 
 
311
  json=payload,
312
  )
313
  event_id = self._extract_event_id(response)
314
- event_response = self.client.get(self._event_url(event_id))
 
 
 
315
  if event_response.status_code >= 400:
316
  raise RuntimeError(
317
  f"Bextts event stream failed: HTTP {event_response.status_code}"
@@ -345,11 +371,17 @@ class BexttsTextToSpeechModel(TextToSpeechModel):
345
  payload = self._build_payload(normalized_text, voice, kwargs)
346
  response = await self.async_client.post(
347
  self._predict_url(),
348
- headers={"Content-Type": "application/json"},
 
 
 
349
  json=payload,
350
  )
351
  event_id = self._extract_event_id(response)
352
- event_response = await self.async_client.get(self._event_url(event_id))
 
 
 
353
  if event_response.status_code >= 400:
354
  raise RuntimeError(
355
  f"Bextts event stream failed: HTTP {event_response.status_code}"
 
86
  base_url: Optional[str] = None,
87
  **kwargs: Any,
88
  ):
89
+ config_api_key = (
90
+ kwargs.get("api_key")
91
+ or kwargs.get("HF_TOKEN")
92
+ or kwargs.get("hf_token")
93
+ )
94
+ resolved_api_key = (
95
+ api_key
96
+ or config_api_key
97
+ or os.getenv("BEXTTS_API_KEY")
98
+ or os.getenv("HF_TOKEN")
99
+ )
100
  super().__init__(
101
  model_name=model_name or BEXTTS_DEFAULT_MODEL,
102
+ api_key=resolved_api_key,
103
  base_url=(
104
  base_url
105
  or os.getenv("BEXTTS_BASE_URL")
 
132
  def _file_url(self, file_path: str) -> str:
133
  return f"{self.base_url}/gradio_api/file={file_path}"
134
 
135
+ def _auth_headers(self) -> Dict[str, str]:
136
+ return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
137
+
138
  def _build_reference_file(self, kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
139
  reference_audio = (
140
  kwargs.get("reference_audio")
 
267
  file_path = file_data.get("path")
268
  if not file_path:
269
  raise RuntimeError("Bextts response did not include an audio path")
270
+ response = self.client.get(
271
+ self._file_url(str(file_path)),
272
+ headers=self._auth_headers(),
273
+ )
274
  if response.status_code >= 400:
275
  raise RuntimeError(f"Bextts audio download failed: HTTP {response.status_code}")
276
  return response.content
 
279
  file_path = file_data.get("path")
280
  if not file_path:
281
  raise RuntimeError("Bextts response did not include an audio path")
282
+ response = await self.async_client.get(
283
+ self._file_url(str(file_path)),
284
+ headers=self._auth_headers(),
285
+ )
286
  if response.status_code >= 400:
287
  raise RuntimeError(f"Bextts audio download failed: HTTP {response.status_code}")
288
  return response.content
 
327
  payload = self._build_payload(normalized_text, voice, kwargs)
328
  response = self.client.post(
329
  self._predict_url(),
330
+ headers={
331
+ "Content-Type": "application/json",
332
+ **self._auth_headers(),
333
+ },
334
  json=payload,
335
  )
336
  event_id = self._extract_event_id(response)
337
+ event_response = self.client.get(
338
+ self._event_url(event_id),
339
+ headers=self._auth_headers(),
340
+ )
341
  if event_response.status_code >= 400:
342
  raise RuntimeError(
343
  f"Bextts event stream failed: HTTP {event_response.status_code}"
 
371
  payload = self._build_payload(normalized_text, voice, kwargs)
372
  response = await self.async_client.post(
373
  self._predict_url(),
374
+ headers={
375
+ "Content-Type": "application/json",
376
+ **self._auth_headers(),
377
+ },
378
  json=payload,
379
  )
380
  event_id = self._extract_event_id(response)
381
+ event_response = await self.async_client.get(
382
+ self._event_url(event_id),
383
+ headers=self._auth_headers(),
384
+ )
385
  if event_response.status_code >= 400:
386
  raise RuntimeError(
387
  f"Bextts event stream failed: HTTP {event_response.status_code}"
upstream-esperanto/tests/providers/tts/test_bextts.py CHANGED
@@ -60,6 +60,29 @@ def test_init_defaults():
60
  assert model.base_url == BEXTTS_DEFAULT_BASE_URL
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def test_generate_speech_posts_to_gradio_and_downloads_wav(tts_model):
64
  tts_model.client.post.return_value = make_response(
65
  json_data={"event_id": "evt-1"}
@@ -89,6 +112,30 @@ def test_generate_speech_posts_to_gradio_and_downloads_wav(tts_model):
89
  assert response.provider == "bextts"
90
 
91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  def test_generate_speech_defaults_to_prepared_voice(tts_model):
93
  tts_model.client.post.return_value = make_response(
94
  json_data={"event_id": "evt-1"}
 
60
  assert model.base_url == BEXTTS_DEFAULT_BASE_URL
61
 
62
 
63
+ def test_init_uses_bextts_api_key_env(monkeypatch):
64
+ monkeypatch.setenv("BEXTTS_API_KEY", "hf_test_token")
65
+
66
+ model = BexttsTextToSpeechModel()
67
+
68
+ assert model.api_key == "hf_test_token"
69
+
70
+
71
+ def test_init_uses_hf_token_env_as_fallback(monkeypatch):
72
+ monkeypatch.delenv("BEXTTS_API_KEY", raising=False)
73
+ monkeypatch.setenv("HF_TOKEN", "hf_test_token")
74
+
75
+ model = BexttsTextToSpeechModel()
76
+
77
+ assert model.api_key == "hf_test_token"
78
+
79
+
80
+ def test_init_uses_hf_token_config():
81
+ model = BexttsTextToSpeechModel(HF_TOKEN="hf_test_token")
82
+
83
+ assert model.api_key == "hf_test_token"
84
+
85
+
86
  def test_generate_speech_posts_to_gradio_and_downloads_wav(tts_model):
87
  tts_model.client.post.return_value = make_response(
88
  json_data={"event_id": "evt-1"}
 
112
  assert response.provider == "bextts"
113
 
114
 
115
+ def test_generate_speech_sends_authorization_header(tts_model):
116
+ tts_model.api_key = "hf_test_token"
117
+ tts_model.client.post.return_value = make_response(
118
+ json_data={"event_id": "evt-1"}
119
+ )
120
+ tts_model.client.get.side_effect = [
121
+ make_response(text=COMPLETE_EVENT),
122
+ make_response(content=RAW_AUDIO),
123
+ ]
124
+
125
+ tts_model.generate_speech("Прывітанне", voice="Aaron Dreschner")
126
+
127
+ assert tts_model.client.post.call_args.kwargs["headers"] == {
128
+ "Content-Type": "application/json",
129
+ "Authorization": "Bearer hf_test_token",
130
+ }
131
+ assert tts_model.client.get.call_args_list[0].kwargs["headers"] == {
132
+ "Authorization": "Bearer hf_test_token",
133
+ }
134
+ assert tts_model.client.get.call_args_list[1].kwargs["headers"] == {
135
+ "Authorization": "Bearer hf_test_token",
136
+ }
137
+
138
+
139
  def test_generate_speech_defaults_to_prepared_voice(tts_model):
140
  tts_model.client.post.return_value = make_response(
141
  json_data={"event_id": "evt-1"}
upstream-open-notebook/api/credentials_service.py CHANGED
@@ -61,7 +61,7 @@ PROVIDER_ENV_CONFIG: Dict[str, dict] = {
61
  },
62
  "dashscope": {"required": ["DASHSCOPE_API_KEY"]},
63
  "minimax": {"required": ["MINIMAX_API_KEY"]},
64
- "bextts": {"required_any": ["BEXTTS_BASE_URL"]},
65
  }
66
 
67
  PROVIDER_MODALITIES: Dict[str, List[str]] = {
@@ -304,10 +304,12 @@ def create_credential_from_env(provider: str) -> Credential:
304
  api_key=SecretStr(api_key) if api_key else None,
305
  )
306
  elif provider == "bextts":
 
307
  return Credential(
308
  name=name,
309
  provider=provider,
310
  modalities=modalities,
 
311
  base_url=os.environ.get("BEXTTS_BASE_URL"),
312
  )
313
  else:
 
61
  },
62
  "dashscope": {"required": ["DASHSCOPE_API_KEY"]},
63
  "minimax": {"required": ["MINIMAX_API_KEY"]},
64
+ "bextts": {"required_any": ["BEXTTS_BASE_URL", "BEXTTS_API_KEY", "HF_TOKEN"]},
65
  }
66
 
67
  PROVIDER_MODALITIES: Dict[str, List[str]] = {
 
304
  api_key=SecretStr(api_key) if api_key else None,
305
  )
306
  elif provider == "bextts":
307
+ api_key = os.environ.get("BEXTTS_API_KEY") or os.environ.get("HF_TOKEN")
308
  return Credential(
309
  name=name,
310
  provider=provider,
311
  modalities=modalities,
312
+ api_key=SecretStr(api_key) if api_key else None,
313
  base_url=os.environ.get("BEXTTS_BASE_URL"),
314
  )
315
  else:
upstream-open-notebook/open_notebook/domain/credential.py CHANGED
@@ -78,7 +78,10 @@ class Credential(ObjectModel):
78
  """
79
  config: Dict[str, Any] = {}
80
  if self.api_key:
81
- config["api_key"] = self.api_key.get_secret_value()
 
 
 
82
  if self.base_url:
83
  config["base_url"] = self.base_url
84
  # For Azure, base_url from the UI form maps to endpoint
 
78
  """
79
  config: Dict[str, Any] = {}
80
  if self.api_key:
81
+ secret_value = self.api_key.get_secret_value()
82
+ config["api_key"] = secret_value
83
+ if self.provider and self.provider.lower() == "bextts":
84
+ config["HF_TOKEN"] = secret_value
85
  if self.base_url:
86
  config["base_url"] = self.base_url
87
  # For Azure, base_url from the UI form maps to endpoint
upstream-open-notebook/tests/test_bextts_provider.py CHANGED
@@ -13,7 +13,9 @@ from open_notebook.ai.model_discovery import (
13
 
14
 
15
  def test_bextts_credential_metadata():
16
- assert PROVIDER_ENV_CONFIG["bextts"] == {"required_any": ["BEXTTS_BASE_URL"]}
 
 
17
  assert get_default_modalities("bextts") == ["text_to_speech"]
18
 
19
 
 
13
 
14
 
15
  def test_bextts_credential_metadata():
16
+ assert PROVIDER_ENV_CONFIG["bextts"] == {
17
+ "required_any": ["BEXTTS_BASE_URL", "BEXTTS_API_KEY", "HF_TOKEN"]
18
+ }
19
  assert get_default_modalities("bextts") == ["text_to_speech"]
20
 
21
 
upstream-open-notebook/tests/test_credentials_api.py CHANGED
@@ -216,6 +216,29 @@ class TestCredentialNumCtx:
216
  assert "num_ctx" not in cred.to_esperanto_config()
217
 
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  class TestAudioProviderWiring:
220
  """Tests for the new audio providers (Mistral STT/TTS, Deepgram TTS, xAI TTS)."""
221
 
 
216
  assert "num_ctx" not in cred.to_esperanto_config()
217
 
218
 
219
+ class TestBexttsCredentialConfig:
220
+ """Tests for Bextts credential values threaded into Esperanto config."""
221
+
222
+ def test_api_key_is_exposed_as_hf_token_alias(self):
223
+ from pydantic import SecretStr
224
+
225
+ from open_notebook.domain.credential import Credential
226
+
227
+ cred = Credential(
228
+ name="Bextts",
229
+ provider="bextts",
230
+ modalities=["text_to_speech"],
231
+ api_key=SecretStr("hf_test_token"),
232
+ base_url="https://archivartaunik-bextts.hf.space",
233
+ )
234
+
235
+ config = cred.to_esperanto_config()
236
+
237
+ assert config["api_key"] == "hf_test_token"
238
+ assert config["HF_TOKEN"] == "hf_test_token"
239
+ assert config["base_url"] == "https://archivartaunik-bextts.hf.space"
240
+
241
+
242
  class TestAudioProviderWiring:
243
  """Tests for the new audio providers (Mistral STT/TTS, Deepgram TTS, xAI TTS)."""
244