feat: SSH dev routing, model registry UX, chat input toolbar, doc sync
Backend / infrastructure:
- cortex/tools/_projects.py (new): shared project alias registry with ssh_host
for workstation projects (aether_api, aether_frontend, aether_container)
- cortex/tools/git.py: all git tools route to workstation via SSH when ssh_host set
- cortex/tools/aider.py: aider_run SSH-routes to workstation using bash -l -c
- cortex/routers/local_llm.py: POST /api/models/{id}/edit AJAX endpoint — save
model edits without page reload or tab reset; returns JSON {ok, label, model_name}
- cortex/llm_client.py: remove Gemini CLI and Claude CLI backends; clean up
fallback chain and process group tracking (continuation of Gemini CLI removal)
- cortex/routers/auth.py: strip Claude/Gemini CLI auth status checks (CLI removed)
- cortex/routers/chat.py: remove legacy claude/gemini backend fields
- cortex/config.py: clean up CLI-related settings
- cortex/main.py: remove CLI lifecycle hooks
UI:
- cortex/static/local_llm.html: model edit forms now save via fetch() + toast;
stay on Models tab; update row header label in place on success
- cortex/static/index.html: restructure input area to column layout — textarea
above, compact toolbar below (Chat/Tools/Attach + Send); fixes dead space at
M/L/XL sizes; context panel "Role" → "Model" section label
- cortex/static/style.css: column input-area layout; #input-toolbar; flex:1 →
width:100% on textarea (fixes scrollHeight in column flex context); compact
send/stop button padding
- cortex/static/app.js: add XL (720px) to height cycle; default M (240px)
Docs:
- cortex/static/HELP.md: S/M/L → S/M/L/XL; add Rebuild to distill table; fix
"Role selector" references (no such UI); fix "your active role" → Chat role;
fix ⚡ toggle description; Model Registry section cleanup
- documentation/ARCH__BACKENDS.md: reflect CLI removal, current backend state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,76 +1,12 @@
|
||||
"""
|
||||
CLI auth status for both Claude and Gemini backends.
|
||||
|
||||
GET /auth/status — returns per-backend auth info and warning flags
|
||||
|
||||
Claude: warns when OAuth token is < WARN_HOURS from expiry (requires
|
||||
user to re-run `claude` to refresh via browser flow).
|
||||
Gemini: warns only when oauth_creds.json is missing or has no
|
||||
refresh_token (access token rotates automatically every ~1h).
|
||||
GET /auth/status — returns connectivity status for configured model backends.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth")
|
||||
|
||||
CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json"
|
||||
GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json"
|
||||
GEMINI_ACCTS = Path.home() / ".gemini" / "google_accounts.json"
|
||||
WARN_HOURS = 24 # no refresh token — warn a day ahead
|
||||
WARN_HOURS_REFRESH = 1 # refresh token present — only warn if CLI hasn't rotated in time
|
||||
|
||||
|
||||
def _claude_status() -> dict:
|
||||
try:
|
||||
data = json.loads(CLAUDE_CREDS.read_text())
|
||||
oauth = data["claudeAiOauth"]
|
||||
has_refresh = bool(oauth.get("refreshToken"))
|
||||
expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
hours_remaining = (expires_dt - now).total_seconds() / 3600
|
||||
# When a refresh token is present the CLI *should* auto-rotate the access
|
||||
# token, but sometimes it doesn't. Use a tight 1-hour window so a fresh
|
||||
# 8-hour token doesn't immediately trigger a warning, but a stale token
|
||||
# that the CLI missed will still surface before it expires.
|
||||
expired = hours_remaining <= 0
|
||||
threshold = WARN_HOURS_REFRESH if has_refresh else WARN_HOURS
|
||||
warning = expired or hours_remaining < threshold
|
||||
return {
|
||||
"ok": True,
|
||||
"has_refresh_token": has_refresh,
|
||||
"access_token_expires_at": expires_dt.isoformat(),
|
||||
"access_token_hours_remaining": round(hours_remaining, 1),
|
||||
"warning": warning,
|
||||
"expired": expired,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("claude auth check failed: %s", e)
|
||||
return {"ok": False, "error": str(e), "warning": True, "expired": False}
|
||||
|
||||
|
||||
def _gemini_status() -> dict:
|
||||
try:
|
||||
creds = json.loads(GEMINI_CREDS.read_text())
|
||||
if not creds.get("refresh_token"):
|
||||
return {"ok": True, "authenticated": False, "warning": True, "account": None}
|
||||
account = None
|
||||
try:
|
||||
accts = json.loads(GEMINI_ACCTS.read_text())
|
||||
account = accts.get("active")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "authenticated": True, "warning": False, "account": account}
|
||||
except FileNotFoundError:
|
||||
return {"ok": True, "authenticated": False, "warning": True, "account": None}
|
||||
except Exception as e:
|
||||
logger.warning("gemini auth check failed: %s", e)
|
||||
return {"ok": False, "error": str(e), "warning": True, "authenticated": False}
|
||||
|
||||
|
||||
async def _local_status(username: str = "scott") -> dict:
|
||||
"""Check reachability of the user's configured local model host."""
|
||||
@@ -104,7 +40,5 @@ async def _local_status(username: str = "scott") -> dict:
|
||||
@router.get("/status")
|
||||
async def auth_status() -> dict:
|
||||
return {
|
||||
"claude": _claude_status(),
|
||||
"gemini": _gemini_status(),
|
||||
"local": await _local_status(),
|
||||
}
|
||||
|
||||
@@ -21,11 +21,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _backend_label(backend: str, username: str, role: str = "chat") -> str:
|
||||
"""Human-readable label for the model that handled a request (legacy path)."""
|
||||
if backend == "claude":
|
||||
return "Claude"
|
||||
if backend == "gemini":
|
||||
return "Gemini"
|
||||
"""Human-readable label for the model that handled a request."""
|
||||
if backend == "local":
|
||||
cfg = model_registry.get_best_local_model(username, role)
|
||||
if cfg:
|
||||
@@ -52,7 +48,7 @@ class ChatRequest(BaseModel):
|
||||
message: str
|
||||
session_id: str | None = None
|
||||
tier: int | None = None
|
||||
model: str | None = None # legacy backend override ("claude"|"gemini"|"local")
|
||||
model: str | None = None # ignored — kept for API compatibility
|
||||
slot: str | None = None # Phase 3: explicit slot ("primary"|"backup_1"|"backup_2")
|
||||
chat_role: str = "chat" # active role: "chat"|"coder"|"research"|"distill" etc.
|
||||
include_long: bool = True
|
||||
@@ -64,10 +60,6 @@ class ChatRequest(BaseModel):
|
||||
attachment: Attachment | None = None # image attachment (text files injected client-side)
|
||||
|
||||
|
||||
class BackendRequest(BaseModel):
|
||||
primary: str # "claude", "gemini", or "local"
|
||||
|
||||
|
||||
class NoteRequest(BaseModel):
|
||||
session_id: str
|
||||
note: str
|
||||
@@ -183,9 +175,6 @@ async def _stream_chat(req: ChatRequest):
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
||||
|
||||
finally:
|
||||
# Ensure the LLM task is cancelled if the generator is torn down
|
||||
# (e.g. client disconnect or server shutdown). This propagates
|
||||
# CancelledError into _gemini() which kills the process group.
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
@@ -203,10 +192,6 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
)
|
||||
|
||||
|
||||
_BACKEND_CYCLE = ("claude", "gemini", "local")
|
||||
_BACKEND_FALLBACK = {"claude": "gemini", "gemini": "claude", "local": "claude"}
|
||||
|
||||
|
||||
def _request_user(request: Request) -> str | None:
|
||||
"""Extract username from JWT cookie, or None."""
|
||||
try:
|
||||
@@ -216,20 +201,6 @@ def _request_user(request: Request) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _local_model_info(request: Request) -> dict | None:
|
||||
"""Return the best local model {label, model_name} for the session user, or None."""
|
||||
username = _request_user(request)
|
||||
if not username:
|
||||
return None
|
||||
try:
|
||||
cfg = model_registry.get_best_local_model(username, "chat")
|
||||
if cfg:
|
||||
return {"label": cfg.get("label", ""), "model_name": cfg.get("model_name", "")}
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _chat_slot_models(username: str) -> list[dict]:
|
||||
"""Return [{slot, label, type}] for each configured slot in the chat role, primary first."""
|
||||
registry = model_registry.get_registry(username)
|
||||
@@ -279,7 +250,6 @@ async def get_backend(request: Request) -> dict:
|
||||
username = _request_user(request)
|
||||
chat_models = _chat_slot_models(username) if username else []
|
||||
available_roles = _available_roles_for_toggle(username) if username else []
|
||||
p = settings.primary_backend
|
||||
|
||||
orch_label = None
|
||||
if username:
|
||||
@@ -288,25 +258,9 @@ async def get_backend(request: Request) -> dict:
|
||||
orch_label = orch_cfg.get("label") or orch_cfg.get("model_name") or None
|
||||
|
||||
return {
|
||||
"chat_models": chat_models, # Phase 3: [{slot, label, type}] for chat-role slots
|
||||
"available_roles": available_roles, # kept for banner + backward compat
|
||||
"chat_models": chat_models,
|
||||
"available_roles": available_roles,
|
||||
"orchestrator_model": orch_label,
|
||||
# Legacy fields kept for backward compat
|
||||
"primary": p,
|
||||
"fallback": _BACKEND_FALLBACK.get(p, "claude"),
|
||||
"local_model": _local_model_info(request),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/backend")
|
||||
async def set_backend(req: BackendRequest, request: Request) -> dict:
|
||||
if req.primary not in _BACKEND_CYCLE:
|
||||
raise HTTPException(status_code=400, detail="primary must be 'claude', 'gemini', or 'local'")
|
||||
settings.primary_backend = req.primary
|
||||
return {
|
||||
"primary": req.primary,
|
||||
"fallback": _BACKEND_FALLBACK[req.primary],
|
||||
"local_model": _local_model_info(request),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -744,6 +744,53 @@ async def remove_custom_role_route(
|
||||
return RedirectResponse("/settings/models#roles", status_code=303)
|
||||
|
||||
|
||||
@router.post("/api/models/{model_id}/edit")
|
||||
async def edit_model_ajax(
|
||||
request: Request,
|
||||
model_id: str,
|
||||
mtype: str = Form(""),
|
||||
label: str = Form(""),
|
||||
model_name: str = Form(""),
|
||||
context_k: int = Form(0),
|
||||
max_rounds: int = Form(0),
|
||||
tools: int = Form(1),
|
||||
tags: str = Form(""),
|
||||
reasoning_budget_tokens: int = Form(0),
|
||||
host_id: str = Form(""),
|
||||
account_id: str = Form(""),
|
||||
credential_id: str = Form("cli"),
|
||||
) -> JSONResponse:
|
||||
"""AJAX: edit a model entry. Returns JSON {ok, label, model_name} on success."""
|
||||
username = _get_user(request)
|
||||
if not username:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
if not model_name.strip():
|
||||
return JSONResponse({"error": "Model name is required."}, status_code=400)
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
max_rounds_ = max_rounds or None
|
||||
tools_bool = tools != 0
|
||||
reasoning_budget_ = reasoning_budget_tokens or None
|
||||
if mtype == "local_openai":
|
||||
if not host_id.strip():
|
||||
return JSONResponse({"error": "Select a host for this model."}, status_code=400)
|
||||
reg.save_model(username, model_id, host_id, label, model_name, context_k, tag_list,
|
||||
max_rounds=max_rounds_, tools=tools_bool,
|
||||
reasoning_budget_tokens=reasoning_budget_)
|
||||
elif mtype == "gemini_api":
|
||||
reg.save_cloud_model(username, model_id, "google", model_name, label,
|
||||
account_id=account_id or None, context_k=context_k, tags=tag_list,
|
||||
max_rounds=max_rounds_, tools=tools_bool)
|
||||
elif mtype in ("claude_cli", "anthropic_api"):
|
||||
reg.save_cloud_model(username, model_id, "anthropic", model_name, label,
|
||||
credential_id=credential_id or "cli", context_k=context_k, tags=tag_list,
|
||||
max_rounds=max_rounds_, tools=tools_bool)
|
||||
else:
|
||||
return JSONResponse({"error": f"Unknown model type: {mtype}"}, status_code=400)
|
||||
display = label.strip() or model_name.strip()
|
||||
logger.info("model edited (ajax): %s / %s (%s)", username, display, mtype)
|
||||
return JSONResponse({"ok": True, "label": display, "model_name": model_name.strip()})
|
||||
|
||||
|
||||
@router.post("/api/models/role")
|
||||
async def set_role(request: Request) -> JSONResponse:
|
||||
"""AJAX: assign a model to a role priority slot.
|
||||
|
||||
Reference in New Issue
Block a user