Update 2026-07-04 23:29:22

This commit is contained in:
kassam 2026-07-04 23:29:23 +04:00
parent e3aa48660d
commit a40b8fca0b
4 changed files with 82 additions and 82 deletions

View File

@ -96,10 +96,14 @@ _CORE_CFG = _load_core_config()
_GEMINI = _CORE_CFG.get("gemini_defaults", {}) _GEMINI = _CORE_CFG.get("gemini_defaults", {})
_AUDIO = _CORE_CFG.get("audio_defaults", {}) _AUDIO = _CORE_CFG.get("audio_defaults", {})
# -- Gemini defaults (override via data/motions/config.json or env) -- # -- Gemini API key — STRICT env-only ----
GEMINI_API_KEY = os.environ.get( # Single source of truth: cPanel "Setup Python App → Environment variables" → `api`.
"SANAD_GEMINI_API_KEY", # start.sh exports it before launching uvicorn (see start.sh step 3, which parses
_GEMINI.get("api_key", "")) # ~/.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( GEMINI_MODEL = os.environ.get(
"SANAD_GEMINI_MODEL", "SANAD_GEMINI_MODEL",
"models/" + _GEMINI.get("model_live", "gemini-2.5-flash-native-audio-preview-12-2025")) "models/" + _GEMINI.get("model_live", "gemini-2.5-flash-native-audio-preview-12-2025"))

View File

@ -134,33 +134,38 @@ class ApiKeyPayload(BaseModel):
async def get_api_key(): async def get_api_key():
"""Return the current Gemini API key in masked form. """Return the current Gemini API key in masked form.
Never returns the full key. Response: The key now comes EXCLUSIVELY from the `api` env var (sourced from
{ cPanel "Setup Python App → Environment variables"). This endpoint
"has_key": true, is read-only.
"masked": "AIza***...kqf8",
"length": 39,
"source": "config_file" | "default"
}
""" """
import Project.Sanad.config as cfg_mod import Project.Sanad.config as cfg_mod
key = getattr(cfg_mod, "GEMINI_API_KEY", "") or "" 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 { return {
"has_key": bool(key), "has_key": bool(key),
"masked": _mask_api_key(key), "masked": _mask_api_key(key),
"length": len(key), "length": len(key),
"source": source, "source": "env:api",
"read_only": True,
} }
@router.post("/api-key") @router.post("/api-key")
async def update_api_key(payload: ApiKeyPayload): 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 """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. hot-swaps the in-memory value so the next Gemini connect uses it.

View File

@ -129,12 +129,13 @@
<div class="tab-content active" id="tab-voice"> <div class="tab-content active" id="tab-voice">
<div class="grid"> <div class="grid">
<!-- Gemini API Key --> <!-- Gemini API Key (read-only — key now lives in cPanel env var only) -->
<div class="card card-full"> <div class="card card-full">
<h3>Gemini API Key</h3> <h3>Gemini API Key</h3>
<div style="font-size:.72rem;color:var(--muted);margin-bottom:.4rem"> <div style="font-size:.72rem;color:var(--muted);margin-bottom:.4rem">
The key used by <strong>GeminiVoiceClient</strong> and the <strong>Live Gemini subprocess</strong>. Read-only. Source of truth: cPanel → <strong>Setup Python App</strong>
Saved to <code>data/motions/config.json</code>. Get a free key at <strong>Environment variables</strong><code>api</code>. To rotate,
edit that field and restart uvicorn. Get a free key at
<a href="https://aistudio.google.com/app/apikey" target="_blank" style="color:var(--accent)">aistudio.google.com/app/apikey</a>. <a href="https://aistudio.google.com/app/apikey" target="_blank" style="color:var(--accent)">aistudio.google.com/app/apikey</a>.
</div> </div>
<div class="row" style="align-items:center;gap:.4rem"> <div class="row" style="align-items:center;gap:.4rem">
@ -143,12 +144,6 @@
<span id="gm-key-source" class="badge" style="font-size:.65rem"></span> <span id="gm-key-source" class="badge" style="font-size:.65rem"></span>
<button class="btn btn-ghost btn-sm" onclick="refreshApiKey(this)" title="Reload masked key from server">Refresh</button> <button class="btn btn-ghost btn-sm" onclick="refreshApiKey(this)" title="Reload masked key from server">Refresh</button>
</div> </div>
<div class="row" style="align-items:center;gap:.4rem;margin-top:.4rem">
<label style="min-width:70px">New key</label>
<input id="gm-key-new" type="password" placeholder="Paste new AIza... key here" style="flex:1;font-family:monospace" autocomplete="off">
<button class="btn btn-ghost btn-sm" onclick="toggleApiKeyVisibility()" title="Show/hide while typing">👁</button>
<button class="btn btn-primary btn-sm" onclick="saveApiKey(this)" title="Validate, save to config.json, hot-swap in memory">Save</button>
</div>
<div id="gm-key-msg" style="font-size:.7rem;margin-top:.3rem;color:var(--muted)"></div> <div id="gm-key-msg" style="font-size:.7rem;margin-top:.3rem;color:var(--muted)"></div>
</div> </div>
@ -269,51 +264,20 @@ async function refreshApiKey(b){
if(r.has_key){ if(r.has_key){
inp.value=r.masked||''; inp.value=r.masked||'';
inp.placeholder=''; inp.placeholder='';
src.textContent=r.source==='config_file'?'saved':'default'; src.textContent=r.source||'env';
src.className='badge '+(r.source==='config_file'?'badge-ok':'badge-warn'); src.className='badge badge-ok';
msg.textContent=`Length: ${r.length} chars`; msg.textContent=`Length: ${r.length} chars · read-only (cPanel env)`;
}else{ }else{
inp.value=''; inp.value='';
inp.placeholder='(no key configured)'; inp.placeholder='(no key configured)';
src.textContent='empty'; src.textContent='empty';
src.className='badge badge-err'; 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){} }catch(e){}
if(b)btnDone(b); 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 // Audio — only updates the mic/spk badges and mute-shortcut buttons that
// still exist in the Voice & Audio cards. Heavier audio device picker // still exist in the Voice & Audio cards. Heavier audio device picker
// + G1 volume slider were removed with the Operations tab. // + G1 volume slider were removed with the Operations tab.

View File

@ -82,6 +82,20 @@ class GeminiVoiceClient:
kwargs[key] = {"Content-Type": "application/json"} kwargs[key] = {"Content-Type": "application/json"}
return kwargs 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): async def connect(self):
uri = f"{GEMINI_WS_URI}?key={GEMINI_API_KEY}" uri = f"{GEMINI_WS_URI}?key={GEMINI_API_KEY}"
try: try:
@ -100,30 +114,35 @@ class GeminiVoiceClient:
"systemInstruction": {"parts": [{"text": self.system_prompt}]}, "systemInstruction": {"parts": [{"text": self.system_prompt}]},
} }
} }
await self._ws.send(json.dumps(setup)) # Guard the app-level setup handshake with a timeout. websockets'
await self._ws.recv() # ACK # 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._connected = True
self._reconnect_attempts = 0 self._reconnect_attempts = 0
log.info("Connected to Gemini (%s)", GEMINI_MODEL) log.info("Connected to Gemini (%s)", GEMINI_MODEL)
await bus.emit("voice.connected") 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: except Exception:
self._connected = False self._connected = False
self._ws = None await self._safe_close_ws()
log.exception("Failed to connect to Gemini") log.exception("Failed to connect to Gemini")
raise raise
async def disconnect(self): async def disconnect(self):
try: await self._safe_close_ws()
if self._ws is not None: self._connected = False
await self._ws.close() self._owner = None
except Exception: log.info("Disconnected from Gemini")
pass await bus.emit("voice.disconnected")
finally:
self._ws = None
self._connected = False
self._owner = None
log.info("Disconnected from Gemini")
await bus.emit("voice.disconnected")
async def _ensure_connected(self): async def _ensure_connected(self):
"""Reconnect if dropped, with bounded retries. """Reconnect if dropped, with bounded retries.
@ -172,13 +191,19 @@ class GeminiVoiceClient:
} }
try: try:
async with self._send_lock: 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 return True
except websockets.exceptions.ConnectionClosed: except websockets.exceptions.ConnectionClosed:
log.warning("send_audio_chunk: connection closed") log.warning("send_audio_chunk: connection closed")
self._connected = False self._connected = False
await bus.emit("voice.error", reason="connection_closed") await bus.emit("voice.error", reason="connection_closed")
return False 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: except Exception:
log.exception("send_audio_chunk failed") log.exception("send_audio_chunk failed")
return False return False
@ -197,8 +222,8 @@ class GeminiVoiceClient:
self._owner = owner self._owner = owner
try: try:
return await self._send_text_inner(text) return await self._send_text_inner(text)
except websockets.exceptions.ConnectionClosed: except (websockets.exceptions.ConnectionClosed, asyncio.TimeoutError):
log.warning("send_text: connection died on send — reconnecting once") log.warning("send_text: connection died/stalled on send — reconnecting once")
self._connected = False self._connected = False
if not await self._ensure_connected(): if not await self._ensure_connected():
raise RuntimeError("Reconnect after send failure also failed.") raise RuntimeError("Reconnect after send failure also failed.")
@ -215,7 +240,8 @@ class GeminiVoiceClient:
} }
} }
async with self._send_lock: 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] = [] audio_chunks: list[bytes] = []
text_parts: list[str] = [] text_parts: list[str] = []
@ -298,7 +324,8 @@ class GeminiVoiceClient:
return False return False
try: try:
async with self._send_lock: 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 return True
except Exception: except Exception:
log.exception("raw_send failed") log.exception("raw_send failed")