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", {})
|
_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"))
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user