Initial commit — Cortex API + Inara identity
Cortex: FastAPI backend serving Inara via Claude/Gemini CLI backends. Includes SSE streaming chat, session persistence, Google Chat webhook handler, and Docker support. Inara: Identity files (persona, soul, protocols, memory, context tiers) mounted read-only into the container at runtime. Features in initial cut: - /chat endpoint with SSE keepalive + LLM fallback - Session store with rolling history window - Markdown rendering, copy-to-clipboard, links open in new tab - Stacked right-column input controls (height selector, enter toggle, note mode with public/private) — semi-hidden until textarea grows - /note endpoint for injecting public context into session history - Docker Compose config (local dev runs natively; Docker for server) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
183
cortex/llm_client.py
Normal file
183
cortex/llm_client.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track active Gemini process group IDs so we can kill them on shutdown
|
||||
_active_pgroups: set[int] = set()
|
||||
|
||||
|
||||
def _register_pgroup(pid: int) -> None:
|
||||
_active_pgroups.add(pid)
|
||||
|
||||
|
||||
def _unregister_pgroup(pid: int) -> None:
|
||||
_active_pgroups.discard(pid)
|
||||
|
||||
|
||||
async def cleanup() -> None:
|
||||
"""Kill any lingering Gemini process groups. Call from lifespan shutdown."""
|
||||
for pid in list(_active_pgroups):
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
logger.info("Shutdown: killed Gemini process group %d", pid)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
_active_pgroups.clear()
|
||||
|
||||
|
||||
async def complete(
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model: str | None = None,
|
||||
max_tokens: int = 2048,
|
||||
) -> tuple[str, str]:
|
||||
"""Returns (response_text, actual_backend_used)."""
|
||||
if model in ("claude", "gemini"):
|
||||
primary = model
|
||||
else:
|
||||
primary = settings.primary_backend
|
||||
|
||||
fallback = "gemini" if primary == "claude" else "claude"
|
||||
|
||||
try:
|
||||
response = await _dispatch(primary, system_prompt, messages, model)
|
||||
return response, primary
|
||||
except Exception as e:
|
||||
logger.warning("%s failed (%s) — falling back to %s", primary, e, fallback)
|
||||
response = await _dispatch(fallback, system_prompt, messages, None)
|
||||
return response, fallback
|
||||
|
||||
|
||||
async def _dispatch(
|
||||
backend: str,
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model: str | None,
|
||||
) -> str:
|
||||
if backend == "gemini":
|
||||
return await _gemini(system_prompt, messages)
|
||||
return await _claude(system_prompt, messages, model)
|
||||
|
||||
|
||||
async def _claude(system_prompt: str, messages: list[dict], model: str | None) -> str:
|
||||
cmd = [
|
||||
"claude", "--print",
|
||||
"--no-session-persistence",
|
||||
"--output-format", "text",
|
||||
]
|
||||
if model and model not in ("claude", "gemini"):
|
||||
cmd.extend(["--model", model])
|
||||
if system_prompt:
|
||||
cmd.extend(["--system-prompt", system_prompt])
|
||||
cmd.append(_build_conversation(messages))
|
||||
return await _run(cmd, timeout=settings.timeout_claude)
|
||||
|
||||
|
||||
async def _gemini(system_prompt: str, messages: list[dict]) -> str:
|
||||
# Gemini CLI spawns MCP child processes that keep stdout pipes open after responding.
|
||||
# start_new_session=True puts the whole tree in its own process group so
|
||||
# os.killpg kills everything at once on timeout.
|
||||
cmd = [
|
||||
"gemini",
|
||||
"--output-format", "text",
|
||||
"--extensions", "", # disable all extensions — prevents MCP child processes
|
||||
"-p", _build_prompt(system_prompt, messages),
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError("gemini not found in PATH")
|
||||
|
||||
_register_pgroup(proc.pid)
|
||||
timeout = settings.timeout_gemini
|
||||
try:
|
||||
stdout_bytes, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
raw = stdout_bytes.decode()
|
||||
except asyncio.TimeoutError:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise RuntimeError(f"Gemini timed out after {timeout}s")
|
||||
except asyncio.CancelledError:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
_unregister_pgroup(proc.pid)
|
||||
|
||||
clean = _clean_gemini_output(raw)
|
||||
if not clean:
|
||||
raise RuntimeError("Gemini returned an empty response")
|
||||
return clean
|
||||
|
||||
|
||||
# Lines Gemini CLI writes to stdout that are not part of the actual response
|
||||
_GEMINI_NOISE = (
|
||||
"Loaded cached credentials",
|
||||
"Loading extension:",
|
||||
"Server '",
|
||||
"Listening for",
|
||||
"Model is overloaded",
|
||||
"High demand",
|
||||
"Retrying",
|
||||
"retrying",
|
||||
"429",
|
||||
"quota",
|
||||
)
|
||||
|
||||
|
||||
def _clean_gemini_output(text: str) -> str:
|
||||
lines = [
|
||||
line for line in text.splitlines()
|
||||
if not any(line.strip().startswith(p) for p in _GEMINI_NOISE)
|
||||
]
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
async def _run(cmd: list[str], timeout: int = 60) -> str:
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"
|
||||
raise RuntimeError(f"{cmd[0]} failed: {detail}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _build_conversation(messages: list[dict]) -> str:
|
||||
"""Conversation only — used for Claude (system prompt passed separately)."""
|
||||
parts = []
|
||||
prior = messages[:-1]
|
||||
if prior:
|
||||
history_lines = []
|
||||
for msg in prior:
|
||||
label = "Scott" if msg["role"] == "user" else "Inara"
|
||||
history_lines.append(f"{label}: {msg['content']}")
|
||||
parts.append("<conversation>\n" + "\n\n".join(history_lines) + "\n</conversation>")
|
||||
parts.append(messages[-1]["content"] if messages else "")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _build_prompt(system_prompt: str, messages: list[dict]) -> str:
|
||||
"""Full prompt with system context embedded — used for Gemini."""
|
||||
parts = []
|
||||
if system_prompt:
|
||||
parts.append(f"<system>\n{system_prompt}\n</system>")
|
||||
parts.append(_build_conversation(messages))
|
||||
return "\n\n".join(parts)
|
||||
Reference in New Issue
Block a user