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", {})
_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"))

View File

@ -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.

View File

@ -129,12 +129,13 @@
<div class="tab-content active" id="tab-voice">
<div class="grid">
<!-- Gemini API Key -->
<!-- Gemini API Key (read-only — key now lives in cPanel env var only) -->
<div class="card card-full">
<h3>Gemini API Key</h3>
<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>.
Saved to <code>data/motions/config.json</code>. Get a free key at
Read-only. Source of truth: cPanel → <strong>Setup Python App</strong>
<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>.
</div>
<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>
<button class="btn btn-ghost btn-sm" onclick="refreshApiKey(this)" title="Reload masked key from server">Refresh</button>
</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>
@ -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.

View File

@ -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")