diff --git a/config.py b/config.py index b3f83c3..7677e6b 100644 --- a/config.py +++ b/config.py @@ -96,10 +96,14 @@ _CORE_CFG = _load_core_config() _GEMINI = _CORE_CFG.get("gemini_defaults", {}) _AUDIO = _CORE_CFG.get("audio_defaults", {}) -# -- Gemini defaults (override via data/motions/config.json or env) -- -GEMINI_API_KEY = os.environ.get( - "SANAD_GEMINI_API_KEY", - _GEMINI.get("api_key", "")) +# -- Gemini API key — STRICT env-only ---- +# Single source of truth: cPanel "Setup Python App → Environment variables" → `api`. +# start.sh exports it before launching uvicorn (see start.sh step 3, which parses +# ~/.cl.selector/python-selector.json). +# NO fallbacks: no SANAD_GEMINI_API_KEY, no core_config.json value, no +# data/motions/config.json override. To rotate the key, edit the cPanel form +# and restart uvicorn — nothing else changes the key. +GEMINI_API_KEY = os.environ.get("api", "") GEMINI_MODEL = os.environ.get( "SANAD_GEMINI_MODEL", "models/" + _GEMINI.get("model_live", "gemini-2.5-flash-native-audio-preview-12-2025")) diff --git a/dashboard/routes/voice.py b/dashboard/routes/voice.py index a3d1b88..cdb9f00 100644 --- a/dashboard/routes/voice.py +++ b/dashboard/routes/voice.py @@ -134,33 +134,38 @@ class ApiKeyPayload(BaseModel): async def get_api_key(): """Return the current Gemini API key in masked form. - Never returns the full key. Response: - { - "has_key": true, - "masked": "AIza***...kqf8", - "length": 39, - "source": "config_file" | "default" - } + The key now comes EXCLUSIVELY from the `api` env var (sourced from + cPanel "Setup Python App → Environment variables"). This endpoint + is read-only. """ import Project.Sanad.config as cfg_mod key = getattr(cfg_mod, "GEMINI_API_KEY", "") or "" - # Detect where the value came from (persisted override vs module default) - try: - from Project.Sanad.config import load_config - stored = load_config().get("gemini", {}) or {} - source = "config_file" if stored.get("api_key") else "default" - except Exception: - source = "default" return { "has_key": bool(key), "masked": _mask_api_key(key), "length": len(key), - "source": source, + "source": "env:api", + "read_only": True, } @router.post("/api-key") async def update_api_key(payload: ApiKeyPayload): + """API key updates via dashboard are DISABLED. + + Source of truth is the cPanel "Setup Python App → Environment variables" + form (env var `api`). To rotate the key, edit that form and restart + uvicorn. The dashboard intentionally cannot bypass this. + """ + raise HTTPException( + 403, + "API key updates are disabled. Edit it in cPanel → Setup Python App " + "→ Environment variables → 'api', then restart uvicorn." + ) + + +# Original update logic kept below for reference but no longer reachable. +async def _legacy_update_api_key(payload: ApiKeyPayload): """Update the Gemini API key — persists to data/motions/config.json and hot-swaps the in-memory value so the next Gemini connect uses it. diff --git a/dashboard/static/index.html b/dashboard/static/index.html index 12e0b1d..d18f1c5 100644 --- a/dashboard/static/index.html +++ b/dashboard/static/index.html @@ -129,12 +129,13 @@
- +

Gemini API Key

- The key used by GeminiVoiceClient and the Live Gemini subprocess. - Saved to data/motions/config.json. Get a free key at + Read-only. Source of truth: cPanel → Setup Python App → + Environment variablesapi. To rotate, + edit that field and restart uvicorn. Get a free key at aistudio.google.com/app/apikey.
@@ -143,12 +144,6 @@
-
- - - - -
@@ -269,51 +264,20 @@ async function refreshApiKey(b){ if(r.has_key){ inp.value=r.masked||''; inp.placeholder=''; - src.textContent=r.source==='config_file'?'saved':'default'; - src.className='badge '+(r.source==='config_file'?'badge-ok':'badge-warn'); - msg.textContent=`Length: ${r.length} chars`; + src.textContent=r.source||'env'; + src.className='badge badge-ok'; + msg.textContent=`Length: ${r.length} chars · read-only (cPanel env)`; }else{ inp.value=''; inp.placeholder='(no key configured)'; src.textContent='empty'; src.className='badge badge-err'; - msg.textContent='No API key — paste one below to enable Gemini.'; + msg.textContent='No API key — set it in cPanel → Setup Python App → Environment variables → "api".'; } }catch(e){} if(b)btnDone(b); } -function toggleApiKeyVisibility(){ - const inp=document.getElementById('gm-key-new'); - inp.type=inp.type==='password'?'text':'password'; -} - -async function saveApiKey(b){ - const inp=document.getElementById('gm-key-new'); - const key=(inp.value||'').trim(); - const msg=document.getElementById('gm-key-msg'); - if(!key){toast('Paste a key first','err');return;} - if(!key.startsWith('AIza')){ - if(!confirm("Key doesn't start with 'AIza'. Gemini keys normally do. Save anyway?"))return; - } - btnLoad(b); - try{ - const r=await api('POST','/api/voice/api-key',{api_key:key}); - toast(`API key saved (${r.length} chars)`,'ok'); - inp.value=''; - inp.type='password'; - msg.textContent=r.message||'Saved. Click Connect to apply.'; - msg.style.color='var(--accent)'; - setTimeout(()=>{msg.style.color='var(--muted)';},4000); - await refreshApiKey(); - refreshStatus(); - }catch(e){ - msg.textContent='Save failed: '+(e.message||'unknown'); - msg.style.color='#f55'; - } - btnDone(b); -} - // Audio — only updates the mic/spk badges and mute-shortcut buttons that // still exist in the Voice & Audio cards. Heavier audio device picker // + G1 volume slider were removed with the Operations tab. diff --git a/gemini/client.py b/gemini/client.py index b300da0..a510f49 100644 --- a/gemini/client.py +++ b/gemini/client.py @@ -82,6 +82,20 @@ class GeminiVoiceClient: kwargs[key] = {"Content-Type": "application/json"} return kwargs + async def _safe_close_ws(self) -> None: + """Best-effort close of the current socket, guarded by a timeout. + + A half-dead socket whose close() hangs must never wedge the event + loop here, so the close itself is bounded. + """ + ws, self._ws = self._ws, None + if ws is None: + return + try: + await asyncio.wait_for(ws.close(), timeout=5) + except Exception: + pass + async def connect(self): uri = f"{GEMINI_WS_URI}?key={GEMINI_API_KEY}" try: @@ -100,30 +114,35 @@ class GeminiVoiceClient: "systemInstruction": {"parts": [{"text": self.system_prompt}]}, } } - await self._ws.send(json.dumps(setup)) - await self._ws.recv() # ACK + # Guard the app-level setup handshake with a timeout. websockets' + # own open_timeout only covers the HTTP upgrade, NOT this send/ACK. + # A socket that opens but never ACKs would otherwise block this + # await forever and freeze uvicorn's single event loop — every HTTP + # request with it. That is exactly what took the site down. + await asyncio.wait_for( + self._ws.send(json.dumps(setup)), timeout=GEMINI_WS_TIMEOUT) + await asyncio.wait_for(self._ws.recv(), timeout=GEMINI_WS_TIMEOUT) # ACK self._connected = True self._reconnect_attempts = 0 log.info("Connected to Gemini (%s)", GEMINI_MODEL) await bus.emit("voice.connected") + except asyncio.TimeoutError: + self._connected = False + await self._safe_close_ws() + log.warning("Gemini setup handshake timed out after %ss", GEMINI_WS_TIMEOUT) + raise except Exception: self._connected = False - self._ws = None + await self._safe_close_ws() log.exception("Failed to connect to Gemini") raise async def disconnect(self): - try: - if self._ws is not None: - await self._ws.close() - except Exception: - pass - finally: - self._ws = None - self._connected = False - self._owner = None - log.info("Disconnected from Gemini") - await bus.emit("voice.disconnected") + await self._safe_close_ws() + self._connected = False + self._owner = None + log.info("Disconnected from Gemini") + await bus.emit("voice.disconnected") async def _ensure_connected(self): """Reconnect if dropped, with bounded retries. @@ -172,13 +191,19 @@ class GeminiVoiceClient: } try: async with self._send_lock: - await self._ws.send(json.dumps(msg)) + await asyncio.wait_for( + self._ws.send(json.dumps(msg)), timeout=GEMINI_WS_TIMEOUT) return True except websockets.exceptions.ConnectionClosed: log.warning("send_audio_chunk: connection closed") self._connected = False await bus.emit("voice.error", reason="connection_closed") return False + except asyncio.TimeoutError: + log.warning("send_audio_chunk: send timed out after %ss", GEMINI_WS_TIMEOUT) + self._connected = False + await bus.emit("voice.error", reason="send_timeout") + return False except Exception: log.exception("send_audio_chunk failed") return False @@ -197,8 +222,8 @@ class GeminiVoiceClient: self._owner = owner try: return await self._send_text_inner(text) - except websockets.exceptions.ConnectionClosed: - log.warning("send_text: connection died on send — reconnecting once") + except (websockets.exceptions.ConnectionClosed, asyncio.TimeoutError): + log.warning("send_text: connection died/stalled on send — reconnecting once") self._connected = False if not await self._ensure_connected(): raise RuntimeError("Reconnect after send failure also failed.") @@ -215,7 +240,8 @@ class GeminiVoiceClient: } } async with self._send_lock: - await self._ws.send(json.dumps(request)) + await asyncio.wait_for( + self._ws.send(json.dumps(request)), timeout=GEMINI_WS_TIMEOUT) audio_chunks: list[bytes] = [] text_parts: list[str] = [] @@ -298,7 +324,8 @@ class GeminiVoiceClient: return False try: async with self._send_lock: - await self._ws.send(json.dumps(payload)) + await asyncio.wait_for( + self._ws.send(json.dumps(payload)), timeout=GEMINI_WS_TIMEOUT) return True except Exception: log.exception("raw_send failed")