Update 2026-07-04 23:29:22
This commit is contained in:
parent
e3aa48660d
commit
a40b8fca0b
12
config.py
12
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"))
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user